The controller plans config, templates and moved providers' provisions, hands bus accounts to the vault and judges the new manifest words, on the feature's SDK and node-engine validator (feature 57, config and provisioning).
446 lines
20 KiB
Go
446 lines
20 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/nats-io/nats.go"
|
|
|
|
"github.com/novox/mesh-controller/internal/conditions"
|
|
"github.com/novox/mesh-controller/internal/link"
|
|
"github.com/novox/mesh-controller/internal/secrets"
|
|
)
|
|
|
|
// A module's own secret given at the operator's desk (novox/hq ADR 0259 §10, ADR 0277).
|
|
//
|
|
// mesh-controller secret ask <node> <module> <name> [--at <desk>]
|
|
//
|
|
// **The value never passes through whoever asked for it.** An agent, or the operator at the mesh MCP
|
|
// server, calls `secret-ask` (or `give`) with the machine, the module, the secret's name and the desk — never
|
|
// a value. The controller makes a sealing keypair for this one call, asks the desk's `node-launcher.secret` to
|
|
// prompt the operator without showing what is typed, and is answered with what was typed **sealed to that
|
|
// key**: no plaintext on the bus, in a runtime's log or in any call's record. It opens it here, seals it to the
|
|
// module's machine exactly as `secret accept` does, and forgets it. What it answers says only that the
|
|
// value was taken, or why not.
|
|
//
|
|
// **Bounded, and the prompt says who asked** (ADR 0277): one open prompt per secret and few an hour, read from
|
|
// the store before the prompt opens (inventory.OpenSecretAsk), so an agent cannot keep a prompt in front of the
|
|
// operator until they type. The prompt names the module, the secret, the machine and who asked — the caller as
|
|
// the bus named it, never a word the caller chose — written by the desk's launcher from those names alone.
|
|
//
|
|
// **What remains** (ADR 0234's accepted residual risk): on an X11 desk any program of the operator's
|
|
// account can read the keys as they are typed. And a program that calls the desk's prompt itself, with a
|
|
// key of its own, is answered with what the operator typed into a prompt they did not ask for — as it could
|
|
// draw a window of its own. The prompt says who asks and for what, so the operator types only into a
|
|
// prompt they started.
|
|
|
|
// deskPromptWithin is how long the prompt waits for the operator: below the runtime's thirty seconds for
|
|
// one call, as the launcher's menu is.
|
|
const deskPromptWithin = 25
|
|
|
|
// deskGive is the desk path, its four reaches given so a test needs no store and no bus.
|
|
type deskGive struct {
|
|
// declares refuses a module or a secret the mesh would refuse, before anybody is asked to type.
|
|
declares func(module, name string) error
|
|
// known refuses a machine the mesh does not know, before anybody is asked to type; nil knows every one
|
|
// (a test that does not look).
|
|
known func(machine string) error
|
|
// trusted says a module runs as an account of its own: its secret is never taken at a desk (below). Nil is
|
|
// never (a test that does not look).
|
|
trusted func(module string) (bool, error)
|
|
// ask asks one machine's node-launcher.secret and answers its result, or the holder's refusal.
|
|
ask func(machine string, args map[string]any) (json.RawMessage, error)
|
|
// promptFor is the one line the secret's declaration asks for it as (novox/hq issue 789): read from the
|
|
// controller's own catalogue, never from the call or the module's runtime. Nil, or an empty answer, asks by
|
|
// name alone, as the desk does today.
|
|
promptFor func(module, name string) (string, error)
|
|
// accept seals the value as `secret accept` does, and says whether it lives until the module's start.
|
|
accept func(value string) (untilStart bool, err error)
|
|
// record writes the act in the hand-act log.
|
|
record func(link.HandAct) error
|
|
// announce raises the condition that says a module's own secret was given (secretGivenObservation), on
|
|
// every channel; nil announces nothing (a test that does not look).
|
|
announce func(node, module, name, how string) error
|
|
// askedBy is who asked, as the bus named the caller: said in the prompt and recorded.
|
|
askedBy string
|
|
// open records the ask and holds the bounds (one open per secret, few an hour), answering the record's id;
|
|
// nil keeps no record (a test that does not look). end closes it with how it ended.
|
|
open func(node, module, name, desk string) (int64, error)
|
|
end func(id int64, outcome string) error
|
|
// vault, when set, is the path of a module whose secrets are filled the new way (vault_secrets.go): the vault
|
|
// records the ask, holds its bounds and opens what is typed; open, end and accept are not used.
|
|
vault vaultAsker
|
|
}
|
|
|
|
// askedByName is the caller as the prompt names it: the first clause of what the bus said, in the characters
|
|
// a name has, at most 80 of them. The desk's launcher refuses anything else, so no words of the caller's own
|
|
// reach the prompt.
|
|
func askedByName(caller string) string {
|
|
first, _, _ := strings.Cut(caller, ",")
|
|
var b strings.Builder
|
|
for _, r := range strings.TrimSpace(first) {
|
|
switch {
|
|
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '.', r == '_', r == '/', r == '@',
|
|
r == '-', r == ' ':
|
|
b.WriteRune(r)
|
|
default:
|
|
b.WriteRune('-')
|
|
}
|
|
if b.Len() >= 80 {
|
|
break
|
|
}
|
|
}
|
|
name := strings.TrimSpace(b.String())
|
|
if name == "" || strings.HasPrefix(name, "-") {
|
|
return "an unnamed caller"
|
|
}
|
|
return name
|
|
}
|
|
|
|
// errNothingGiven is a prompt dismissed, or not answered in time: nothing changes.
|
|
var errNothingGiven = errors.New("nothing was given")
|
|
|
|
// give asks the desk for the value and seals it; it answers the words said to the caller.
|
|
func (d deskGive) give(node, module, name, desk string) (string, error) {
|
|
for what, v := range map[string]string{"the machine": node, "the module": module, "the secret": name, "the desk": desk} {
|
|
if strings.TrimSpace(v) == "" {
|
|
return "", fmt.Errorf("%s is not named", what)
|
|
}
|
|
}
|
|
if d.known != nil {
|
|
for what, machine := range map[string]string{"the machine the secret is for": node, "the desk": desk} {
|
|
if err := d.known(machine); err != nil {
|
|
return "", fmt.Errorf("nobody was asked to type anything: %s, %s, is not a machine this mesh knows: %w",
|
|
what, machine, err)
|
|
}
|
|
}
|
|
}
|
|
if err := d.declares(module, name); err != nil {
|
|
return "", fmt.Errorf("nobody was asked to type anything: %w", err)
|
|
}
|
|
// **A trusted party's secret is not taken at a desk** (the confirmation review of 2026-10-09, N1-give). The
|
|
// prompt's answer comes back over the desk machine's bus, on a subject that machine's runtime answers — and
|
|
// on a desk machine agents run as the operator, who holds that runtime's credential. An agent could answer
|
|
// first, with a bot token of its own sealed to this call's key, and the channel the operator's answers are
|
|
// proven on would be the agent's. So the value of a module running as its own account is typed at the
|
|
// controller's terminal, where no bus carries it.
|
|
if d.trusted != nil {
|
|
trusted, err := d.trusted(module)
|
|
if err != nil {
|
|
return "", fmt.Errorf("nobody was asked to type anything: whether %s is a trusted party could not be read: %w", module, err)
|
|
}
|
|
if trusted {
|
|
return "", fmt.Errorf("nobody was asked to type anything: %s runs as an account of its own and proves the "+
|
|
"operator's answers, so its %s is given at the controller's terminal alone — there, run `mesh-controller "+
|
|
"secret accept %s %s %s` and type it at its prompt. A desk's prompt is answered over the desk machine's "+
|
|
"bus, where an agent may answer first (novox/hq ADR 0259 §10)", module, name, node, module, name)
|
|
}
|
|
}
|
|
if d.vault != nil {
|
|
return d.giveToVault(node, module, name, desk)
|
|
}
|
|
// The bounds, read and kept before anybody is asked to type (novox/hq ADR 0277): one open prompt per secret,
|
|
// few an hour. How the ask ends is recorded whatever happens below.
|
|
outcome := "failed"
|
|
if d.open != nil {
|
|
id, err := d.open(node, module, name, desk)
|
|
if err != nil {
|
|
return "", fmt.Errorf("nobody was asked to type anything: %w", err)
|
|
}
|
|
defer func() {
|
|
if d.end != nil {
|
|
_ = d.end(id, outcome)
|
|
}
|
|
}()
|
|
}
|
|
public, private, err := secrets.Keypair()
|
|
if err != nil {
|
|
return "", fmt.Errorf("no key could be made to take the value: %w", err)
|
|
}
|
|
// By name, never by words: the holder writes the prompt from these, and says the controller asks, which
|
|
// the bus alone makes true (broker.ControllerOnly). Who asked is the bus's word on the caller, cut to a
|
|
// name's characters — never an argument of the call. `what` is the catalogue's own words for the secret
|
|
// (novox/hq issue 789), never a word the caller chose; omitted when the module declares none, so an old
|
|
// desk and an old module behave as today.
|
|
args := map[string]any{
|
|
"module": module,
|
|
"secret": name,
|
|
"node": node,
|
|
"asked_by": askedByName(d.askedBy),
|
|
"seal_to": public,
|
|
"timeout_seconds": deskPromptWithin,
|
|
}
|
|
if d.promptFor != nil {
|
|
what, err := d.promptFor(module, name)
|
|
if err != nil {
|
|
outcome = "refused"
|
|
return "", fmt.Errorf("nobody was asked to type anything: %w", err)
|
|
}
|
|
if what != "" {
|
|
args["what"] = what
|
|
}
|
|
}
|
|
raw, err := d.ask(desk, args)
|
|
if err != nil {
|
|
outcome = "refused"
|
|
return "", fmt.Errorf("the desk on %s could not be asked: %w", desk, err)
|
|
}
|
|
var answer struct {
|
|
Sealed string `json:"sealed"`
|
|
Cancelled bool `json:"cancelled"`
|
|
TimedOut bool `json:"timed_out"`
|
|
}
|
|
if err := json.Unmarshal(raw, &answer); err != nil {
|
|
return "", fmt.Errorf("the desk on %s answered something that is not the prompt's answer", desk)
|
|
}
|
|
switch {
|
|
case answer.TimedOut:
|
|
outcome = "timed-out"
|
|
return "", fmt.Errorf("%w: the prompt on %s was not answered within %d seconds", errNothingGiven, desk, deskPromptWithin)
|
|
case answer.Cancelled:
|
|
outcome = "dismissed"
|
|
return "", fmt.Errorf("%w: the prompt on %s was dismissed", errNothingGiven, desk)
|
|
case answer.Sealed == "":
|
|
return "", fmt.Errorf("the desk on %s answered no sealed value", desk)
|
|
}
|
|
opened, err := secrets.Open(private, answer.Sealed)
|
|
if err != nil {
|
|
// Never the value, never what failed to open: only that it was not sealed to this call.
|
|
return "", fmt.Errorf("the desk on %s answered a value not sealed to this call; nothing was taken", desk)
|
|
}
|
|
value := asSupplied(string(opened))
|
|
for i := range opened {
|
|
opened[i] = 0
|
|
}
|
|
if strings.TrimSpace(value) == "" {
|
|
outcome = "empty"
|
|
return "", fmt.Errorf("%w: the prompt on %s was answered empty", errNothingGiven, desk)
|
|
}
|
|
untilStart, err := d.accept(value)
|
|
value = ""
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
outcome = "given"
|
|
act := link.HandAct{Verb: "secret accept", Args: []string{node, module, name, "--at-desk", desk},
|
|
Why: fmt.Sprintf("the operator gave %s for %s on %s at the desk on %s, asked by %s", name, module, node, desk,
|
|
askedByName(d.askedBy)),
|
|
Cause: "given-at-the-desk"}
|
|
recorded := ""
|
|
if err := d.record(act); err != nil {
|
|
recorded = fmt.Sprintf("\n this act could NOT be recorded in the hand-act log, and is done anyway: %v", err)
|
|
}
|
|
if d.announce != nil {
|
|
if err := d.announce(node, module, name, "at the desk on "+desk); err != nil {
|
|
recorded += fmt.Sprintf("\n this change could NOT be announced on the operator's channels: %v", err)
|
|
}
|
|
}
|
|
words := fmt.Sprintf("%s on %s now holds %q, given at the desk on %s and sealed to %s; the mesh cannot read it "+
|
|
"back.\n run `push %s` to send it", module, node, name, desk, node, node)
|
|
if untilStart {
|
|
words += fmt.Sprintf("\n it lives until %s next starts well under the mesh, and is then replaced with a value "+
|
|
"the mesh makes (ADR 0228)", module)
|
|
}
|
|
return words + recorded, nil
|
|
}
|
|
|
|
// askAtDesk is `secret ask <node> <module> <name> [--at <machine>]`, and the terminal's `secret accept … --at-desk
|
|
// <machine>`: the desk path, on this controller's stores and bus. The desk is the module's machine unless named.
|
|
func askAtDesk(ctx context.Context, node, module, name, desk string) error {
|
|
if desk == "" {
|
|
desk = node
|
|
}
|
|
open, err := openStores(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer open.Close()
|
|
askedBy := link.Caller()
|
|
d := deskGive{
|
|
askedBy: askedBy,
|
|
open: func(node, module, name, desk string) (int64, error) {
|
|
return open.inventory.OpenSecretAsk(ctx, node, module, name, askedByName(link.Caller()), desk)
|
|
},
|
|
end: func(id int64, outcome string) error { return open.inventory.EndSecretAsk(ctx, id, outcome) },
|
|
declares: func(module, name string) error { return open.inventory.DeclaresOwnSecret(ctx, module, name) },
|
|
known: func(machine string) error {
|
|
_, err := open.inventory.NodeByName(ctx, machine)
|
|
return err
|
|
},
|
|
trusted: func(module string) (bool, error) { return open.inventory.RunsAsItsOwnAccount(ctx, module) },
|
|
promptFor: func(module, name string) (string, error) { return open.inventory.OwnSecretPrompt(ctx, module, name) },
|
|
ask: func(machine string, args map[string]any) (json.RawMessage, error) {
|
|
var result json.RawMessage
|
|
err := onTheBus(func(conn *nats.Conn) error {
|
|
answer, err := link.AskSeatTool(ctx, conn, "node-launcher", "secret", machine, args,
|
|
time.Duration(deskPromptWithin+5)*time.Second)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if answer.Error != "" {
|
|
return errors.New(answer.Error)
|
|
}
|
|
result = answer.Result
|
|
return nil
|
|
})
|
|
return result, err
|
|
},
|
|
accept: func(value string) (bool, error) {
|
|
return open.inventory.AcceptGivenSecret(ctx, node, module, name, value)
|
|
},
|
|
record: func(act link.HandAct) error {
|
|
return onTheBus(func(conn *nats.Conn) error {
|
|
_, err := link.RecordHandAct(ctx, conn, act)
|
|
return err
|
|
})
|
|
},
|
|
announce: func(node, module, name, how string) error {
|
|
return announceSecretGiven(ctx, node, module, name, how, askedBy)
|
|
},
|
|
}
|
|
// A module whose secrets are filled the new way is asked for by the vault (ADR 0349 clause 9); every other
|
|
// keeps today's path (clause 17).
|
|
ours, err := filledTheNewWay(module)
|
|
if err != nil {
|
|
return fmt.Errorf("nothing was asked: %w", err)
|
|
}
|
|
if ours {
|
|
d.vault = theVault(ctx)
|
|
}
|
|
words, err := d.give(node, module, name, desk)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
fmt.Println(words)
|
|
return nil
|
|
}
|
|
|
|
// kindSecretGiven is the condition every value given for a module's own secret raises (the review of 2026-10-09,
|
|
// M4): on every channel, so a bot token changed by somebody else — a channel that now answers for them — is
|
|
// heard of. It stays until the operator silences or clears it.
|
|
const kindSecretGiven = "secret-given"
|
|
|
|
// secretGivenObservation is that condition: which secret, of which module on which machine, how and when.
|
|
// The summary, for whoever looks closer, names the secret and the time. The words the operator reads are
|
|
// held to the plain rule (conditions.PlainWords): no clock time — the channel says when, in the operator's
|
|
// time — and the secret's name said as words. Words that broke the rule were replaced by the keeper with
|
|
// "needs a look … a problem it calls secret given" (hq issue 359), which told the operator nothing.
|
|
func secretGivenObservation(node, module, name, how string, at time.Time) conditions.Observation {
|
|
key := node + "." + module + "." + name
|
|
where := module + " on " + node
|
|
// Within the bounds whatever the names' length: the module and machine, else the module, else the machine.
|
|
headline := "New secret given for " + where
|
|
for _, h := range []string{"New secret given for " + module, "New secret given on " + node} {
|
|
if len(headline) > conditions.HeadlineMax {
|
|
headline = h
|
|
}
|
|
}
|
|
resolved := "You saw that " + module + " was given a new secret"
|
|
if len(resolved) > conditions.HeadlineMax+20 {
|
|
resolved = "You saw that a new secret was given on " + node
|
|
}
|
|
return conditions.Observation{Scope: conditions.ScopeMachine, ID: key, Token: kindSecretGiven, Kind: kindSecretGiven,
|
|
Machine: node, Severity: conditions.Urgent, Source: kindSecretGiven,
|
|
Summary: fmt.Sprintf("%s of %s on %s was given %s at %s", name, module, node, how,
|
|
at.Local().Format("2006-01-02 15:04")),
|
|
Headline: headline,
|
|
Explanation: fmt.Sprintf("The secret %s of %s was given %s. If you gave it, nothing else is needed. If you "+
|
|
"did not, somebody else now holds what %s acts with.", secretNameWords(name), where, how, module),
|
|
Needs: "silence this if you just gave it; if you did not, give it again yourself so that only you hold it.",
|
|
Resolved: resolved,
|
|
Actions: []conditions.Action{conditions.SilenceAction(conditions.Key(conditions.ScopeMachine, key, kindSecretGiven))}}
|
|
}
|
|
|
|
// secretNameWords is a secret's name as the operator reads it: "telegram-token" is "telegram token".
|
|
func secretNameWords(name string) string {
|
|
return strings.Join(strings.FieldsFunc(name, func(r rune) bool { return r == '-' || r == '_' || r == '.' }), " ")
|
|
}
|
|
|
|
// tellWithin is how long the operator channel's `tell` is given to answer: well under a human's patience,
|
|
// and short because nothing in the call waits on the operator — it asks nothing (novox/hq ADR 0380).
|
|
const tellWithin = 10 * time.Second
|
|
|
|
// askOperatorChannel asks the operator channel's seat (the holder issue 797 gives it) its `tell` verb,
|
|
// through the controller's grant (novox/hq ADR 0380 §8), over this controller's own bus connection; a seam
|
|
// a test replaces whole. The holder's own refusal is an error naming it; an absent holder or a refused
|
|
// grant answer as link.ErrNothingServes or link.ErrGrantRefused (checked with errors.Is), so the caller can
|
|
// tell the two apart from any other failure.
|
|
var askOperatorChannel = func(ctx context.Context, args map[string]any) (json.RawMessage, error) {
|
|
var result json.RawMessage
|
|
err := onTheBus(func(conn *nats.Conn) error {
|
|
answer, err := link.AskMeshSeatTool(ctx, conn, "operator-channel", "tell", args, tellWithin)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if answer.Error != "" {
|
|
return fmt.Errorf("operator-channel.tell refused: %s", answer.Error)
|
|
}
|
|
result = answer.Result
|
|
return nil
|
|
})
|
|
return result, err
|
|
}
|
|
|
|
// announceSecretGiven tells the operator, once, that a module's own secret was given (novox/hq ADR 0380):
|
|
// one plain message through the operator channel's `tell`, naming the secret, the module, the machine,
|
|
// where and when it was given and who asked — asking nothing, and opening no condition. **The controller's
|
|
// change waits for that verb at run time, not at merge** (clause 9): while nothing on the bus yet serves it,
|
|
// or the controller's own grant is refused, the secret-given condition is raised exactly as before ADR 0380
|
|
// — any other failure (the holder itself refusing, or answering something unreadable) is the announcement's
|
|
// own failure, said to the caller as it always was.
|
|
func announceSecretGiven(ctx context.Context, node, module, name, how, askedBy string) error {
|
|
at := time.Now()
|
|
body := map[string]any{
|
|
"headline": fmt.Sprintf("A password was entered for %s's %s on %s", module, secretNameWords(name), node),
|
|
"subject": node,
|
|
"lines": []string{
|
|
fmt.Sprintf("%s at %s, asked by %s.", how, at.Local().Format("15:04"), askedByName(askedBy)),
|
|
"If that was not you, give it again yourself at the controller's terminal.",
|
|
},
|
|
}
|
|
_, told := askOperatorChannel(ctx, body)
|
|
switch {
|
|
case told == nil:
|
|
clearSecretGivenConditions(ctx)
|
|
return nil
|
|
case errors.Is(told, link.ErrNothingServes), errors.Is(told, link.ErrGrantRefused):
|
|
log.Printf("secret-given: %s's %s on %s could not be told through the operator channel (%v); the "+
|
|
"secret-given condition is raised as it was before novox/hq ADR 0380 (its clause 9)", module, name,
|
|
node, told)
|
|
return withKeeper(ctx, func(k *conditions.Keeper) error {
|
|
_, err := k.Observe(ctx, secretGivenObservation(node, module, name, how, at))
|
|
return err
|
|
})
|
|
default:
|
|
return told
|
|
}
|
|
}
|
|
|
|
// clearSecretGivenConditions retires every open secret-given condition once a plain message has replaced
|
|
// it (novox/hq ADR 0380 §10): called after the first time a secret-given message is told successfully. No
|
|
// resolved line is sent — the condition is simply gone — and why is kept in the keeper's own history.
|
|
func clearSecretGivenConditions(ctx context.Context) {
|
|
if err := withKeeper(ctx, func(k *conditions.Keeper) error {
|
|
open, err := k.Open(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, c := range open {
|
|
if c.Kind != kindSecretGiven {
|
|
continue
|
|
}
|
|
if _, err := k.ClearSaying(ctx, c.Key, "retired by novox/hq ADR 0380", ""); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}); err != nil {
|
|
log.Printf("secret-given: the open secret-given conditions, retired by ADR 0380, could not be cleared: %v", err)
|
|
}
|
|
}
|