diff --git a/cmd/mesh-controller/agent_account.go b/cmd/mesh-controller/agent_account.go index 8900b406..e288c1a1 100644 --- a/cmd/mesh-controller/agent_account.go +++ b/cmd/mesh-controller/agent_account.go @@ -27,6 +27,7 @@ import ( "fmt" "sort" "strings" + "time" "github.com/novox/mesh-controller/internal/conditions" "github.com/novox/mesh-controller/internal/inventory" @@ -60,16 +61,27 @@ func agentConfined(ctx context.Context, inv *inventory.Inventory, node string) ( if err != nil { return true, false, "", err } - confined, why = judgedConfined(n.AgentAccount, h, had) + confined, why = judgedConfined(n.AgentAccount, h, had, time.Now()) return true, confined, why, nil } -// judgedConfined is the judgement over one statement, without the store. -func judgedConfined(agent string, h inventory.NodeHealth, had bool) (bool, string) { +// verdictFreshFor is how old the statement holding the verdict may be, by this controller's clock. A +// node-engine states its health on every change and at least every five minutes (mesh-host's sayAnyway), so +// three statements missed is a node-engine stopped, or a machine away. **A stale verdict is not a pass**: an +// agent that stopped the node-engine must not leave "cannot become root" standing from before. +const verdictFreshFor = 15 * time.Minute + +// judgedConfined is the judgement over one statement, without the store, at now. +func judgedConfined(agent string, h inventory.NodeHealth, had bool, now time.Time) (bool, string) { if !had { return false, fmt.Sprintf("the agent account %s is not judged: the machine's node-engine has stated "+ "nothing of what it runs", agent) } + if age := now.Sub(h.HeardAt); age > verdictFreshFor { + return false, fmt.Sprintf("the agent account %s is not judged: the machine's newest statement was heard at "+ + "%s, more than %d minutes ago, and a verdict that old is not a verdict on now", agent, + h.HeardAt.Local().Format("2006-01-02 15:04"), int(verdictFreshFor.Minutes())) + } if h.Contract < link.RootContract { return false, fmt.Sprintf("the agent account %s is not judged: the machine's node-engine is older than "+ "the judging of an account's root (its statement's contract is %d, the judging is %d)", @@ -122,7 +134,7 @@ func probeAgentAccounts(ctx context.Context, d *doctor) ([]conditions.Observatio if err != nil { return nil, err } - confined, why := judgedConfined(n.AgentAccount, h, had) + confined, why := judgedConfined(n.AgentAccount, h, had, time.Now()) if confined { continue } diff --git a/cmd/mesh-controller/agent_account_test.go b/cmd/mesh-controller/agent_account_test.go index 5a157d4c..42dab04f 100644 --- a/cmd/mesh-controller/agent_account_test.go +++ b/cmd/mesh-controller/agent_account_test.go @@ -22,7 +22,7 @@ func TestAnAgentAccountIsConfinedOnlyOnAHealthyVerdictJudgedForRoot(t *testing.T Kind: link.KindAccount, Target: target, State: state, Reason: reason, Root: root, Account: target} } statement := func(contract int, rs ...inventory.ResourceHealth) inventory.NodeHealth { - return inventory.NodeHealth{Node: "anchor", Contract: contract, SaidAt: at, Resources: rs} + return inventory.NodeHealth{Node: "anchor", Contract: contract, SaidAt: at, HeardAt: at, Resources: rs} } for _, c := range []struct { name string @@ -45,11 +45,21 @@ func TestAnAgentAccountIsConfinedOnlyOnAHealthyVerdictJudgedForRoot(t *testing.T {"a verdict on another account", statement(link.RootContract, verdict("ops", link.RootNever, link.StateHealthy, "")), true, false, "no verdict on it"}, } { - confined, why := judgedConfined("agent", c.h, c.had) + confined, why := judgedConfined("agent", c.h, c.had, at.Add(time.Minute)) if confined != c.confined || !strings.Contains(why, c.says) { t.Errorf("%s: confined %v, %q; want %v saying %q", c.name, confined, why, c.confined, c.says) } } + // A verdict heard longer ago than the bound is no verdict: an agent that stopped the node-engine must not + // leave "healthy" standing. + fresh := statement(link.RootContract, verdict("agent", link.RootNever, link.StateHealthy, "")) + if ok, _ := judgedConfined("agent", fresh, true, at.Add(verdictFreshFor)); !ok { + t.Error("a verdict exactly at the bound is still one") + } + if ok, why := judgedConfined("agent", fresh, true, at.Add(verdictFreshFor+time.Second)); ok || + !strings.Contains(why, "not judged") { + t.Errorf("a stale healthy verdict passed: %q", why) + } } // DA raises an urgent condition, with plain words, on a machine whose agent account is not judged unable to @@ -146,6 +156,35 @@ func TestTheFactsCarryTheAgentAccountAsAPseudonym(t *testing.T) { } } +// No verb runs a `node` command that sets something: through the generic `command` verb, `node account`, +// `node agent-account` and every other `node` subcommand but list and show are refused, naming the terminal. +func TestNoVerbSetsANodesAccounts(t *testing.T) { + for _, line := range []string{ + "node agent-account novox --clear", + "node agent-account novox ops", + "node account novox agent", + "node account novox", + "node add intruder", + "node public-domain novox --clear", + "node", + "node frobnicate", + } { + argv, err := argvFor("command", map[string]any{"command": line}) + if err == nil || !strings.Contains(err.Error(), "controller's terminal only") || + !strings.Contains(err.Error(), "ADR 0266") { + t.Errorf("%q ran as %v (%v); want a refusal naming the terminal", line, argv, err) + } + } + for _, line := range []string{"node show novox", "node list --json", "status --json"} { + if _, err := argvFor("command", map[string]any{"command": line}); err != nil { + t.Errorf("%q, a read, was refused: %v", line, err) + } + } + if err := terminalOnly([]string{"node", "account", "a", "b"}); err == nil { + t.Error("the refusal is not only the command verb's") + } +} + // Naming the agent account is the controller's terminal's alone: the `node` verb only shows. func TestTheNodeVerbOnlyShows(t *testing.T) { argv, err := argvFor("node", map[string]any{"node": "anchor"}) diff --git a/cmd/mesh-controller/seatverbs.go b/cmd/mesh-controller/seatverbs.go index f3b5a4d1..32062d52 100644 --- a/cmd/mesh-controller/seatverbs.go +++ b/cmd/mesh-controller/seatverbs.go @@ -55,6 +55,9 @@ func argvFor(verb string, args map[string]any) ([]string, error) { return nil, err } argv, err := a.commandLine() + if err == nil { + err = terminalOnly(argv) + } if len(a.misread) > 0 { // The table and the command line disagree: the verb reads an argument no caller can see // in its schema, so no caller could ever pass it. @@ -1324,3 +1327,27 @@ func seatAnnouncement(handlers map[string]link.ToolHandler) micro.Info { Endpoints: endpoints, } } + +// nodeReads are the `node` subcommands a verb may run: the ones that only read. +var nodeReads = map[string]bool{"list": true, "show": true} + +// terminalOnly refuses, through any verb, a command that is the operator's at the controller's terminal +// alone (novox/hq ADR 0266). **Every `node` subcommand that is not a read**: `node account` and +// `node agent-account` above all. Whoever may call a verb includes agents, and an agent that named itself +// the operator account, or cleared the agent account, would have the next send grant it root through the +// sudo module's rule. An allow list, so a subcommand added later is refused until it is judged a read. +func terminalOnly(argv []string) error { + if len(argv) == 0 || argv[0] != "node" { + return nil + } + if len(argv) > 1 && nodeReads[argv[1]] { + return nil + } + sub := "node" + if len(argv) > 1 { + sub += " " + argv[1] + } + return fmt.Errorf("%s is run at the controller's terminal only, never through a verb: a node's accounts "+ + "decide who may become root on it (novox/hq ADR 0266). A verb may run node list and node show. "+ + "Nothing was done", sub) +} diff --git a/cmd/mesh-controller/seatverbs_test.go b/cmd/mesh-controller/seatverbs_test.go index bbb29096..59ce1607 100644 --- a/cmd/mesh-controller/seatverbs_test.go +++ b/cmd/mesh-controller/seatverbs_test.go @@ -237,19 +237,19 @@ func TestAJSONVerbsAnswerIsItsStandardOutput(t *testing.T) { } } -// `command` is the generic verb: the command line as given, split as a shell would, nothing added — -// so an operator's `node account g14 jochen` is one call through the console rather than a shell on -// the control node (novox/hq ADR 0154, ADR 0175). +// `command` is the generic verb: the command line as given, split as a shell would, nothing added +// (novox/hq ADR 0154, ADR 0175). It once carried an operator's `node account g14 jochen` too; a node's +// accounts are the controller's terminal's alone since ADR 0266 (TestNoVerbSetsANodesAccounts). func TestCommandRunsTheLineAsGiven(t *testing.T) { - argv, err := argvFor("command", map[string]any{"command": "node account g14 jochen"}) - if err != nil || strings.Join(argv, " ") != "node account g14 jochen" { + argv, err := argvFor("command", map[string]any{"command": "node show g14"}) + if err != nil || strings.Join(argv, " ") != "node show g14" { t.Fatalf("a plain line: %v %v", argv, err) } argv, err = argvFor("command", map[string]any{"command": `settings set dnsmasq '{"a": "b c"}' --node ace`}) if err != nil || len(argv) != 6 || argv[3] != `{"a": "b c"}` { t.Fatalf("a quoted word stays one word: %q %v", argv, err) } - argv, err = argvFor("command", map[string]any{"command": `node add "the box" --adopted`}) + argv, err = argvFor("command", map[string]any{"command": `module show "the box" --json`}) if err != nil || len(argv) != 4 || argv[2] != "the box" { t.Fatalf("double quotes group: %q %v", argv, err) } diff --git a/go.mod b/go.mod index 6c1418dd..2e978079 100644 --- a/go.mod +++ b/go.mod @@ -35,4 +35,4 @@ require ( // committed. Every build (the build agent's `go build`, the Dockerfile) compiles from vendor/ and // fetches nothing; go refuses to build when vendor/ and this file disagree, so a pin moved without // `go mod vendor` fails loudly, at once, everywhere. -replace github.com/novox/mesh-host => git.novox.be/novox/mesh-host v0.0.0-20261008163010-8390fab5cb35 +replace github.com/novox/mesh-host => git.novox.be/novox/mesh-host v0.0.0-20261008183646-5fc37b44a94e diff --git a/go.sum b/go.sum index da80eb63..eca6cf1b 100644 --- a/go.sum +++ b/go.sum @@ -6,6 +6,8 @@ git.novox.be/novox/mesh-host v0.0.0-20261007162834-56e2ebec4bac h1:yLtFS0pDCCqIE git.novox.be/novox/mesh-host v0.0.0-20261007162834-56e2ebec4bac/go.mod h1:VlilMCRZ5yyNXg7SNigNBLr0Gt32jrGw5KSNq5JAVYs= git.novox.be/novox/mesh-host v0.0.0-20261008163010-8390fab5cb35 h1:3uag/9Tv4Y3ippY5Yr1rIIBh23Ur9RbhzOB4CLbKz0I= git.novox.be/novox/mesh-host v0.0.0-20261008163010-8390fab5cb35/go.mod h1:VlilMCRZ5yyNXg7SNigNBLr0Gt32jrGw5KSNq5JAVYs= +git.novox.be/novox/mesh-host v0.0.0-20261008183646-5fc37b44a94e h1:+XxiuXJqWj7ZcGMB2b/WGPsC8zpmXgmwXnBvjw9C/CM= +git.novox.be/novox/mesh-host v0.0.0-20261008183646-5fc37b44a94e/go.mod h1:72ZATZjxMLaJfWdvlSDJrygIoBzCmKIjCDMhEXxVzTo= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= diff --git a/internal/catalogue/verbs.go b/internal/catalogue/verbs.go index 332cb4ea..2df181ee 100644 --- a/internal/catalogue/verbs.go +++ b/internal/catalogue/verbs.go @@ -268,9 +268,10 @@ var ControllerVerbs = []Verb{ "machine it is assigned to with where it comes from; module and node narrow it (novox/hq ADR 0262)", }, nil, "clear", "replace", "history")}, {Name: "command", Description: "Run one command line of the controller's own, as you would type it at its " + - "shell — `node account g14 jochen`, `node show ace`, `module list` — and answer what it printed. The " + - "generic verb beside the named ones (novox/hq ADR 0154): everything the binary can do, without a verb " + - "per command. Any node may call any tool (ADR 0175), so nothing is held back here.", + "shell — `node show ace`, `module list` — and answer what it printed. The generic verb beside the named " + + "ones (novox/hq ADR 0154): what the binary can do, without a verb per command. Held back: every `node` " + + "command but `node list` and `node show` — a node's accounts decide who may become root on it, and are " + + "set at the controller's terminal only (ADR 0266).", Input: schema(map[string]string{ "command": "the command line, as the controller's binary takes it; quotes group a word with spaces", }, []string{"command"})}, diff --git a/internal/inventory/agent_account_test.go b/internal/inventory/agent_account_test.go index d807d36b..44139bb8 100644 --- a/internal/inventory/agent_account_test.go +++ b/internal/inventory/agent_account_test.go @@ -53,6 +53,9 @@ func TestAgentAccountIsRecordedAndRefusedWhereItWouldNotConfine(t *testing.T) { {"Agent", "", "not a login name"}, {"9agent", "", "not a login name"}, {"agent", "relative", "absolute"}, + {"postgres", "", "service account"}, + {"systemd-network", "", "service account"}, + {"showcase", "", "service account"}, {"", "/home/x", "without an agent account"}, } { err := inv.SetAgentAccount(ctx, "anchor", c.account, c.home) @@ -64,9 +67,23 @@ func TestAgentAccountIsRecordedAndRefusedWhereItWouldNotConfine(t *testing.T) { t.Fatalf("a refusal changed the record: %q", n.AgentAccount) } + // The other direction: the operator account may not be named as the agent account either. + if err := inv.SetAccount(ctx, "anchor", "agent", ""); err == nil || !strings.Contains(err.Error(), "agent account") { + t.Fatalf("the operator account named as the agent account: %v; want a refusal", err) + } + if n, _ = inv.NodeByName(ctx, "anchor"); n.Account != "operator" { + t.Fatalf("a refusal changed the operator account: %q", n.Account) + } + if err := inv.SetAgentAccount(ctx, "anchor", "", ""); err != nil { t.Fatal(err) } + if err := inv.SetAccount(ctx, "anchor", "agent", ""); err != nil { + t.Fatalf("with the agent account cleared, the name is free: %v", err) + } + if err := inv.SetAccount(ctx, "anchor", "operator", ""); err != nil { + t.Fatal(err) + } if n, _ = inv.NodeByName(ctx, "anchor"); n.AgentAccount != "" || n.AgentAccountHome != "" { t.Fatalf("clearing left %q %q", n.AgentAccount, n.AgentAccountHome) } diff --git a/internal/inventory/nodes.go b/internal/inventory/nodes.go index ee427004..f71f2697 100644 --- a/internal/inventory/nodes.go +++ b/internal/inventory/nodes.go @@ -192,7 +192,23 @@ func scanNode(row pgx.Row) (Node, error) { // SetAccount records the operator account on a node — its human login — and optionally where that // account's home is (novox/hq to-be 29). An empty home means the mesh derives it. Clearing the // account (empty name) is allowed: a machine may stop having a known operator. +// +// **Never the node's agent account** (novox/hq ADR 0266): the operator account may become root, and the +// agent account exists so agents cannot; naming the one as the other gives agents root. Refused here as +// SetAgentAccount refuses the other direction. func (i *Inventory) SetAccount(ctx context.Context, node, account, home string) error { + account = strings.TrimSpace(account) + if account != "" { + n, err := i.NodeByName(ctx, node) + if err != nil { + return err + } + if n.AgentAccount != "" && n.AgentAccount == account { + return fmt.Errorf("%s is %s's agent account: the operator account may become root, and agents run as "+ + "%s so that they cannot (novox/hq ADR 0266); clear the agent account first "+ + "(node agent-account %s --clear) if the operator is to log in as it", account, node, account, node) + } + } tag, err := i.store.Pool().Exec(ctx, `update node set account = $1, account_home = $2 where name = $3`, account, home, node) if err != nil { @@ -244,6 +260,24 @@ func (i *Inventory) SetAgentAccount(ctx context.Context, node, account, home str return nil } +// serviceAccounts are the system and service accounts a machine of the mesh has, or a module of the +// catalogue declares (showcase, and the accounts ADR 0259 gives the router and the channels). The controller +// cannot read a machine's user database, so this list is the controller's half; the node-engine's half is +// refusing to take an existing account below the first login uid as one that must never become root. +var serviceAccounts = map[string]bool{ + "root": true, "bin": true, "daemon": true, "sys": true, "adm": true, "nobody": true, "mail": true, + "ftp": true, "http": true, "www-data": true, "git": true, "sshd": true, "dbus": true, "polkitd": true, + "postgres": true, "docker": true, "nats": true, "redis": true, "uuidd": true, "dnsmasq": true, + "avahi": true, "rtkit": true, "colord": true, "geoclue": true, "tss": true, "alpm": true, "usbmux": true, + "showcase": true, "messenger": true, "telegram": true, +} + +// serviceAccount says whether a name is a system or service account: one of the list, or a name of +// systemd's own (systemd-…). +func serviceAccount(name string) bool { + return serviceAccounts[name] || strings.HasPrefix(name, "systemd-") +} + // AgentAccountRefusal is why an agent account cannot be named, or nil: root, a malformed login, or a // home that is not an absolute path. func AgentAccountRefusal(account, home string) error { @@ -255,6 +289,11 @@ func AgentAccountRefusal(account, home string) error { return fmt.Errorf("%q is not a login name: lower case letters, digits, _ and -, a letter or _ first, "+ "at most 32", account) } + if serviceAccount(account) { + return fmt.Errorf("%q is a system or service account a machine or a module already has: the agent "+ + "account is one the mesh creates for agents alone, which nothing else runs as or owns files as "+ + "(novox/hq ADR 0266); name a new one, such as agent", account) + } if home != "" && !strings.HasPrefix(home, "/") { return fmt.Errorf("the agent account's home %q is not an absolute path", home) } diff --git a/vendor/modules.txt b/vendor/modules.txt index 0dd98a66..f2187cb8 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -75,7 +75,7 @@ github.com/nats-io/nkeys # github.com/nats-io/nuid v1.0.1 ## explicit github.com/nats-io/nuid -# github.com/novox/mesh-host v0.0.0 => git.novox.be/novox/mesh-host v0.0.0-20261008163010-8390fab5cb35 +# github.com/novox/mesh-host v0.0.0 => git.novox.be/novox/mesh-host v0.0.0-20261008183646-5fc37b44a94e ## explicit; go 1.26.0 github.com/novox/mesh-host/internal/declaration github.com/novox/mesh-host/validate @@ -133,4 +133,4 @@ golang.org/x/text/width # golang.org/x/time v0.15.0 ## explicit; go 1.25.0 golang.org/x/time/rate -# github.com/novox/mesh-host => git.novox.be/novox/mesh-host v0.0.0-20261008163010-8390fab5cb35 +# github.com/novox/mesh-host => git.novox.be/novox/mesh-host v0.0.0-20261008183646-5fc37b44a94e