diff --git a/cmd/mesh-controller/agent_account.go b/cmd/mesh-controller/agent_account.go new file mode 100644 index 00000000..9436997e --- /dev/null +++ b/cmd/mesh-controller/agent_account.go @@ -0,0 +1,250 @@ +package main + +// The account agents run as (novox/hq ADR 0266). +// +// On the control node every agent session ran as the operator's account, which may become root without a +// password — so any agent there could become root without a person, and ADR 0259 §8 (an answer from the +// operator's phone authorises an act) rests on that being false where the router and its channels run. The +// decision: a node may name an account its agents run as, of their own and without sudo; the operator's +// account keeps its sudo. +// +// - **Named at the controller's terminal only** (`node agent-account`): not a verb, not a setting, so no +// agent can name itself another account. Empty is a real state: agents run as the operator there. +// - **Composed** as `${machine:agent-account}`, `${machine:agent-home}` and `${machine:agent-root}` for the +// agent's module, which declares the account with `root: never`, and as MESH_AGENT_ACCOUNT and +// MESH_AGENT_HOME for its tools (catalogue/machine_into_files.go, runtime.go). +// - **Judged by the machine itself.** The node-engine reads, on every look, whether an account declared +// `root: never` can become root without a person — uid 0, a group that grants root, a sudo rule, a +// secret of the mesh it may read — and says it as the declaring module's account verdict, marked +// Root "never". agentConfined reads that verdict; the self-check (probe DA) raises +// `agent-can-become-root` while it does not hold, and `node show` says it. +// +// A verdict not given is never a pass: an engine older than the judging, an account not yet declared, a +// statement that says nothing of it — each is "not judged", and fails. + +import ( + "context" + "fmt" + "sort" + "strings" + "time" + + "github.com/novox/mesh-controller/internal/conditions" + "github.com/novox/mesh-controller/internal/inventory" + "github.com/novox/mesh-controller/internal/link" +) + +// kindAgentCanBecomeRoot is the condition raised while a machine's agent account can become root without +// a person, or is not judged (ADR 0266). +const kindAgentCanBecomeRoot = "agent-can-become-root" + +// agentAccountProbe is the self-check's probe of it. +const agentAccountProbe = "DA" + +// agentConfined says whether the agents of a machine that names an agent account are confined: the +// machine's newest statement holds a healthy account verdict, judged for root, on that account. named is +// false for a machine that names none — agents run as the operator account there, which this does not +// judge. why is said either way, in the mesh's words; err is a store that could not be read. +// +// The one judgement: `node show`, the self-check, and ADR 0259's router honouring a verified sender read +// it here. +func agentConfined(ctx context.Context, inv *inventory.Inventory, node string) (named, confined bool, why string, err error) { + n, err := inv.NodeByName(ctx, node) + if err != nil { + return false, false, "", err + } + if n.AgentAccount == "" { + return false, false, fmt.Sprintf("%s names no agent account: agents run as the operator account (%s)", + node, orNoneKnown(n.Account)), nil + } + h, had, err := inv.HealthOf(ctx, node) + if err != nil { + return true, false, "", err + } + confined, why = judgedConfined(n.AgentAccount, h, had, time.Now()) + return true, confined, why, nil +} + +// 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)", + agent, h.Contract, link.RootContract) + } + var verdicts []inventory.ResourceHealth + for _, r := range h.Resources { + if r.Kind == link.KindAccount && r.Target == agent && r.Root == link.RootNever { + verdicts = append(verdicts, r) + } + } + if len(verdicts) == 0 { + return false, fmt.Sprintf("the agent account %s is not judged: the machine's newest statement holds no "+ + "verdict on it — no module there declares it never to become root, or the declaration naming it "+ + "has not been applied", agent) + } + sort.Slice(verdicts, func(i, j int) bool { + return verdicts[i].Module+verdicts[i].Resource < verdicts[j].Module+verdicts[j].Resource + }) + for _, v := range verdicts { + switch v.State { + case link.StateHealthy: + case link.StateUnhealthy: + // The engine's own words, which start with link.ReasonRoot when it found a way to root. + return false, fmt.Sprintf("the agent account %s %s (said by %s's %s)", agent, + orNoneKnown(v.Reason), v.Module, v.Resource) + default: + return false, fmt.Sprintf("the agent account %s is not judged: %s (%s's %s, %s)", agent, + orNoneKnown(v.Reason), v.Module, v.Resource, v.State) + } + } + return true, fmt.Sprintf("the agent account %s cannot become root without a person (judged %s)", agent, + h.SaidAt.Local().Format("2006-01-02 15:04")) +} + +// searchQuietFor is how long the controller lets an agent account's verdict wait for the node-engine's setuid +// search before that is itself the urgent condition: the engine's bound on one search (link.RootSearchBound, the +// engine's own value), counted from when this controller first saw it waiting, never from the engine's start. +const searchQuietFor = link.RootSearchBound + +// The kinds of an agent account's verdict, for the quiet a search earns. +const ( + verdictOther = iota // anything else: a stale statement, an older engine, no verdict, another unknown + verdictPending // waiting for the search, and otherwise healthy + verdictComplete // judged: healthy, or a way to root found +) + +// rootVerdictKind reads a statement for the agent account (novox/hq ADR 0266): pending when every verdict on it +// is healthy or not judged yet because the engine's setuid search runs, at least one of them that; complete when +// every verdict is healthy or one found a way to root. The engine's own "since" is not read: it starts again at +// every restart of the engine. +func rootVerdictKind(agent string, h inventory.NodeHealth, had bool, now time.Time) int { + if !had || now.Sub(h.HeardAt) > verdictFreshFor || h.Contract < link.RootContract { + return verdictOther + } + pending, any := false, false + for _, r := range h.Resources { + if r.Kind != link.KindAccount || r.Target != agent || r.Root != link.RootNever { + continue + } + any = true + switch { + case r.State == link.StateHealthy: + case r.State == link.StateUnhealthy: + return verdictComplete + case r.State == link.StateUnknown && strings.HasPrefix(r.Reason, link.ReasonRootPending): + pending = true + default: + return verdictOther + } + } + switch { + case !any: + return verdictOther + case pending: + return verdictPending + } + return verdictComplete +} + +// searchStillRunning says the one thing keeping an agent account from being judged is the node-engine's setuid +// search, and that this controller first saw it waiting less than searchQuietFor ago — kept in the store, so a +// node-engine restarted in a loop does not keep it quiet. A complete verdict forgets when it began. +func searchStillRunning(ctx context.Context, inv *inventory.Inventory, node, agent string, h inventory.NodeHealth, + had bool, now time.Time) (bool, error) { + switch rootVerdictKind(agent, h, had, now) { + case verdictComplete: + return false, inv.RootSearchJudged(ctx, node) + case verdictPending: + since, err := inv.RootSearchPending(ctx, node, now) + if err != nil { + return false, err + } + return now.Sub(since) <= searchQuietFor, nil + } + return false, nil +} + +// probeAgentAccounts is DA: every machine that names an agent account has it judged, on its node-engine's +// newest statement, unable to become root without a person (ADR 0266). +func probeAgentAccounts(ctx context.Context, d *doctor) ([]conditions.Observation, error) { + inv := d.open.inventory + nodes, err := inv.Nodes(ctx) + if err != nil { + return nil, err + } + var out []conditions.Observation + for _, n := range nodes { + if n.AgentAccount == "" { + continue + } + h, had, err := inv.HealthOf(ctx, n.Name) + if err != nil { + return nil, err + } + now := time.Now() + quiet, err := searchStillRunning(ctx, inv, n.Name, n.AgentAccount, h, had, now) + if err != nil { + return nil, err + } + confined, why := judgedConfined(n.AgentAccount, h, had, now) + if confined { + continue + } + // Not judged yet only because the first search since the node-engine started is still running: not the + // urgent condition after every restart. The agent is still not confined — ADR 0259's router reads + // agentConfined, not this — and `node show` still says not judged. Loud again once the search fails, + // runs out its bound, or the statement goes stale. + if quiet { + continue + } + out = append(out, conditions.Observation{Scope: conditions.ScopeMachine, ID: n.Name, Token: "agent-root", + Machine: n.Name, Severity: conditions.Urgent, + Summary: fmt.Sprintf("on %s, %s (ADR 0266): an agent there may become root without a person, and "+ + "no answer from a channel authorises an act there (ADR 0259 §8)", n.Name, why), + Said: why}) + } + return sortedFound(out), nil +} + +// agentAccountLines is what `node show` says of the account agents run as. +func agentAccountLines(ctx context.Context, inv *inventory.Inventory, n inventory.Node) []string { + if n.AgentAccount == "" { + return []string{fmt.Sprintf(" agents run as the operator account (%s); no agent account is named", + orNoneKnown(n.Account))} + } + _, confined, why, err := agentConfined(ctx, inv, n.Name) + if err != nil { + return []string{fmt.Sprintf(" agents run as %s (home %s); whether it can become root could NOT be read: %v", + n.AgentAccount, n.AgentHome(), err)} + } + verdict := "CAN become root, or is not judged: " + why + if confined { + verdict = why + } + return []string{fmt.Sprintf(" agents run as %s (home %s)", n.AgentAccount, n.AgentHome()), + " " + verdict} +} + +// orNoneKnown is a value, or that none is known. +func orNoneKnown(s string) string { + if strings.TrimSpace(s) == "" { + return "none known" + } + return s +} diff --git a/cmd/mesh-controller/agent_account_test.go b/cmd/mesh-controller/agent_account_test.go new file mode 100644 index 00000000..68143eca --- /dev/null +++ b/cmd/mesh-controller/agent_account_test.go @@ -0,0 +1,273 @@ +package main + +import ( + "github.com/novox/mesh-host/rootsearch" + "strings" + "testing" + "time" + + "github.com/novox/mesh-controller/internal/catalogue" + "github.com/novox/mesh-controller/internal/conditions" + snapshot "github.com/novox/mesh-controller/internal/facts" + "github.com/novox/mesh-controller/internal/inventory" + "github.com/novox/mesh-controller/internal/link" +) + +// The agent account's judgement (novox/hq ADR 0266): confined only on a healthy account verdict judged for +// root, on the very account; every verdict not given — no statement, an older engine, no verdict on it, a +// verdict of unknown — is "not judged" and fails, never a pass. +func TestAnAgentAccountIsConfinedOnlyOnAHealthyVerdictJudgedForRoot(t *testing.T) { + at := time.Date(2026, 10, 8, 19, 21, 0, 0, time.UTC) + verdict := func(target, root, state, reason string) inventory.ResourceHealth { + return inventory.ResourceHealth{Module: "claude-code", Resource: "claude-code.agent-account", + 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, HeardAt: at, Resources: rs} + } + for _, c := range []struct { + name string + h inventory.NodeHealth + had bool + confined bool + says string + }{ + {"judged and unable", statement(link.RootContract, verdict("agent", link.RootNever, link.StateHealthy, "")), + true, true, "cannot become root without a person"}, + {"judged and able", statement(link.RootContract, verdict("agent", link.RootNever, link.StateUnhealthy, + link.ReasonRoot+": in the group docker, which grants root")), true, false, "in the group docker"}, + {"a verdict of unknown", statement(link.RootContract, verdict("agent", link.RootNever, link.StateUnknown, + "sudo could not be read")), true, false, "not judged"}, + {"no statement", inventory.NodeHealth{}, false, false, "not judged"}, + {"an older engine", statement(link.ReadinessContract, verdict("agent", "", link.StateHealthy, "")), + true, false, "older than the judging"}, + {"a verdict on groups only", statement(link.RootContract, verdict("agent", "", link.StateHealthy, "")), + true, false, "no verdict on it"}, + {"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, 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 +// become root; a machine that names none is not its to judge. +func TestTheSelfCheckSaysAnAgentAccountThatCanBecomeRoot(t *testing.T) { + open := aMesh(t) + ctx := t.Context() + inv := open.inventory + for _, n := range []string{"anchor", "laptop"} { + if _, err := inv.NodeByName(ctx, n); err != nil { + if _, err := inv.AddNode(ctx, n); err != nil { + t.Fatal(err) + } + } + if err := inv.SetAccount(ctx, n, "ops", ""); err != nil { + t.Fatal(err) + } + } + if err := inv.SetAgentAccount(ctx, "anchor", "agent", ""); err != nil { + t.Fatal(err) + } + d := &doctor{open: open} + found, err := probeAgentAccounts(ctx, d) + if err != nil { + t.Fatal(err) + } + found = onlyMachine(found, "anchor") + if len(found) != 1 || found[0].Machine != "anchor" || found[0].Severity != conditions.Urgent || + !strings.Contains(found[0].Said, "not judged") { + t.Fatalf("a named agent account with no verdict: %+v", found) + } + w := plainWordings[kindAgentCanBecomeRoot](conditions.Observation{Kind: kindAgentCanBecomeRoot, Machine: "anchor"}) + if w.Headline == "" || w.Needs == "" || w.Resolved == "" { + t.Errorf("the condition has no plain words: %+v", w) + } + + healthy := inventory.ResourceHealth{Module: "claude-code", Resource: "claude-code.agent-account", + Kind: link.KindAccount, Target: "agent", State: link.StateHealthy, Root: link.RootNever, Account: "agent"} + if _, err := inv.RecordHealth(ctx, inventory.NodeHealth{Node: "anchor", Contract: link.RootContract, + SaidAt: time.Now(), HeardAt: time.Now(), Resources: []inventory.ResourceHealth{healthy}}); err != nil { + t.Fatal(err) + } + if found, err = probeAgentAccounts(ctx, d); err != nil || len(onlyMachine(found, "anchor")) != 0 { + t.Fatalf("a judged agent account still fails: %+v %v", found, err) + } + if named, confined, why, err := agentConfined(ctx, inv, "anchor"); err != nil || !named || !confined { + t.Fatalf("agentConfined on anchor: %v %v %q %v", named, confined, why, err) + } + if named, _, why, err := agentConfined(ctx, inv, "laptop"); err != nil || named || + !strings.Contains(why, "operator account") { + t.Fatalf("agentConfined on a machine naming none: %v %q %v", named, why, err) + } +} + +func TestTheAgentRootWordsArePlain(t *testing.T) { + w := plainWordings[kindAgentCanBecomeRoot](conditions.Observation{Kind: kindAgentCanBecomeRoot, Machine: "anchor"}) + if why, ok := conditions.PlainWords(w, "anchor"); !ok { + t.Fatalf("not plain: %s: %+v", why, w) + } +} + +func onlyMachine(obs []conditions.Observation, machine string) []conditions.Observation { + var out []conditions.Observation + for _, o := range obs { + if o.Machine == machine { + out = append(out, o) + } + } + return out +} + +// The snapshot a merge check composes from carries the agent account as a pseudonym (novox/hq ADR 0266), +// so a change is judged against machines that name one, and the name never leaves. +func TestTheFactsCarryTheAgentAccountAsAPseudonym(t *testing.T) { + open, _ := aMeshWithSecrets(t) + ctx := t.Context() + if err := open.inventory.SetAccount(ctx, "anchor", "keeper", ""); err != nil { + t.Fatal(err) + } + if err := open.inventory.SetAgentAccount(ctx, "anchor", "warden", ""); err != nil { + t.Fatal(err) + } + f, err := gatherFacts(ctx, open, "2.11.17") + if err != nil { + t.Fatal(err) + } + body, _ := f.Encode() + if strings.Contains(string(body), "warden") { + t.Error("the agent account's name is in the snapshot") + } + m, ok := f.Machine(snapshot.Pseudonym("machine", "anchor")) + if !ok || m.AgentAccount != snapshot.Pseudonym("account", "warden") || m.Account == m.AgentAccount { + t.Fatalf("the anchor's agent account reads as %q (operator %q)", m.AgentAccount, m.Account) + } +} + +// 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") || + !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"}) + if err != nil || strings.Join(argv, " ") != "node show anchor" { + t.Fatalf("the node verb runs %v (%v)", argv, err) + } + for _, v := range catalogue.ControllerVerbs { + if strings.Contains(v.Name, "agent") { + t.Errorf("a verb %q may name the agent account", v.Name) + } + } +} + +// After every node-engine restart its search for setuid programs runs for up to its bound, and the agent account +// is not judged until it ends. DA does not raise that as urgent while the search is within its bound, counted from +// when this controller first saw it waiting — never from the engine's own "since", which a restart resets, so an +// engine restarted in a loop does not keep it quiet. The account is still not confined, and `node show` still +// says not judged; a search that failed, or a way to root found, is urgent at once. +func TestASearchStillRunningAfterARestartIsNotUrgent(t *testing.T) { + if searchQuietFor != rootsearch.Bound { + t.Fatalf("the quiet is %s and the node-engine's bound %s: they are one value", searchQuietFor, rootsearch.Bound) + } + open := aMesh(t) + ctx := t.Context() + inv := open.inventory + if _, err := inv.NodeByName(ctx, "anchor"); err != nil { + if _, err := inv.AddNode(ctx, "anchor"); err != nil { + t.Fatal(err) + } + } + if err := inv.SetAgentAccount(ctx, "anchor", "agent", ""); err != nil { + t.Fatal(err) + } + d := &doctor{open: open} + say := func(state, reason string) []conditions.Observation { + t.Helper() + // The engine's since is always now: it was just restarted. + v := inventory.ResourceHealth{Module: "claude-code", Resource: "claude-code.agent-account", + Kind: link.KindAccount, Target: "agent", State: state, Reason: reason, Root: link.RootNever, + Account: "agent", Since: time.Now()} + if _, err := inv.RecordHealth(ctx, inventory.NodeHealth{Node: "anchor", Contract: link.RootContract, + SaidAt: time.Now(), HeardAt: time.Now(), Resources: []inventory.ResourceHealth{v}}); err != nil { + t.Fatal(err) + } + found, err := probeAgentAccounts(ctx, d) + if err != nil { + t.Fatal(err) + } + return onlyMachine(found, "anchor") + } + running := link.ReasonRootPending + ": the search for setuid programs, started at 19:21, has not finished yet" + healthy := func() { + t.Helper() + if found := say(link.StateHealthy, ""); len(found) != 0 { + t.Fatalf("a healthy verdict raised: %+v", found) + } + } + if found := say(link.StateUnknown, running); len(found) != 0 { + t.Fatalf("a search first seen now was raised: %+v", found) + } + if _, confined, why, _ := agentConfined(ctx, inv, "anchor"); confined || !strings.Contains(why, "not judged") { + t.Fatalf("an account whose search runs was read as confined: %q", why) + } + // The engine restarted again and again, each statement's own since fresh: the controller's clock runs on. + if _, err := inv.RootSearchPending(ctx, "anchor", time.Now().Add(-searchQuietFor-time.Minute)); err != nil { + t.Fatal(err) + } + healthy() // a complete verdict forgets when the waiting began … + if _, err := inv.RootSearchPending(ctx, "anchor", time.Now().Add(-searchQuietFor-time.Minute)); err != nil { + t.Fatal(err) // … and this restart loop began past the bound + } + if found := say(link.StateUnknown, running); len(found) != 1 || found[0].Severity != conditions.Urgent { + t.Fatalf("a search pending past the bound, by the controller's clock, was not urgent: %+v", found) + } + healthy() + incomplete := rootsearch.ReasonIncomplete + ": the search for setuid programs did not finish (last tried at " + + "19:23: timeout); it is tried again later" + if found := say(link.StateUnknown, incomplete); len(found) != 1 || found[0].Severity != conditions.Urgent { + t.Fatalf("a search that did not finish was not urgent: %+v", found) + } + if found := say(link.StateUnhealthy, link.ReasonRoot+": in the group docker; "+running); len(found) != 1 || + found[0].Severity != conditions.Urgent { + t.Fatalf("a way to root found while the search runs was not urgent: %+v", found) + } +} diff --git a/cmd/mesh-controller/build.go b/cmd/mesh-controller/build.go index 083139ea..6f1c0cd5 100644 --- a/cmd/mesh-controller/build.go +++ b/cmd/mesh-controller/build.go @@ -451,6 +451,10 @@ func buildOneAsked(ctx context.Context, source buildSource, path, ref string, wa if err != nil { return "", err } + // Through a verb, only a repository the catalogue builds from (novox/hq ADR 0266). + if err := verbMayAsk(ctx, source, repository); err != nil { + return "", err + } ident, err := openIdentity(ctx) if err != nil { @@ -500,8 +504,10 @@ func buildOneAsked(ctx context.Context, source buildSource, path, ref string, wa // never before, so an ask that failed never reads as a build in flight. A dry run registers nothing, and // is not kept. keep := !dryRun && asker != "" + // Asked at the terminal is what lets its outcome register a module from a repository the catalogue does + // not build it from (novox/hq ADR 0266); never through a verb. asked := inventory.BuildRequest{ID: request.ID, Repository: source.Repository, Seat: source.Seat, Path: path, - Ref: ref, For: asker} + Ref: ref, For: asker, AtTerminal: startedAtTheTerminal()} if wait == 0 { // Asked and not waited for (novox/hq issue 176): the outcome is the role's event, and the @@ -631,6 +637,21 @@ func takeIn(ctx context.Context, inv *inventory.Inventory, result link.BuildResu return manifest, kept, fmt.Errorf("%s built %s (%s), and the mesh does not register it: %w", result.On, result.Repository, short(result.Commit), err) } + // **Only from the repository the catalogue builds the module from** (novox/hq ADR 0266): else the trunk + // below is the trunk of whatever repository was built, which may be one an agent made — and a module named + // `sudo` from it would be what the next push sends. Another repository is the operator's, at the terminal. + trunk := result.Trunk + if was, err := inv.SourceOf(ctx, manifest.Module); err == nil && followedBranch(was.Ref) != "" { + trunk = followedBranch(was.Ref) + } + if trunk == "" { + trunk = "main" + } + repoID, err := mayRegisterFrom(ctx, inv, manifest.Module, recorded, result.ID, result.Path, trunk) + if err != nil { + return manifest, kept, fmt.Errorf("%s built %s (%s), recorded and not registered: %w", result.On, + manifest.Module, short(result.Commit), err) + } // **Only a commit on the trunk is published** (novox/hq ADR 0238): a commit off its repository's // default branch — a pull request's head, a feature branch built by hand, a `rebuild` or `replay // --register` of one — is for checking, and is never a module's version; nothing could then send it. @@ -678,6 +699,10 @@ func takeIn(ctx context.Context, inv *inventory.Inventory, result link.BuildResu } return manifest, kept, err } + // Which repository it is registered from, by the forge's own id (novox/hq ADR 0266). + if err := inv.SetSourceIdentity(ctx, manifest.Module, repoID); err != nil { + return manifest, kept, err + } // The keep set just moved, and new bytes just landed (novox/hq ADR 0189). Asked here rather // than on a timer of its own: this is the only moment either is true. Never fatal — the build // worked and the module is registered. @@ -719,6 +744,9 @@ func buildAndShow(ctx context.Context, source buildSource, path, ref string, wai if err != nil { return err } + if err := verbMayAsk(ctx, source, repository); err != nil { + return err + } ident, err := openIdentity(ctx) if err != nil { return err diff --git a/cmd/mesh-controller/build_source.go b/cmd/mesh-controller/build_source.go new file mode 100644 index 00000000..c190dbc1 --- /dev/null +++ b/cmd/mesh-controller/build_source.go @@ -0,0 +1,437 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "regexp" + "strings" + "time" + + "github.com/novox/mesh-controller/internal/catalogue" + "github.com/novox/mesh-controller/internal/inventory" + "github.com/novox/mesh-controller/internal/link" +) + +// Where a build may register a module from (novox/hq ADR 0266). +// +// A build's outcome registers its module, and a registered module is what the next push sends. Before this, +// an outcome registered whatever its manifest named, from whichever repository it was built from: an agent +// that made a repository of its own, committed `modules/sudo/module.json` granting itself a rule without a +// password, and asked the `build` verb for it had its repository registered as the module `sudo` — whose next +// push made the agent root on every node. A fork of the node-engine did the same everywhere. The trunk rule +// (ADR 0238) did not stop it: the trunk it checked was the trunk of the repository built, which was the +// agent's own. +// +// So **an outcome registers a module only from the repository the catalogue already builds that module +// from**; and a module new to the catalogue only from a repository the catalogue already builds another +// module from — whose trunk takes a reviewed, approved merge, which is how a merge adds a module (novox/hq +// issue 300). Anything else — a module moved to another repository, a module from a repository the +// catalogue has never built — is the operator's, at the controller's terminal: allowed only when the build +// request was kept as asked there. Judged at the take-in, which every outcome reaches whoever hears it and +// whichever verb asked it (`build`, `rebuild`, `replay --register`, `assign` with build), and before the ask +// for a call through a verb, so an agent cannot have a build node run a repository the catalogue does not +// build from at all. + +// errNotItsSource is an outcome refused for where it was built from. +var errNotItsSource = errors.New("not built from the repository the catalogue builds it from") + +// servedVar marks every process the serving controller starts — each verb's command, each child — and the serving +// process itself, so none of them can read as the operator at the terminal (novox/hq ADR 0266). Set by serve +// before it answers anything, inherited by every child through os.Environ. +const servedVar = "MESH_SERVED_BY_THE_CONTROLLER" + +// startedAtTheTerminal says this process was started at the controller's terminal: not the serving controller, +// not anything it started, not a verb's command, not a seat call's. The serving controller marks its own +// environment (servedVar), so a build asked in it, or by any process it starts, never reads as the terminal's; +// runVerb also names the verb and the caller. +// +// **And a line mesh-cli asked as the controller's terminal** (novox/hq ADR 0272 §4): the serving controller runs it +// without the served mark and without a verb, names its caller, and marks it with cliTerminalVar — a mark only the +// mesh-cli path sets and every other command line the controller runs is stripped of (commandEnvironment). +func startedAtTheTerminal() bool { + if os.Getenv(servedVar) != "" || os.Getenv(verbVar) != "" { + return false + } + return os.Getenv(link.CallerVar) == "" || os.Getenv(cliTerminalVar) != "" +} + +// cliTerminalVar marks a command line the serving controller runs as the controller's terminal for mesh-cli. +const cliTerminalVar = "MESH_CLI_TERMINAL" + +// markServed marks this process, and so everything it starts, as the serving controller's. +func markServed() { + if err := os.Setenv(servedVar, "1"); err != nil { + panic("the serving controller could not mark its environment: " + err.Error()) + } +} + +// sourceForms compares sources however each is spelled: a path on a seat's holder (ADR 0111) or a URL — a +// build asked of a seat's path is registered with the URL composed from it when its outcome does not echo +// the seat. A seat's path is composed into its URL where the seat's holder is known, so both spellings of +// one repository are one; where it is not, a seat's path matches only the same seat's same path. +type sourceForms struct { + bases map[string]string // the seat's clone base, `scheme://host:port`, by seat; "" where it is not known + base func(seat string) string +} + +// newSourceForms reads the seats' bases from the mesh once, when first needed. +func newSourceForms(ctx context.Context, inv *inventory.Inventory) *sourceForms { + f := &sourceForms{bases: map[string]string{}} + var world *catalogue.World + f.base = func(seat string) string { + if b, known := f.bases[seat]; known { + return b + } + if world == nil { + w := catalogue.World{} + if shelf, err := inv.Catalogue(ctx); err == nil { + if read, err := theRestOfTheMesh(ctx, inv, shelf, ""); err == nil { + w = read + } + } + world = &w + } + b, err := seatBase(*world, seat) + if err != nil { + b = "" + } + f.bases[seat] = b + return b + } + return f +} + +// canonical is one spelling of a repository: lower case, no `.git`, no trailing slash, and a seat's path as +// the URL its holder serves it at where that is known. +func (f *sourceForms) canonical(repository, seat string) string { + trim := func(s string) string { + s = strings.TrimSpace(strings.ToLower(s)) + s = strings.TrimRight(s, "/") + return strings.TrimRight(strings.TrimSuffix(s, ".git"), "/") + } + if seat == "" { + return trim(repository) + } + if b := f.base(seat); b != "" { + return trim(b + "/" + strings.Trim(repository, "/")) + } + return "seat:" + seat + ":" + trim(strings.Trim(repository, "/")) +} + +// same says two sources are one repository. +func (f *sourceForms) same(aRepository, aSeat, bRepository, bSeat string) bool { + if aRepository == "" || bRepository == "" { + return false + } + return f.canonical(aRepository, aSeat) == f.canonical(bRepository, bSeat) +} + +// buildsFrom says the catalogue builds some module from this repository. +func (f *sourceForms) buildsFrom(entries []inventory.Entry, repository, seat string) bool { + for _, e := range entries { + if e.Provided || e.Source.Repository == "" { + continue + } + if f.same(e.Source.Repository, e.Source.Seat, repository, seat) { + return true + } + } + return false +} + +// forgeFacts is what the mesh's forge says of a repository a module is registered from (novox/hq ADR 0266). +type forgeFacts struct { + // ID is the forge's own id of the repository: what tells it from one deleted and made again by its name. + ID int64 + // Guarded is whether the branch is protected as a module's trunk must be: no direct push, at least one + // required status, and no administrator merging past one. Why says what is missing when it is not. + Guarded bool + Why string +} + +// askTheForge asks the forge, through its module's tools on the bus, for a repository's id and its branch's +// protection. A variable so a test needs no forge. +var askTheForge = func(ctx context.Context, owner, repo, branch string) (forgeFacts, error) { + js, err := aBus() + if err != nil { + return forgeFacts{}, err + } + defer js.Close() + bus := link.OverNATS{Conn: js.Conn()} + ask := func(tool string, args map[string]any, into any) error { + raw, _ := json.Marshal(args) + answer, err := link.Ask(ctx, bus, "gitea", tool, raw, 30*time.Second) + if err != nil { + return err + } + if answer.Error != "" { + return fmt.Errorf("gitea.%s: %s", tool, answer.Error) + } + return json.Unmarshal(answer.Result, into) + } + var found struct { + Result struct { + ID int64 `json:"id"` + FullName string `json:"full_name"` + } `json:"result"` + } + if err := ask("gitea_api", map[string]any{"path": "/repos/" + owner + "/" + repo}, &found); err != nil { + return forgeFacts{}, err + } + if found.Result.ID == 0 { + return forgeFacts{}, fmt.Errorf("the forge named no id for %s/%s", owner, repo) + } + var rules struct { + Rules []struct { + Rule string `json:"rule"` + Push bool `json:"push"` + RequiredStatuses []string `json:"required_statuses"` + AdminMayOverride bool `json:"admin_may_override"` + } `json:"rules"` + } + if err := ask("gitea_branch_protection_get", map[string]any{"owner": owner, "repo": repo}, &rules); err != nil { + return forgeFacts{}, err + } + facts := forgeFacts{ID: found.Result.ID, Why: fmt.Sprintf("no protection rule covers %s", branch)} + for _, r := range rules.Rules { + if !ruleCovers(r.Rule, branch) { + continue + } + switch { + case r.Push: + facts.Why = fmt.Sprintf("the rule %s lets a person push to %s directly", r.Rule, branch) + case len(r.RequiredStatuses) == 0: + facts.Why = fmt.Sprintf("the rule %s requires no status before a merge into %s", r.Rule, branch) + case r.AdminMayOverride: + facts.Why = fmt.Sprintf("the rule %s lets an administrator merge into %s past a status", r.Rule, branch) + default: + return forgeFacts{ID: facts.ID, Guarded: true}, nil + } + } + return facts, nil +} + +// ruleCovers says a protection rule's name — a branch, or a glob of them — covers a branch, as the forge reads it. +func ruleCovers(rule, branch string) bool { + if rule == branch { + return true + } + if !strings.ContainsAny(rule, "*?[") { + return false + } + var re strings.Builder + re.WriteString("^") + for i := 0; i < len(rule); i++ { + switch c := rule[i]; { + case c == '*' && i+1 < len(rule) && rule[i+1] == '*': + re.WriteString(".*") + i++ + case c == '*': + re.WriteString("[^/]*") + case c == '?': + re.WriteString("[^/]") + default: + re.WriteString(regexp.QuoteMeta(string(c))) + } + } + re.WriteString("$") + ok, _ := regexp.MatchString(re.String(), branch) + return ok +} + +// onTheForge is a source's owner and name on the mesh's own forge (the git seat's holder), and whether it is +// there at all. +func (f *sourceForms) onTheForge(repository, seat string) (owner, name string, ok bool) { + var rest string + switch { + case seat == gitSeat: + rest = strings.Trim(repository, "/") + case seat == "": + base := f.base(gitSeat) + if base == "" { + return "", "", false + } + url, prefix := f.canonical(repository, ""), f.canonical(base, "")+"/" + if !strings.HasPrefix(url, prefix) { + return "", "", false + } + // The case the forge spells it with: the URL as given, past the base. + rest = strings.TrimSuffix(strings.Trim(repository[len(prefix):], "/"), ".git") + default: + return "", "", false + } + parts := strings.Split(rest, "/") + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return "", "", false + } + return parts[0], parts[1], true +} + +// mayRegisterFrom says whether a build's outcome may register module from the source it was built from, and the +// forge's id of that repository to record (novox/hq ADR 0266). Through any verb, only: +// +// - from the module's registered repository — the same repository by the forge's own id, not only its name; or, +// for a module new to the catalogue, from a repository the catalogue builds another module from; +// - and from a repository on the mesh's forge whose trunk is protected as a trunk must be: no direct push, at +// least one required status, no administrator merging past one. +// +// Anything else only when the build request was kept as asked at the controller's terminal, for this very +// repository and path. path is the module's directory as built; branch the trunk it is registered from. +func mayRegisterFrom(ctx context.Context, inv *inventory.Inventory, module string, built inventory.Source, + buildID, path, branch string) (int64, error) { + forms := newSourceForms(ctx, inv) + was, err := inv.SourceOf(ctx, module) + isNew := errors.Is(err, inventory.ErrNoSuchModule) + if err != nil && !isNew { + return 0, err + } + terminal, err := askedHereFor(ctx, inv, forms, buildID, built, path) + if err != nil { + return 0, err + } + refuse := func(why string) (int64, error) { + return 0, fmt.Errorf("%w: %s. A module is registered from such a source only by a build asked at the "+ + "controller's terminal, never through a verb: a registered module is what the next push sends, and whoever "+ + "may call a verb includes agents (novox/hq ADR 0266)", errNotItsSource, why) + } + + var why string + var alongside []inventory.Entry // the modules a new one's repository already builds + switch { + case !isNew && forms.same(was.Repository, was.Seat, built.Repository, built.Seat): + case !isNew: + registered := was.Repository + if registered == "" { + registered = "no repository (it was handed over by hand)" + } + why = fmt.Sprintf("%s is built from %s, and this build is of %s", module, sourceWords(registered, was.Seat), + sourceWords(built.Repository, built.Seat)) + default: + entries, err := inv.Catalogued(ctx) + if err != nil { + return 0, err + } + for _, e := range entries { + if !e.Provided && e.Source.Repository != "" && + forms.same(e.Source.Repository, e.Source.Seat, built.Repository, built.Seat) { + alongside = append(alongside, e) + } + } + if len(alongside) == 0 { + why = fmt.Sprintf("%s is new to the catalogue, and %s is no repository the catalogue builds a module from", + module, sourceWords(built.Repository, built.Seat)) + } + } + if why != "" && !terminal { + return refuse(why) + } + + owner, name, onForge := forms.onTheForge(built.Repository, built.Seat) + if !onForge { + if terminal { + fmt.Printf("%s: %s is not on the mesh's forge — registered, as asked at the controller's terminal\n", + buildID, sourceWords(built.Repository, built.Seat)) + return 0, nil + } + return refuse(fmt.Sprintf("%s is not on the mesh's forge, so whether its trunk is protected cannot be read", + sourceWords(built.Repository, built.Seat))) + } + facts, err := askTheForge(ctx, owner, name, branch) + if err != nil { + if terminal { + fmt.Printf("%s: the forge could not be asked about %s/%s (%v) — registered, as asked at the controller's "+ + "terminal\n", buildID, owner, name, err) + return 0, nil + } + return refuse(fmt.Sprintf("the forge could not say whether %s/%s's %s is protected: %v", owner, name, branch, err)) + } + if terminal { + if why != "" || !facts.Guarded { + fmt.Printf("%s: %s — registered, as asked at the controller's terminal\n", buildID, + strings.Trim(why+"; "+facts.Why, "; ")) + } + return facts.ID, nil + } + if !facts.Guarded { + return refuse(fmt.Sprintf("%s/%s's %s is not protected as a module's trunk must be: %s", owner, name, branch, + facts.Why)) + } + // The same repository by the forge's id, not only its name: one deleted and made again is another. + recorded := map[string]int64{} + if !isNew { + id, err := inv.SourceIdentity(ctx, module) + if err != nil { + return 0, err + } + recorded[module] = id + } + for _, e := range alongside { + id, err := inv.SourceIdentity(ctx, e.Manifest.Module) + if err != nil { + return 0, err + } + recorded[e.Manifest.Module] = id + } + for m, id := range recorded { + if id != 0 && id != facts.ID { + return refuse(fmt.Sprintf("%s/%s is not the repository %s was registered from: the forge knows it as "+ + "repository %d, and %s was registered from repository %d — one of that name deleted and made again", + owner, name, m, facts.ID, m, id)) + } + } + return facts.ID, nil +} + +// askedHereFor says the build was asked at the controller's terminal, for this repository and this path: a kept +// request marked so, whose source is the outcome's (novox/hq ADR 0266). +func askedHereFor(ctx context.Context, inv *inventory.Inventory, forms *sourceForms, buildID string, + built inventory.Source, path string) (bool, error) { + r, found, err := inv.BuildRequestByID(ctx, buildID) + if err != nil || !found || !r.AtTerminal { + return false, err + } + if !forms.same(r.Repository, r.Seat, built.Repository, built.Seat) || + strings.Trim(r.Path, "/") != strings.Trim(path, "/") { + fmt.Printf("%s was asked at the terminal of %s at %q, and its outcome is of %s at %q: not the terminal's\n", + buildID, sourceWords(r.Repository, r.Seat), r.Path, sourceWords(built.Repository, built.Seat), path) + return false, nil + } + return true, nil +} + +// sourceWords is a source as a person reads it. +func sourceWords(repository, seat string) string { + if seat == "" { + return repository + } + return buildSource{Repository: repository, Seat: seat}.String() +} + +// verbMayAsk refuses, for a call through a verb, a build of a repository the catalogue builds no module from +// (novox/hq ADR 0266): the build node would run what an agent wrote, and its outcome could never be +// registered anyway. url is the repository as it is cloned. At the terminal anything may be asked. +func verbMayAsk(ctx context.Context, source buildSource, url string) error { + if startedAtTheTerminal() { + return nil + } + open, err := openStores(ctx) + if err != nil { + return err + } + defer open.Close() + entries, err := open.inventory.Catalogued(ctx) + if err != nil { + return err + } + forms := newSourceForms(ctx, open.inventory) + if forms.buildsFrom(entries, source.Repository, source.Seat) || forms.buildsFrom(entries, url, "") { + return nil + } + return terminalRefusal("%s is no repository the catalogue builds a module from, and a build of any other is "+ + "asked at the controller's terminal only, never through a verb: a build node runs what the repository "+ + "says, and its outcome would register a module the next push sends — whoever may call a verb includes "+ + "agents (novox/hq ADR 0266). Nothing was asked", source) +} diff --git a/cmd/mesh-controller/build_source_test.go b/cmd/mesh-controller/build_source_test.go new file mode 100644 index 00000000..69fc5d8a --- /dev/null +++ b/cmd/mesh-controller/build_source_test.go @@ -0,0 +1,345 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "hash/fnv" + "os" + "os/exec" + "slices" + "strings" + "sync" + "testing" + + "github.com/novox/mesh-controller/internal/inventory" + "github.com/novox/mesh-controller/internal/link" +) + +// The routes to root a review of ADR 0266 found (novox/hq ADR 0266 §7): an agent makes a repository of its +// own — or forks one the mesh builds from — commits a module.json naming a module the mesh runs everywhere +// (`sudo`, granting itself a rule without a password; `mesh-host`, the node-engine), and asks the `build` verb +// for it. Its outcome was registered under that name from the agent's repository, and the next push sent it. + +// onTrunk is an outcome of a commit on its repository's trunk, as the build seat says it. +func onTrunk(id, repository, seat, path string, manifest map[string]any) link.BuildResult { + raw, _ := json.Marshal(manifest) + r := link.BuildResult{ID: id, Repository: "http://forge.internal:20000/" + repository + ".git", Path: path, + Ref: "main", On: "anchor", Commit: "c0ffee0123456789", Manifest: raw, + Trunk: "main", OnTrunk: true, Branches: []string{"main"}} + if seat != "" { + r.Source = &link.SourceOnSeat{Seat: seat, Repository: repository} + } + return r +} + +// keptAsked keeps a build request as the asker would: through a verb, or at the terminal. +func keptAsked(t *testing.T, inv *inventory.Inventory, id, repository, path string, atTerminal bool) { + t.Helper() + if err := inv.RecordBuildRequest(t.Context(), inventory.BuildRequest{ID: id, Repository: repository, Seat: "git", + Path: path, For: "build", AtTerminal: atTerminal}); err != nil { + t.Fatal(err) + } +} + +// theCatalogue is a mesh whose sudo is built from the catalogue repository and whose node-engine from its own. +func theCatalogue(t *testing.T) *stores { + t.Helper() + open := aMesh(t) + ctx := t.Context() + for _, b := range []link.BuildResult{ + onTrunk("build-sudo", "novox/mesh-catalog", "git", "modules/sudo", map[string]any{"module": "sudo", "version": "1"}), + onTrunk("build-host", "novox/mesh-host", "git", "", map[string]any{"module": "mesh-host", "version": "1"}), + } { + keptAsked(t, open.inventory, b.ID, b.Source.Repository, b.Path, true) + if _, _, err := takeIn(ctx, open.inventory, b); err != nil { + t.Fatal(err) + } + } + return open +} + +func TestABuildFromAnAgentsRepositoryIsNotRegisteredAsAModuleTheMeshHolds(t *testing.T) { + open := theCatalogue(t) + ctx := t.Context() + for _, c := range []struct { + name, repository, path, module string + }{ + {"its own repository naming sudo", "agent/sudo", "modules/sudo", "sudo"}, + {"a fork of the catalogue", "agent/mesh-catalog", "modules/sudo", "sudo"}, + {"a fork of the node-engine", "agent/mesh-host", "", "mesh-host"}, + } { + t.Run(c.name, func(t *testing.T) { + id := "build-" + strings.ReplaceAll(c.repository, "/", "-") + keptAsked(t, open.inventory, id, c.repository, c.path, false) // through the build verb + evil := onTrunk(id, c.repository, "git", c.path, map[string]any{"module": c.module, "version": "evil"}) + _, _, err := takeIn(ctx, open.inventory, evil) + if !errors.Is(err, errNotItsSource) { + t.Fatalf("a build of %s was taken in as %s: %v", c.repository, c.module, err) + } + shelf, err := open.inventory.Catalogue(ctx) + if err != nil { + t.Fatal(err) + } + if got := shelf[c.module].Version; got != "1" { + t.Fatalf("%s is now %q, from %s", c.module, got, c.repository) + } + if _, found, _ := open.inventory.BuildByID(ctx, id); !found { + t.Errorf("the refused build %s is not recorded", id) + } + }) + } +} + +func TestANewModuleFromARepositoryTheCatalogueDoesNotBuildFromIsNotRegistered(t *testing.T) { + open := theCatalogue(t) + ctx := t.Context() + keptAsked(t, open.inventory, "build-new", "agent/tools", "", false) + _, _, err := takeIn(ctx, open.inventory, onTrunk("build-new", "agent/tools", "git", "", + map[string]any{"module": "agent-tools", "version": "1"})) + if !errors.Is(err, errNotItsSource) { + t.Fatalf("a new module from an agent's repository was taken in: %v", err) + } + // And one asked of nobody here — an outcome on the bus no request was kept for — the same. + _, _, err = takeIn(ctx, open.inventory, onTrunk("build-unasked", "agent/tools", "git", "", + map[string]any{"module": "agent-tools", "version": "1"})) + if !errors.Is(err, errNotItsSource) { + t.Fatalf("an outcome nobody asked for was taken in: %v", err) + } + if shelf, _ := open.inventory.Catalogue(ctx); shelf["agent-tools"].Module != "" { + t.Fatal("the refused module is in the catalogue") + } +} + +// The operator at the terminal may still move a module, or add one from a new repository. +func TestAtTheTerminalAnotherRepositoryIsRegistered(t *testing.T) { + open := theCatalogue(t) + ctx := t.Context() + keptAsked(t, open.inventory, "build-moved", "novox/sudo", "", true) + if _, _, err := takeIn(ctx, open.inventory, onTrunk("build-moved", "novox/sudo", "git", "", + map[string]any{"module": "sudo", "version": "2"})); err != nil { + t.Fatalf("a move the operator asked for at the terminal was refused: %v", err) + } + if src, _ := open.inventory.SourceOf(ctx, "sudo"); src.Repository != "novox/sudo" { + t.Fatalf("sudo is built from %q", src.Repository) + } + if err := open.inventory.RecordBuildRequest(ctx, inventory.BuildRequest{ID: "build-external", + Repository: "http://forge.internal:20000/someone/app.git", For: "build", AtTerminal: true}); err != nil { + t.Fatal(err) + } + if _, _, err := takeIn(ctx, open.inventory, onTrunk("build-external", "someone/app", "", "", + map[string]any{"module": "app", "version": "1"})); err != nil { + t.Fatalf("a new module the operator asked for at the terminal was refused: %v", err) + } +} + +// The delivery's flow is untouched: a merge's rebuild of a module from its own repository, and a merge adding +// a module to a repository the catalogue builds from (novox/hq issue 300), are registered with no terminal. +func TestADeliveryFromTheRegisteredRepositoryIsRegistered(t *testing.T) { + open := theCatalogue(t) + ctx := t.Context() + rebuilt := onTrunk("build-plan", "novox/mesh-catalog", "git", "modules/sudo", map[string]any{"module": "sudo", "version": "2"}) + if err := open.inventory.RecordBuildRequest(ctx, inventory.BuildRequest{ID: rebuilt.ID, + Repository: "novox/mesh-catalog", Seat: "git", Path: "modules/sudo", For: "plan"}); err != nil { + t.Fatal(err) + } + if _, _, err := takeIn(ctx, open.inventory, rebuilt); err != nil { + t.Fatalf("a plan's build of the module's own repository was refused: %v", err) + } + added := onTrunk("build-merge", "novox/mesh-catalog", "git", "modules/zram", map[string]any{"module": "zram", "version": "1"}) + if _, _, err := takeIn(ctx, open.inventory, added); err != nil { + t.Fatalf("a module a merge added to the catalogue repository was refused: %v", err) + } + shelf, _ := open.inventory.Catalogue(ctx) + if shelf["sudo"].Version != "2" || shelf["zram"].Module == "" { + t.Fatalf("not registered: sudo %q, zram %q", shelf["sudo"].Version, shelf["zram"].Module) + } +} + +// Through a verb, a build of a repository the catalogue builds nothing from is not even asked: the build node +// would run what the agent wrote. +func TestAVerbAsksNoBuildOfARepositoryTheCatalogueDoesNotBuildFrom(t *testing.T) { + theCatalogue(t) + ctx := t.Context() + t.Setenv(verbVar, "build") + t.Setenv(link.CallerVar, "node-tools.anchor, through the mesh-controller seat") + err := verbMayAsk(ctx, buildSource{Repository: "agent/sudo", Seat: "git"}, "http://forge.internal:20000/agent/sudo.git") + var policy *heldAtTheTerminal + if !errors.As(err, &policy) { + t.Fatalf("a verb's build of an agent's repository was asked: %v", err) + } + if err := verbMayAsk(ctx, buildSource{Repository: "novox/mesh-catalog", Seat: "git"}, + "http://forge.internal:20000/novox/mesh-catalog.git"); err != nil { + t.Fatalf("a verb's build of the catalogue repository was refused: %v", err) + } + t.Setenv(verbVar, "") + t.Setenv(link.CallerVar, "") + if err := verbMayAsk(ctx, buildSource{Repository: "agent/sudo", Seat: "git"}, ""); err != nil { + t.Fatalf("the terminal was refused: %v", err) + } +} + +// asTheOperator keeps a build as asked at the controller's terminal, as the operator's first build of a module +// from a repository the catalogue does not yet build from is (novox/hq ADR 0266), and hands it back. +func asTheOperator(t *testing.T, inv *inventory.Inventory, b link.BuildResult) link.BuildResult { + t.Helper() + repository, seat := b.Repository, "" + if b.Source != nil { + repository, seat = b.Source.Repository, b.Source.Seat + } + if err := inv.RecordBuildRequest(t.Context(), inventory.BuildRequest{ID: b.ID, Repository: repository, Seat: seat, + Path: b.Path, For: "build", AtTerminal: true}); err != nil { + t.Fatal(err) + } + return b +} + +// A rollback puts back only a build of the module's own repository: an agent's build of the module's name, +// recorded and refused, at the very commit the machine ran before (a fork carries it), is never registered by +// the back door of a failed gate. +func TestARollbackNeverPutsBackABuildFromAnotherRepository(t *testing.T) { + open := theCatalogue(t) + ctx := t.Context() + inv := open.inventory + fork := onTrunk("build-1791500000000000000", "agent/mesh-catalog", "git", "modules/sudo", + map[string]any{"module": "sudo", "version": "evil"}) + fork.Commit = "c0ffee0123456789" // the commit sudo was registered at + keptAsked(t, inv, fork.ID, "agent/mesh-catalog", "modules/sudo", false) + if _, _, err := takeIn(ctx, inv, fork); !errors.Is(err, errNotItsSource) { + t.Fatalf("the fork's build was taken in: %v", err) + } + failed := onTrunk("build-1791600000000000000", "novox/mesh-catalog", "git", "modules/sudo", + map[string]any{"module": "sudo", "version": "2"}) + failed.Commit = "badbadbad0123456" + if _, _, err := takeIn(ctx, inv, failed); err != nil { + t.Fatal(err) + } + record, _, err := inv.BuildByID(ctx, failed.ID) + if err != nil { + t.Fatal(err) + } + previous, found, err := inv.PreviousBuild(ctx, "sudo", "c0ffee0123456789", record) + if err != nil { + t.Fatal(err) + } + if found && previous.ID == fork.ID { + t.Fatalf("a rollback would put back the fork's build %s", previous.ID) + } + if !found || previous.ID != "build-sudo" { + t.Fatalf("a rollback puts back %q (found %v), want the registered build-sudo", previous.ID, found) + } +} + +// theForge is what the forge says in a test, by owner/name: a repository not named here is protected as a +// trunk must be, with an id of its own. +var theForge sync.Map + +func init() { + askTheForge = func(_ context.Context, owner, repo, _ string) (forgeFacts, error) { + if said, ok := theForge.Load(owner + "/" + repo); ok { + switch f := said.(type) { + case error: + return forgeFacts{}, f + case forgeFacts: + return f, nil + } + } + h := fnv.New32a() + _, _ = h.Write([]byte(owner + "/" + repo)) + return forgeFacts{ID: int64(h.Sum32()), Guarded: true}, nil + } +} + +// A module's trunk the forge does not protect — direct pushes, no required status — or a forge that cannot say, +// registers nothing through a verb: the trunk rule means nothing on a branch anyone pushes to. +func TestATrunkTheForgeDoesNotProtectRegistersNothing(t *testing.T) { + open := theCatalogue(t) + ctx := t.Context() + theForge.Store("novox/unguarded", forgeFacts{ID: 7, Why: "the rule main lets a person push to main directly"}) + t.Cleanup(func() { theForge.Delete("novox/unguarded") }) + first := onTrunk("build-unguarded-1", "novox/unguarded", "git", "", map[string]any{"module": "unguarded", "version": "1"}) + keptAsked(t, open.inventory, first.ID, "novox/unguarded", "", true) + if _, _, err := takeIn(ctx, open.inventory, first); err != nil { + t.Fatalf("at the terminal: %v", err) + } + again := onTrunk("build-unguarded-2", "novox/unguarded", "git", "", map[string]any{"module": "unguarded", "version": "2"}) + if _, _, err := takeIn(ctx, open.inventory, again); !errors.Is(err, errNotItsSource) || + !strings.Contains(err.Error(), "push to main directly") { + t.Fatalf("a rebuild from an unprotected trunk was taken in: %v", err) + } + theForge.Store("novox/unguarded", errors.New("nothing serves gitea.gitea_api")) + if _, _, err := takeIn(ctx, open.inventory, onTrunk("build-unguarded-3", "novox/unguarded", "git", "", + map[string]any{"module": "unguarded", "version": "3"})); !errors.Is(err, errNotItsSource) { + t.Fatalf("a forge that could not say was read as a protected trunk: %v", err) + } + if shelf, _ := open.inventory.Catalogue(ctx); shelf["unguarded"].Version != "1" { + t.Fatalf("unguarded is %q", shelf["unguarded"].Version) + } +} + +// A repository deleted and made again under the module's repository's name is another repository: the forge's +// id, recorded at registration, tells them apart. +func TestARepositoryMadeAgainUnderItsNameIsNotTheModulesSource(t *testing.T) { + open := theCatalogue(t) + ctx := t.Context() + theForge.Store("novox/remade", forgeFacts{ID: 100, Guarded: true}) + t.Cleanup(func() { theForge.Delete("novox/remade") }) + first := onTrunk("build-remade-1", "novox/remade", "git", "", map[string]any{"module": "remade", "version": "1"}) + keptAsked(t, open.inventory, first.ID, "novox/remade", "", true) + if _, _, err := takeIn(ctx, open.inventory, first); err != nil { + t.Fatal(err) + } + if id, _ := open.inventory.SourceIdentity(ctx, "remade"); id != 100 { + t.Fatalf("the forge's id was not recorded: %d", id) + } + theForge.Store("novox/remade", forgeFacts{ID: 101, Guarded: true}) // deleted, and made again by an agent + if _, _, err := takeIn(ctx, open.inventory, onTrunk("build-remade-2", "novox/remade", "git", "", + map[string]any{"module": "remade", "version": "evil"})); !errors.Is(err, errNotItsSource) || + !strings.Contains(err.Error(), "made again") { + t.Fatalf("a repository made again under the name was taken in: %v", err) + } + // And a new module from it, beside the one registered from the first, the same. + if _, _, err := takeIn(ctx, open.inventory, onTrunk("build-remade-3", "novox/remade", "git", "modules/other", + map[string]any{"module": "other", "version": "1"})); !errors.Is(err, errNotItsSource) { + t.Fatalf("a new module from a repository made again was taken in: %v", err) + } +} + +// The terminal's mark is the operator's for the repository and path they asked: an outcome of another, under that +// build's id, is not theirs. +func TestATerminalRequestCoversOnlyWhatItAsked(t *testing.T) { + open := theCatalogue(t) + ctx := t.Context() + if err := open.inventory.RecordBuildRequest(ctx, inventory.BuildRequest{ID: "build-asked", Repository: "novox/app", + Seat: "git", Path: "modules/app", For: "build", AtTerminal: true}); err != nil { + t.Fatal(err) + } + other := onTrunk("build-asked", "agent/sudo", "git", "modules/app", map[string]any{"module": "sudo", "version": "evil"}) + if _, _, err := takeIn(ctx, open.inventory, other); !errors.Is(err, errNotItsSource) { + t.Fatalf("an outcome of another repository under a terminal request's id was taken in: %v", err) + } + elsewhere := onTrunk("build-asked", "novox/app", "git", "modules/sudo", map[string]any{"module": "sudo", "version": "evil"}) + if _, _, err := takeIn(ctx, open.inventory, elsewhere); !errors.Is(err, errNotItsSource) { + t.Fatalf("an outcome of another path under a terminal request's id was taken in: %v", err) + } +} + +// The serving controller, and everything it starts, is never the terminal: a build asked in it reads as asked +// through the mesh even when no verb and no caller is named. +func TestTheServingControllerIsNeverTheTerminal(t *testing.T) { + t.Setenv(verbVar, "") + t.Setenv(link.CallerVar, "") + t.Setenv(servedVar, "") + if !startedAtTheTerminal() { + t.Fatal("a process started by hand is not the terminal") + } + markServed() + if startedAtTheTerminal() { + t.Fatal("the serving controller reads as the terminal") + } + child := exec.Command(os.Args[0], "-test.run=^$") + child.Env = os.Environ() + if !slices.Contains(child.Env, servedVar+"=1") { + t.Fatal("what the serving controller starts does not carry its mark") + } +} diff --git a/cmd/mesh-controller/build_test.go b/cmd/mesh-controller/build_test.go index a7b0c695..aa70ff81 100644 --- a/cmd/mesh-controller/build_test.go +++ b/cmd/mesh-controller/build_test.go @@ -19,11 +19,11 @@ func TestABuildHeardIsRecordedAndRegistered(t *testing.T) { open := aMesh(t) ctx := t.Context() manifest, _ := json.Marshal(map[string]any{"module": "shop", "version": "3"}) - m, _, err := takeIn(ctx, open.inventory, link.BuildResult{ + m, _, err := takeIn(ctx, open.inventory, asTheOperator(t, open.inventory, link.BuildResult{ ID: "b-1", Repository: "http://forge.internal:20000/novox/shop.git", Path: "modules/shop", Ref: "main", On: "anchor", Commit: "abcdef0123", Manifest: manifest, Source: &link.SourceOnSeat{Seat: "git", Repository: "novox/shop"}, - }) + })) if err != nil { t.Fatal(err) } @@ -78,7 +78,7 @@ func TestABuildAtACommitKeepsTheBranchTheModuleFollows(t *testing.T) { Path: "modules/unifi", Ref: ref, On: "anchor", Commit: commit, Manifest: manifest, Source: &link.SourceOnSeat{Seat: "git", Repository: "novox/mesh-catalog"}} } - if _, _, err := takeIn(ctx, open.inventory, result("b-1", "main", "1111111aaaa")); err != nil { + if _, _, err := takeIn(ctx, open.inventory, asTheOperator(t, open.inventory, result("b-1", "main", "1111111aaaa"))); err != nil { t.Fatal(err) } if _, _, err := takeIn(ctx, open.inventory, result("b-2", "9c97a8a", "9c97a8a1d2c3")); err != nil { @@ -117,7 +117,7 @@ func TestAnOlderBuildHeardLaterDoesNotReplaceTheNewer(t *testing.T) { Path: "modules/postgres", Ref: "main", On: "anchor", Commit: "efff5415", Manifest: manifest, Source: &link.SourceOnSeat{Seat: "git", Repository: "novox/mesh-catalog"}} } - if _, _, err := takeIn(ctx, open.inventory, result(newer, "4bcd5f73")); err != nil { + if _, _, err := takeIn(ctx, open.inventory, asTheOperator(t, open.inventory, result(newer, "4bcd5f73"))); err != nil { t.Fatal(err) } _, _, err := takeIn(ctx, open.inventory, result(older, "0ab07fa9")) diff --git a/cmd/mesh-controller/calls_verb_test.go b/cmd/mesh-controller/calls_verb_test.go index 1aaea74f..606a53f8 100644 --- a/cmd/mesh-controller/calls_verb_test.go +++ b/cmd/mesh-controller/calls_verb_test.go @@ -37,8 +37,6 @@ func TestAPushAnswersBeforeItSends(t *testing.T) { }{ {"push", map[string]any{"node": "anchor", "why": "w"}, true}, {"push", map[string]any{"why": "w"}, true}, - {"command", map[string]any{"command": "push anchor --why w"}, true}, - {"command", map[string]any{"command": "push --behind --why=w"}, true}, {"command", map[string]any{"command": "builds"}, false}, {"status", map[string]any{}, false}, {"assign", map[string]any{"node": "anchor", "module": "m"}, false}, diff --git a/cmd/mesh-controller/doctor.go b/cmd/mesh-controller/doctor.go index 54f533ba..b694dd43 100644 --- a/cmd/mesh-controller/doctor.go +++ b/cmd/mesh-controller/doctor.go @@ -121,6 +121,11 @@ var probeRegistry = []probe{ {ID: probeReconnectsID, Asserts: "no user of the bus had its connection dropped more than twelve times in the " + "last hour: the bus module's nats_closed_connections", From: "issue 327", Kind: kindBusReconnects, Phase: 1, run: probeReconnects}, + // The account agents run as (novox/hq ADR 0266): where a machine names one, its node-engine has judged it + // unable to become root without a person — what ADR 0259 §8 rests an authorised answer on. + {ID: agentAccountProbe, Asserts: "every machine that names an agent account has it judged, on its node-engine's " + + "newest statement, unable to become root without a person", From: "ADR 0266, ADR 0259 §8", + Kind: kindAgentCanBecomeRoot, Phase: 1, run: probeAgentAccounts}, {ID: "DW", Asserts: "the watchdogs of the signals table ran within three of their intervals", From: "ADR 0227 rule 6: the watchers are watched", Kind: "watchdogs-silent", Phase: 1, run: probeWatchdogs}, // The core's health definitions (novox/hq to-be 45 §8, ADR 0236): what a core component's new build is diff --git a/cmd/mesh-controller/facts.go b/cmd/mesh-controller/facts.go index 3990026c..01ffd437 100644 --- a/cmd/mesh-controller/facts.go +++ b/cmd/mesh-controller/facts.go @@ -269,7 +269,8 @@ func gatherFacts(ctx context.Context, open *stores, busVersion string) (snapshot engines := map[string]bool{} for _, n := range nodes { m := snapshot.Machine{Name: scrub.Machine(n.Name), Length: len(n.Name), Adopted: n.Adopted, - AccountHome: scrub.Text(n.AccountHome), NodeEngine: n.HostVersion, PublicDomain: domains[n.Name]} + AccountHome: scrub.Text(n.AccountHome), NodeEngine: n.HostVersion, PublicDomain: domains[n.Name], + AgentAccount: scrub.Account(n.AgentAccount), AgentAccountHome: scrub.Text(n.AgentAccountHome)} switch n.Account { case "", "root": m.Account = n.Account diff --git a/cmd/mesh-controller/gate_test.go b/cmd/mesh-controller/gate_test.go index e8d55ce2..9bbd46d3 100644 --- a/cmd/mesh-controller/gate_test.go +++ b/cmd/mesh-controller/gate_test.go @@ -221,7 +221,8 @@ func TestABuildThatFailsItsGateIsRolledBackOnItsFirstMachineAndGoesNoFurther(t * t.Fatalf("sent again after the rollback: %v", g.sent) } _, _, err = takeIn(ctx, inv, link.BuildResult{ID: "build-2", Repository: "novox/mesh-catalog", Path: "modules/app", - Commit: "c2", Manifest: mustJSON(t, catalogue.Manifest{Module: "app", Version: "c2"})}) + Commit: "c2", Manifest: mustJSON(t, catalogue.Manifest{Module: "app", Version: "c2"}), + Source: &link.SourceOnSeat{Seat: "git", Repository: "novox/mesh-catalog"}}) if err == nil || !strings.Contains(err.Error(), "failed its gate") { t.Fatalf("the failed build was registered again: %v", err) } diff --git a/cmd/mesh-controller/handacts_test.go b/cmd/mesh-controller/handacts_test.go index b8d445b7..7d2713d5 100644 --- a/cmd/mesh-controller/handacts_test.go +++ b/cmd/mesh-controller/handacts_test.go @@ -22,10 +22,6 @@ func TestARepairByHandWithoutAReasonIsRefused(t *testing.T) { {"plans", map[string]any{"close": "plan-1"}}, {"plans", map[string]any{"stop": "plan-1"}}, {"hand-act", map[string]any{"what": "restarted the proxy", "cause": "proxy-stuck"}}, - {"command", map[string]any{"command": "push anchor"}}, - {"command", map[string]any{"command": "plans close plan-1"}}, - {"command", map[string]any{"command": "broker consumer-reset EVENTS controller"}}, - {"command", map[string]any{"command": "hand-act record restarted --cause x"}}, } { argv, err := argvFor(c.verb, c.args) if c.verb == "plans" && err == nil { @@ -65,7 +61,6 @@ func TestARepairByHandCarriesItsReason(t *testing.T) { {"plans", map[string]any{"retry": "plan-1"}, "plans retry plan-1"}, {"hand-act", map[string]any{"what": "restarted", "why": "hung", "cause": "proxy", "condition": "machine.a.silent"}, "hand-act record restarted --why hung --cause proxy --condition machine.a.silent"}, - {"command", map[string]any{"command": "push anchor --why stuck"}, "push anchor --why stuck"}, {"command", map[string]any{"command": "plans plan-1"}, "plans plan-1"}, } { argv, err := argvFor(c.verb, c.args) diff --git a/cmd/mesh-controller/merge_gate.go b/cmd/mesh-controller/merge_gate.go index 269f76cf..dd925642 100644 --- a/cmd/mesh-controller/merge_gate.go +++ b/cmd/mesh-controller/merge_gate.go @@ -1013,6 +1013,11 @@ func raiseFromFacts(ctx context.Context, open *stores, f snapshot.Facts, shelf m return notes, err } } + if m.AgentAccount != "" { + if err := inv.SetAgentAccount(ctx, m.Name, m.AgentAccount, m.AgentAccountHome); err != nil { + return notes, err + } + } if m.PublicDomain != "" { if err := inv.SetPublicDomain(ctx, m.Name, m.PublicDomain); err != nil { return notes, err diff --git a/cmd/mesh-controller/meshcli.go b/cmd/mesh-controller/meshcli.go index f7b4c872..97c979d5 100644 --- a/cmd/mesh-controller/meshcli.go +++ b/cmd/mesh-controller/meshcli.go @@ -183,9 +183,15 @@ const settingsForms = "settings set [--replace] [--node // ADR 0272); any other line as the generic `command` verb takes it, with its refusals. func ordinaryLine(argv []string) ([]string, error) { if len(argv) == 0 || argv[0] != "settings" { + // What the generic verb runs, and nothing it would refuse: its reading forms only, and never a command that + // is the terminal's alone (novox/hq ADR 0266) — the two checks argvFor makes of the `command` verb's line, + // made of the words as given rather than re-split from one string. if err := refusedAsTheGenericCommand(argv); err != nil { return nil, err } + if err := terminalOnly(argv); err != nil { + return nil, err + } return argv, nil } args := map[string]any{} diff --git a/cmd/mesh-controller/meshcli_test.go b/cmd/mesh-controller/meshcli_test.go index 0e9b82b7..8baf28a3 100644 --- a/cmd/mesh-controller/meshcli_test.go +++ b/cmd/mesh-controller/meshcli_test.go @@ -2,11 +2,17 @@ package main import ( "context" + "encoding/base64" + "encoding/json" "strings" "testing" + "time" + + "github.com/nats-io/nats.go" "github.com/novox/mesh-controller/internal/inventory" "github.com/novox/mesh-controller/internal/link" + "github.com/novox/mesh-controller/internal/testbus" ) // echoEnvironment makes the test binary, run as a command line, say the verb and caller it was given (TestMain). @@ -60,13 +66,16 @@ func TestMeshCLIIsTheTerminalOnlyForTheControlNodesOperator(t *testing.T) { func TestTheTerminalRunsWithoutAVerbAndAnOrdinaryCallNamesMeshCLI(t *testing.T) { t.Setenv(echoEnvironment, "1") t.Setenv("MESH_VERB", "leaked-from-the-serving-process") + // The serving controller marks itself (ADR 0266); its terminal line for mesh-cli is still the terminal's. + t.Setenv(servedVar, "1") ctx := context.Background() a := runForMeshCLI(ctx, "control", asked("operator", 1000, "status"), cliVerdict{terminal: true, why: "the terminal"}) if a.Exit != 0 || a.Refused != "" || !a.Terminal { t.Fatalf("the terminal's line did not run: %+v", a) } - if got := string(a.Stdout); !strings.Contains(got, `verb=""`) || !strings.Contains(got, "operator through mesh-cli on control") { + if got := string(a.Stdout); !strings.Contains(got, `verb=""`) || !strings.Contains(got, "operator through mesh-cli on control") || + !strings.Contains(got, "terminal=true") { t.Fatalf("the terminal's line ran with %s", got) } @@ -74,7 +83,7 @@ func TestTheTerminalRunsWithoutAVerbAndAnOrdinaryCallNamesMeshCLI(t *testing.T) if a.Exit != 0 || a.Terminal || a.Why != "not the terminal" { t.Fatalf("an ordinary line did not run as one: %+v", a) } - if got := string(a.Stdout); !strings.Contains(got, `verb="mesh-cli"`) { + if got := string(a.Stdout); !strings.Contains(got, `verb="mesh-cli"`) || !strings.Contains(got, "terminal=false") { t.Fatalf("an ordinary line ran with %s", got) } } @@ -106,12 +115,22 @@ func TestAnOrdinaryCallMeetsTheCommandVerbsRefusals(t *testing.T) { // **Which node is the terminal does not follow a verb** (review of ADR 0272): the controller's module is assigned // and unassigned at the terminal alone, so no caller of `assign` can move the terminal to a node of its choosing. +// Asked through the acts themselves, as the verbs ask them; refused before any store is touched (none is given). func TestTheControllersModuleIsMovedAtTheTerminalAlone(t *testing.T) { t.Setenv("MESH_VERB", "assign") - if err := refusedMovingTheController([]string{"zsh", "mesh-controller"}); err == nil || + ctx := context.Background() + if _, err := assignWith(ctx, nil, "laptop", assignOptions{}, "zsh", "mesh-controller"); err == nil || !strings.Contains(err.Error(), "terminal") { t.Fatalf("assigning the controller through a verb was not refused: %v", err) } + if _, err := assign(ctx, nil, "laptop", "mesh-controller"); err == nil || !strings.Contains(err.Error(), "terminal") { + t.Fatalf("assigning the controller through a verb was not refused: %v", err) + } + t.Setenv("MESH_VERB", "unassign") + if _, err := unassign(ctx, nil, "control", "mesh-controller"); err == nil || !strings.Contains(err.Error(), "terminal") { + t.Fatalf("unassigning the controller through a verb was not refused: %v", err) + } + // Another module through a verb, and the controller's at the terminal, are not refused for it. if err := refusedMovingTheController([]string{"zsh"}); err != nil { t.Fatalf("another module was refused: %v", err) } @@ -151,8 +170,8 @@ func TestAnOrdinarySettingsLineTakesTheSettingsVerbsPath(t *testing.T) { } } // Anything else still meets the generic command verb's refusals. - if _, err := ordinaryLine([]string{"retire", "delete", "x"}); err != nil && !strings.Contains(err.Error(), "why") { - t.Errorf("a repair was refused for another reason: %v", err) + if _, err := ordinaryLine([]string{"retire", "delete", "x"}); err == nil { + t.Error("a repair composed as an ordinary line") } } @@ -177,3 +196,71 @@ func TestEveryMeshCLILineIsSaidInTheJournal(t *testing.T) { t.Fatalf("the journal carries the line's values: %q", said) } } + +// **An ordinary line runs only what the generic command verb would** (review of ADR 0272, ADR 0266): its reading +// forms, and never a command that is the terminal's alone. Each of these, from another node's operator, is refused +// and runs nothing. +func TestAnOrdinaryLineRunsNothingTheCommandVerbWouldRefuse(t *testing.T) { + t.Setenv(echoEnvironment, "1") + for _, line := range [][]string{ + {"node", "account", "control", "x"}, + {"node", "agent-account", "control", "x", "--clear"}, + {"token", "issue", "laptop"}, + {"secret", "export", "x"}, + {"operator", "key", "set", "x"}, + {"assign", "laptop", "zsh"}, + } { + if _, err := ordinaryLine(line); err == nil { + t.Errorf("%q composed as an ordinary line", line) + } + a := runForMeshCLI(context.Background(), "laptop", asked("operator", 1000, line...), cliVerdict{why: "not the terminal"}) + if a.Refused == "" || len(a.Stdout) != 0 { + t.Errorf("%q ran as an ordinary line: %+v", line, a) + } + } + if argv, err := ordinaryLine([]string{"status"}); err != nil || argv[0] != "status" { + t.Fatalf("a read was refused: %v", err) + } +} + +// **`calls` never shows a mesh-cli line's answer** (review of ADR 0272): the verb's own answer is read for a line +// served over a bus, and neither the answer's text nor the base64 JSON writes its bytes in is there. +func TestTheCallsVerbNeverShowsAMeshCLILinesAnswer(t *testing.T) { + conn, err := nats.Connect(testbus.URL(t)) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + stop, err := link.OverNATS{Conn: conn}.ServeCLI(func(context.Context, string, link.CLIAsked) link.CLIAnswer { + return link.CLIAnswer{Stdout: []byte("s3cret-join")} + }, nil) + if err != nil { + t.Fatal(err) + } + defer stop() + body, _ := json.Marshal(link.CLIAsked{Line: []string{"token", "issue", "x"}, Account: "operator"}) + msg, err := conn.Request(link.CLISubject("control"), body, 5*time.Second) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(msg.Data), base64.StdEncoding.EncodeToString([]byte("s3cret-join"))) { + t.Fatalf("the asker was not given its answer: %s", msg.Data) + } + recent, _ := link.Calls.Recent() + var id string + for _, c := range recent { + if c.Seat == link.CLISeat { + id = c.ID + break + } + } + shown, err := callsAnswer(link.Calls, id) + if err != nil { + t.Fatal(err) + } + said, _ := json.Marshal(shown) + if strings.Contains(string(said), "s3cret-join") || + strings.Contains(string(said), base64.StdEncoding.EncodeToString([]byte("s3cret-join"))) { + t.Fatalf("calls showed a mesh-cli line's answer: %s", said) + } +} diff --git a/cmd/mesh-controller/module_health.go b/cmd/mesh-controller/module_health.go index 5a6759ce..b14689b3 100644 --- a/cmd/mesh-controller/module_health.go +++ b/cmd/mesh-controller/module_health.go @@ -73,7 +73,7 @@ func stateHealth(ctx context.Context, inv *inventory.Inventory, k *conditions.Ke for _, r := range h.Resources { kept := inventory.ResourceHealth{Module: r.Module, Resource: r.Resource, Kind: r.Kind, Target: r.Target, State: r.State, Reason: r.Reason, Since: r.Since, Streak: r.Streak, Restarts: r.Restarts, - Check: r.Check, Needs: r.Needs, Account: r.Account} + Check: r.Check, Needs: r.Needs, Account: r.Account, Root: r.Root} resources = append(resources, kept) if r.State == link.StateUnhealthy && r.Module != "" { unhealthy[r.Module] = append(unhealthy[r.Module], kept) diff --git a/cmd/mesh-controller/nodes.go b/cmd/mesh-controller/nodes.go index 579c01de..500009a8 100644 --- a/cmd/mesh-controller/nodes.go +++ b/cmd/mesh-controller/nodes.go @@ -100,6 +100,12 @@ func nodeCommand(ctx context.Context, args []string) error { "containers reaching outward. The machine reports which of its links face outside; see " + "`node show `") + case "agent-account": + // The account agents run as on this machine, when it is not the operator's (novox/hq ADR 0266). Here, + // at the controller's terminal, and nowhere else: no verb and no setting names it, so no agent can + // name itself another account. + return nodeAgentAccount(ctx, inv, args[1:]) + case "account": // The operator's login on this machine (novox/hq to-be 29): what a home-scoped file is // owned by and which account `ssh ` uses. Reports with no argument; sets with one; @@ -107,7 +113,7 @@ func nodeCommand(ctx context.Context, args []string) error { return nodeAccount(ctx, inv, args[1:]) default: - return fmt.Errorf("node has no %q; it has add, list, show, public-domain and account", args[0]) + return fmt.Errorf("node has no %q; it has add, list, show, public-domain, account and agent-account", args[0]) } } @@ -178,6 +184,57 @@ func nodeAccount(ctx context.Context, inv *inventory.Inventory, positionals []st return nil } +const agentAccountUsage = "node agent-account — what it is now; " + + " [home] to name the account agents run as (home defaults to /home/); " + + " --clear to have them run as the operator account again" + +// nodeAgentAccount reports, names or clears the account agents run as on a node (novox/hq ADR 0266). Read- +// shaped with no account, like public-domain; clearing is asked for by name. +func nodeAgentAccount(ctx context.Context, inv *inventory.Inventory, args []string) error { + set := flag.NewFlagSet("node agent-account", flag.ContinueOnError) + clear := set.Bool("clear", false, "agents run as the operator account again") + positionals, err := parseAround(set, args) + if err != nil { + return err + } + if len(positionals) == 0 || len(positionals) > 3 { + return errors.New(agentAccountUsage) + } + node := positionals[0] + switch { + case *clear && len(positionals) > 1: + return fmt.Errorf("name an agent account for %s or --clear, not both", node) + case *clear: + if err := inv.SetAgentAccount(ctx, node, "", ""); err != nil { + return err + } + fmt.Printf("agents on %s run as the operator account again\n", node) + fmt.Printf(" run `push %s` to send it; the agent account itself is kept (the mesh never deletes a login)\n", node) + return nil + case len(positionals) >= 2: + home := "" + if len(positionals) == 3 { + home = positionals[2] + } + if err := inv.SetAgentAccount(ctx, node, positionals[1], home); err != nil { + return err + } + fmt.Printf("agents on %s run as %s\n", node, positionals[1]) + fmt.Printf(" run `push %s` to send it; the self-check says once its node-engine has judged it "+ + "unable to become root\n", node) + return nil + default: + n, err := inv.NodeByName(ctx, node) + if err != nil { + return err + } + for _, line := range agentAccountLines(ctx, inv, n) { + fmt.Println(strings.TrimPrefix(line, " ")) + } + return nil + } +} + const publicDomainUsage = "node public-domain — what it is now; " + " to set it; --clear to take it away" @@ -592,6 +649,10 @@ func showNode(ctx context.Context, inv *inventory.Inventory, name string) error if err := showMode(ctx, inv, node); err != nil { return err } + // Whom agents run as here, and whether that account can become root without a person (ADR 0266). + for _, line := range agentAccountLines(ctx, inv, node) { + fmt.Println(line) + } // The domain its routed names are composed under, when it has one (novox/hq ADR 0066). Shown // only when set: a machine that serves nothing to the outside has no domain, and saying so of diff --git a/cmd/mesh-controller/plain_words.go b/cmd/mesh-controller/plain_words.go index 3d6d4ed3..61cab51d 100644 --- a/cmd/mesh-controller/plain_words.go +++ b/cmd/mesh-controller/plain_words.go @@ -110,6 +110,15 @@ var plainWordings = map[string]func(conditions.Observation) words{ "reaches it. It keeps running what it has.", m), Resolved: m + " can get new instructions again"} }), + kindAgentCanBecomeRoot: worded(func(o conditions.Observation) words { + m := machineOr(o, "a machine") + return words{Headline: "Sessions on " + m + " could become root", + Needs: "take the sessions' own account out of every group and rule that grants root; the details say which.", + Explanation: fmt.Sprintf("Assistant sessions on %s run under an account of their own, so that none "+ + "can take over the machine without you. The machine cannot show that this holds now, so an answer "+ + "from your phone authorises nothing there until it does.", m), + Resolved: "Sessions on " + m + " cannot become root again"} + }), "own-address-banned": worded(func(o conditions.Observation) words { m := machineOr(o, "a machine") return words{Headline: m + " has banned the mesh", diff --git a/cmd/mesh-controller/plan.go b/cmd/mesh-controller/plan.go index 55586d33..faaa2dfe 100644 --- a/cmd/mesh-controller/plan.go +++ b/cmd/mesh-controller/plan.go @@ -136,7 +136,8 @@ func planFor(ctx context.Context, open *stores, nodeName string) (catalogue.Reso resolved, err := catalogue.Resolve(shelf, assigned, catalogue.Node{Name: nodeName, Site: site, Capabilities: capabilities, At: onNetwork[nodeName], PublicDomain: publicDomain, - Account: who.Account, AccountHome: who.AccountHome}, world) + Account: who.Account, AccountHome: who.AccountHome, + AgentAccount: who.AgentAccount, AgentAccountHome: who.AgentAccountHome}, world) if err != nil { // The node's own set does not compose. Marked, because this is the only failure here that // a mesh-wide gatherer may pass over — see notResolvable. @@ -885,8 +886,13 @@ func renderingFor(ctx context.Context, open *stores, node string, if err != nil { return catalogue.Rendering{}, inventory.Node{}, err } + judgesRoot, err := engineJudgesRoot(ctx, inv, node) + if err != nil { + return catalogue.Rendering{}, inventory.Node{}, err + } return catalogue.Rendering{ ReadsHealth: readsHealth, + JudgesRoot: judgesRoot, BusMembership: memberships[node], Settings: settings, Generators: gens, Grants: grants, Needed: needed, Foreseen: foreseen, Ports: ports, Certificate: certificate, Authority: authority, Mesh: private, Names: names, @@ -909,6 +915,16 @@ func engineReadsHealth(ctx context.Context, inv *inventory.Inventory, node strin return had && stated.Contract >= link.ReadinessContract, nil } +// engineJudgesRoot says whether a machine's node-engine judges a user's declared `root` (novox/hq ADR 0266), +// by its own newest statement, for the same reason as engineReadsHealth: an older engine parses strictly. +func engineJudgesRoot(ctx context.Context, inv *inventory.Inventory, node string) (bool, error) { + stated, had, err := inv.HealthOf(ctx, node) + if err != nil { + return false, err + } + return had && stated.Contract >= link.RootContract, nil +} + // zonesInTheMesh is every zone a module in the mesh declares, where the mesh placed it (novox/hq ADR // 0199): the zone settled from that node's settings, the node's private address, the port the // answering listen is published on there. diff --git a/cmd/mesh-controller/push.go b/cmd/mesh-controller/push.go index 8a1360c5..e1903da2 100644 --- a/cmd/mesh-controller/push.go +++ b/cmd/mesh-controller/push.go @@ -65,6 +65,8 @@ func connectLink(ctx context.Context, inv *inventory.Inventory, enroller link.En } func serve(ctx context.Context) (err error) { + // Nothing this process does, or starts, is the operator at the terminal (novox/hq ADR 0266). + markServed() // The one process whose log is read over time, so the one that says each change to a node's // unmet seat dependencies once (novox/hq ADR 0207). logUnheldChanges = true diff --git a/cmd/mesh-controller/push_recreates_test.go b/cmd/mesh-controller/push_recreates_test.go index be91f5c1..1fc486a0 100644 --- a/cmd/mesh-controller/push_recreates_test.go +++ b/cmd/mesh-controller/push_recreates_test.go @@ -26,7 +26,7 @@ func TestAPushSaysWhatItRecreates(t *testing.T) { aContainerBuild(t, "postgres", "c1111111", "", start.Add(time.Second), map[string][2]string{"server": {image("e"), ""}}), } { - if _, _, err := takeIn(ctx, inv, b); err != nil { + if _, _, err := takeIn(ctx, inv, asTheOperator(t, inv, b)); err != nil { t.Fatal(err) } } diff --git a/cmd/mesh-controller/recorded_kept_test.go b/cmd/mesh-controller/recorded_kept_test.go index bbd8e192..07bce8bb 100644 --- a/cmd/mesh-controller/recorded_kept_test.go +++ b/cmd/mesh-controller/recorded_kept_test.go @@ -71,7 +71,7 @@ func TestARecordedBuildIsCarriedOnlyByAPersonsPush(t *testing.T) { aContainerBuild(t, "mailu", "c1111111", "", start.Add(time.Second), map[string][2]string{"smtp": {mailImage, ""}, "imap": {mailImage, ""}}), } { - if _, _, err := takeIn(ctx, inv, b); err != nil { + if _, _, err := takeIn(ctx, inv, asTheOperator(t, inv, b)); err != nil { t.Fatal(err) } } diff --git a/cmd/mesh-controller/replays_test.go b/cmd/mesh-controller/replays_test.go index b1e5f495..138f0180 100644 --- a/cmd/mesh-controller/replays_test.go +++ b/cmd/mesh-controller/replays_test.go @@ -539,8 +539,8 @@ func TestReplay301APersonsPushOfAHeldRecordedBuildIsNoRepair(t *testing.T) { inv := open.inventory start := time.Now().Add(-time.Hour) image := "registry.invalid:5000/resolver/server@sha256:" + strings.Repeat("e", 64) - if _, _, err := takeIn(ctx, inv, aContainerBuild(t, "resolver", "c1111111", catalogue.PolicyRecord, start, - map[string][2]string{"server": {image, ""}})); err != nil { + if _, _, err := takeIn(ctx, inv, asTheOperator(t, inv, aContainerBuild(t, "resolver", "c1111111", catalogue.PolicyRecord, start, + map[string][2]string{"server": {image, ""}}))); err != nil { t.Fatal(err) } for _, node := range []string{"anchor", "laptop"} { diff --git a/cmd/mesh-controller/same_source_test.go b/cmd/mesh-controller/same_source_test.go index 9f1949a1..be2dc8c2 100644 --- a/cmd/mesh-controller/same_source_test.go +++ b/cmd/mesh-controller/same_source_test.go @@ -54,7 +54,7 @@ func TestARebuildOfAnUnchangedSourceKeepsItsArtifacts(t *testing.T) { // Another module's merge rebuilt it: a new commit, a new image digest, the same source. anImageBuild(t, "app", "", "c2bbbbbb", strings.Repeat("b", 64), "src1:same", start.Add(time.Minute)), } { - if _, _, err := takeIn(ctx, inv, b); err != nil { + if _, _, err := takeIn(ctx, inv, asTheOperator(t, inv, b)); err != nil { t.Fatalf("build %d: %v", i, err) } } @@ -114,7 +114,7 @@ func TestABusRebuiltFromAnUnchangedSourceDemandsNoBusStep(t *testing.T) { b.Manifest = manifest return b } - if _, _, err := takeIn(ctx, inv, bus("", "n1111111", strings.Repeat("a", 64), "src1:bus", start)); err != nil { + if _, _, err := takeIn(ctx, inv, asTheOperator(t, inv, bus("", "n1111111", strings.Repeat("a", 64), "src1:bus", start))); err != nil { t.Fatal(err) } if _, err := inv.Assign(ctx, "anchor", "nats"); err != nil { diff --git a/cmd/mesh-controller/sayable_test.go b/cmd/mesh-controller/sayable_test.go index 9360e528..ab7951d5 100644 --- a/cmd/mesh-controller/sayable_test.go +++ b/cmd/mesh-controller/sayable_test.go @@ -29,7 +29,7 @@ func TestMain(m *testing.M) { // The process a mesh-cli test runs as a command line: it says the verb and the caller it was given, and // ends (meshcli_test.go). if os.Getenv(echoEnvironment) != "" { - fmt.Printf("verb=%q caller=%q\n", os.Getenv("MESH_VERB"), os.Getenv("MESH_CALLER")) + fmt.Printf("verb=%q caller=%q terminal=%v\n", os.Getenv("MESH_VERB"), os.Getenv("MESH_CALLER"), startedAtTheTerminal()) os.Exit(0) } conditions.Unsayable = func(o conditions.Observation, field string, r outward.Refusal) { diff --git a/cmd/mesh-controller/seatverbs.go b/cmd/mesh-controller/seatverbs.go index 72a84ee1..039b84e4 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. @@ -237,9 +240,14 @@ func refusedAsTheGenericCommand(argv []string) error { // settings verb is where what a verb may not set is refused, and one route is one set of words. // The command refuses places and accesses through any verb as well; this says so before it runs. if argv[0] == "settings" && slices.ContainsFunc(argv[1:], func(w string) bool { return w == "set" || w == "clear" }) { - return errors.New("settings are set and cleared through the settings verb, not the generic " + - "command; and places and accesses only at the controller's terminal (novox/hq issue 339). " + - "Nothing was done") + return &heldAtTheTerminal{msg: "settings are set and cleared through the settings verb, not the " + + "generic command; and places and accesses only at the controller's terminal (novox/hq issue 339). " + + "Nothing was done"} + } + // The generic verb only reads (novox/hq ADR 0266): what writes has a named verb that composes its own + // line, or is the operator's at the controller's terminal. + if err := commandReads(argv); err != nil { + return err } // The generic verb is no way round the hand-act log (novox/hq to-be 45 §7): a repair through // it says why, as it would through its own verb. @@ -925,10 +933,14 @@ func isWhyFlag(word string) bool { // stores' credentials, the bus, the broker, everything a command run from a shell beside it would have, because it // is that — with who asked, and the verb it came through. An empty verb is the controller's terminal (novox/hq ADR // 0272 §4): no `MESH_VERB` at all, whatever this process was started with. +// +// The terminal's line is also not the serving controller's (servedVar), and carries cliTerminalVar, so what reads +// whether it was started at the terminal (startedAtTheTerminal) reads yes; every other line is stripped of that mark. func commandEnvironment(caller, verb string) []string { - env := make([]string, 0, len(os.Environ())+2) + env := make([]string, 0, len(os.Environ())+3) for _, kv := range os.Environ() { - if strings.HasPrefix(kv, verbVar+"=") || strings.HasPrefix(kv, link.CallerVar+"=") { + if strings.HasPrefix(kv, verbVar+"=") || strings.HasPrefix(kv, link.CallerVar+"=") || + strings.HasPrefix(kv, cliTerminalVar+"=") || (verb == "" && strings.HasPrefix(kv, servedVar+"=")) { continue } env = append(env, kv) @@ -936,6 +948,8 @@ func commandEnvironment(caller, verb string) []string { env = append(env, link.CallerVar+"="+caller) if verb != "" { env = append(env, verbVar+"="+verb) + } else { + env = append(env, cliTerminalVar+"=1") } return env } @@ -1096,7 +1110,8 @@ func seatToolHandlers() (map[string]link.ToolHandler, []string, error) { } continue } - if _, err := argvFor(verb, sampleArguments(v)); err != nil { + var policy *heldAtTheTerminal + if _, err := argvFor(verb, sampleArguments(v)); err != nil && !errors.As(err, &policy) { // **A row ahead of this binary is not a reason to go silent.** // // The row is the store's and a control plane follows it (novox/hq ADR 0154), so a verb @@ -1357,3 +1372,131 @@ 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} + +// flagsOnly says a command line's rest names no subcommand: empty, or beginning with a flag. For a command +// with no subcommands every word is a flag, its value or a name it reads. +func flagsOnly(rest []string) bool { return len(rest) == 0 || strings.HasPrefix(rest[0], "-") } + +// subIn says the rest begins with one of these subcommands. +func subIn(rest []string, subs ...string) bool { + return len(rest) > 0 && slices.Contains(subs, rest[0]) +} + +// commandReadForms are the command lines the generic `command` verb may run (novox/hq ADR 0266): **an allow +// list of the ones that only read**, judged command by command. Anything else — every command that writes a +// record, sends, builds, issues an account or a token, sets a key, accepts, rotates, recovers or exports a +// secret — is refused, and a command added later is refused until it is judged a read. Writing has its named +// verbs, which compose their own lines and are judged by terminalOnly; the rest is the operator's at the +// controller's terminal. +var commandReadForms = map[string]func(rest []string) bool{ + "status": flagsOnly, "version": flagsOnly, "help": flagsOnly, "seats": flagsOnly, "healers": flagsOnly, + "hand-acts": flagsOnly, "durations": flagsOnly, "collection": flagsOnly, "images": flagsOnly, + "artifacts": flagsOnly, "data": flagsOnly, "builds": flagsOnly, "queue": flagsOnly, + // `plan ` previews a node's declaration; it sends nothing. + "plan": func([]string) bool { return true }, + // `plans` lists and `plans ` shows one; `plans stop|close|go` acts. + // Judged on every word, not the first: a flag before the subcommand (`plans --json go `) still acts. + "plans": func(r []string) bool { + return !slices.ContainsFunc(r, func(w string) bool { return slices.Contains(plansActs, w) }) + }, + // `doctor` answers the last run, `probes` and `signals` describe; `doctor run` runs. + "doctor": func(r []string) bool { return flagsOnly(r) || subIn(r, "probes", "signals") }, + "conditions": func(r []string) bool { return flagsOnly(r) || subIn(r, "list", "show", "history") }, + "node": func(r []string) bool { return subIn(r, "list", "show") }, + "module": func(r []string) bool { return subIn(r, "list") }, + "settings": func(r []string) bool { return subIn(r, "show", "preferences") }, + "retire": func(r []string) bool { return subIn(r, "list") }, + "cleanup": func(r []string) bool { return subIn(r, "list") }, + "delivery": func(r []string) bool { return subIn(r, "plan", "walks") }, + // `bus` alone says the bus's step; `bus upgrade` takes one. + "bus": func(r []string) bool { return len(r) == 0 }, + // `mirrors` lists; --record and --confirm keep a mirror. + "mirrors": func(r []string) bool { + return flagsOnly(r) && !slices.ContainsFunc(r, func(w string) bool { + return w == "--record" || w == "-record" || strings.HasPrefix(w, "--record=") || strings.HasPrefix(w, "-record=") || + w == "--confirm" || w == "-confirm" || strings.HasPrefix(w, "--confirm=") + }) + }, +} + +// plansActs are the `plans` subcommands that act on a walk; no other word of a plans line is one of them. +var plansActs = []string{"go", "stop", "close", "retry"} + +// heldAtTheTerminal is a refusal of policy (novox/hq ADR 0266): the verb is known and served, and this line is +// the operator's at the controller's terminal. Never read as a verb this binary is behind on. +type heldAtTheTerminal struct{ msg string } + +func (e *heldAtTheTerminal) Error() string { return e.msg } + +func terminalRefusal(format string, args ...any) error { + return &heldAtTheTerminal{fmt.Sprintf(format, args...)} +} + +// commandReads refuses a line the generic verb may not run, saying what it may. +func commandReads(argv []string) error { + if read, ok := commandReadForms[argv[0]]; ok && read(argv[1:]) { + return nil + } + return terminalRefusal("%q is not a reading command, and the generic command verb only reads (novox/hq ADR 0266): "+ + "whoever may call a verb includes agents, and a line that writes, issues, sets a key or reveals a secret "+ + "would be theirs to run. Use the named verb for it, or run it at the controller's terminal. The verb may "+ + "run: %s. Nothing was done", strings.Join(argv, " "), commandReadNames()) +} + +func commandReadNames() string { + names := make([]string, 0, len(commandReadForms)) + for n := range commandReadForms { + names = append(names, n) + } + sort.Strings(names) + return strings.Join(names, ", ") + " (each in its reading forms)" +} + +// terminalOnlyCommands are the commands no verb runs, whatever composed them (novox/hq ADR 0266): they set +// the operator's key, issue a credential or a token that is answered to the caller, or accept, recover or +// export a secret. Their answers or effects hand whoever calls them what the runtime's account holds. +var terminalOnlyCommands = map[string]string{ + "operator": "the operator's key and credential", + "identity": "the mesh's identity keys", + "token": "a token a machine joins with, answered to the caller", + "broker": "the bus's accounts", + "api": "the controller's API keys", + "licence": "the licences' secrets", +} + +// 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 { + return nil + } + if what, kept := terminalOnlyCommands[argv[0]]; kept { + return terminalRefusal("%s is run at the controller's terminal only, never through a verb: it holds %s, and "+ + "whoever may call a verb includes agents (novox/hq ADR 0266). Nothing was done", argv[0], what) + } + // Of a secret's commands only rotation, which seals the new value to the machine that uses it. + if argv[0] == "secret" && (len(argv) < 2 || argv[1] != "rotate") { + return terminalRefusal("secret %s is run at the controller's terminal only, never through a verb: accepting, "+ + "recovering or exporting a secret hands it to whoever asks, and that includes agents (novox/hq ADR "+ + "0266). Nothing was done", strings.Join(argv[1:], " ")) + } + if argv[0] != "node" { + return nil + } + if len(argv) > 1 && nodeReads[argv[1]] { + return nil + } + sub := "node" + if len(argv) > 1 { + sub += " " + argv[1] + } + return terminalRefusal("%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_schema_test.go b/cmd/mesh-controller/seatverbs_schema_test.go index 08ca631c..176b3455 100644 --- a/cmd/mesh-controller/seatverbs_schema_test.go +++ b/cmd/mesh-controller/seatverbs_schema_test.go @@ -271,7 +271,6 @@ var accountedFlags = map[string]map[string]string{ "builds": {"n": "=limit"}, "plans": {"n": "=limit", "what-if": "=repository"}, // The machine's tunnel key, named as the verb's other arguments are (novox/hq ADR 0169). - "token issue": {"overlay-key": "=overlay_key"}, "durations": { "json": "set by the verb: the answer is data", "all": "withheld: every measurement of a fortnight is more than a call should carry; `command` reaches it", diff --git a/cmd/mesh-controller/seatverbs_test.go b/cmd/mesh-controller/seatverbs_test.go index 127e0714..e1a5bf24 100644 --- a/cmd/mesh-controller/seatverbs_test.go +++ b/cmd/mesh-controller/seatverbs_test.go @@ -2,6 +2,7 @@ package main import ( "context" + "errors" "fmt" "github.com/novox/mesh-controller/internal/link" "strings" @@ -108,11 +109,14 @@ func TestRotateTakesAProvisionOrAnOwnSecret(t *testing.T) { } } -// `token` is `token issue` at a shell, with the machine's tunnel key (novox/hq ADR 0169). -func TestTokenIssuesForAMachineAndItsTunnelKey(t *testing.T) { - argv, err := argvFor("token", map[string]any{"new": "laptop", "overlay_key": "k", "for": "2h"}) - if err != nil || strings.Join(argv, " ") != "token issue --new laptop --overlay-key k --for 2h" { - t.Fatalf("token: %v %v", argv, err) +// `token` answers a joining token to its caller, and whoever may call a verb includes agents: it is the +// controller's terminal's alone (novox/hq ADR 0266), refused through the verb whatever it is given. +func TestTokenIsRefusedThroughAVerb(t *testing.T) { + for _, args := range []map[string]any{{"new": "laptop", "overlay_key": "k", "for": "2h"}, {"node": "ace"}} { + argv, err := argvFor("token", args) + if err == nil || !strings.Contains(err.Error(), "controller's terminal only") { + t.Fatalf("token %v: %v %v", args, argv, err) + } } } @@ -237,20 +241,20 @@ 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 show 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`}) - if err != nil || len(argv) != 4 || argv[2] != "the box" { + argv, err = argvFor("command", map[string]any{"command": `plan "the box" --json`}) + if err != nil || len(argv) != 3 || argv[1] != "the box" { t.Fatalf("double quotes group: %q %v", argv, err) } if _, err := argvFor("command", map[string]any{"command": " "}); err == nil { @@ -355,3 +359,59 @@ func TestTheControllerAnnouncesTheVerbsItServes(t *testing.T) { } } } + +// The generic verb only reads (novox/hq ADR 0266): an allow list of reading forms, and every line that +// writes, issues, sets a key or reveals a secret refused — the chain a review found ran through it: set the +// operator's key to one the caller holds, rotate secrets sealed to it, open them. +func TestTheCommandVerbOnlyReads(t *testing.T) { + for _, line := range []string{ + "operator key set --replace k", "operator issue", "secret accept a b", "secret rotate a b c", + "secret recover a", "secret export a", "token issue --new x", "identity show", "broker users", + "api key", "licence show", "push anchor --why w", "assign novox m", "settings set m {}", + "settings clear m", "module add f", "module check /etc", "module forget m", "module issue m --node a", + "seat rename a b", "plans close p --why w", "plans go p", "plans retry p", "plans stop p", + "plans --json go p", "plans -n 3 close p", "plans --what-if r retry p", "doctor run", "conditions silence c --why w", + "retire approve x", "cleanup delete x", "delivery check", "delivery go x", "bus upgrade", + "mirrors --record x", "mirrors --confirm", "hand-act record x --why y --cause z", "serve", "migrate", + "prepare", "declare x", "overlay x", "facts", "merge-gate", "check-here", "build x", "rebuild x", + "cancel x", "kill x", "clear x", "replay x", "pause", "resume", "pin a b", "unpin a", "take x", + "converge", "adopt x", "rollout x", "upgrade x", "collect", "board", "builder", "ask x", "frobnicate", + } { + argv, err := argvFor("command", map[string]any{"command": line}) + var policy *heldAtTheTerminal + if err == nil || !errors.As(err, &policy) { + t.Errorf("%q ran as %v (%v); the generic verb only reads", line, argv, err) + } + } + for _, line := range []string{ + "status --json", "version", "seats --json", "healers", "hand-acts --days 3", "durations", "collection", + "images", "artifacts --collected", "data --machine a", "builds --log b", "queue", "plan ace --diff", + "plans", "plans plan-1", "doctor", "doctor probes", "doctor signals", "conditions", "conditions list", + "conditions show c", "conditions history", "node list", "node show ace", "module list", + "settings show m", "settings preferences", "retire list", "cleanup list", "delivery plan --repository r", + "delivery walks", "bus", "mirrors --json", + } { + if _, err := argvFor("command", map[string]any{"command": line}); err != nil { + t.Errorf("%q, a read, was refused: %v", line, err) + } + } +} + +// What hands a caller a key, a credential or a secret is refused whichever verb composed it. +func TestNoVerbSetsTheOperatorsKeyOrRevealsASecret(t *testing.T) { + for _, argv := range [][]string{ + {"operator", "key", "set"}, {"operator", "issue"}, {"identity"}, {"token", "issue"}, {"broker", "users"}, + {"api"}, {"licence"}, {"secret", "export", "x"}, {"secret", "recover", "x"}, {"secret", "accept", "x"}, {"secret"}, + } { + if err := terminalOnly(argv); err == nil { + t.Errorf("%v passed", argv) + } + } + if err := terminalOnly([]string{"secret", "rotate", "n", "m", "s"}); err != nil { + t.Errorf("rotating seals to the machine that uses the secret, and stays a verb's: %v", err) + } + // A policy refusal is not a verb this binary is behind on: every verb is still served. + if _, behind, err := seatToolHandlers(); err != nil || len(behind) != 0 { + t.Fatalf("behind %v: %v", behind, err) + } +} diff --git a/go.mod b/go.mod index a84b281a..88491951 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-20261007162834-56e2ebec4bac +replace github.com/novox/mesh-host => git.novox.be/novox/mesh-host v0.0.0-20261009103656-1c61b72f354d diff --git a/go.sum b/go.sum index 04d5c9e7..1507aa09 100644 --- a/go.sum +++ b/go.sum @@ -4,6 +4,12 @@ git.novox.be/novox/mesh-host v0.0.0-20261007120832-bdd44154ccac h1:KvnKtJ2rWeIE/ git.novox.be/novox/mesh-host v0.0.0-20261007120832-bdd44154ccac/go.mod h1:VlilMCRZ5yyNXg7SNigNBLr0Gt32jrGw5KSNq5JAVYs= git.novox.be/novox/mesh-host v0.0.0-20261007162834-56e2ebec4bac h1:yLtFS0pDCCqIE9Zx8hgXEFG9fUWzf8L9WQoKV+Amk1E= git.novox.be/novox/mesh-host v0.0.0-20261007162834-56e2ebec4bac/go.mod h1:VlilMCRZ5yyNXg7SNigNBLr0Gt32jrGw5KSNq5JAVYs= +git.novox.be/novox/mesh-host v0.0.0-20261009081005-b28d7bbcbff4 h1:f4rBnKSemuN0Z9dTtRJMigIGfEs6ltFPOILJGHGab74= +git.novox.be/novox/mesh-host v0.0.0-20261009081005-b28d7bbcbff4/go.mod h1:72ZATZjxMLaJfWdvlSDJrygIoBzCmKIjCDMhEXxVzTo= +git.novox.be/novox/mesh-host v0.0.0-20261009101157-2673e7a2c95e h1:H7eVqDILL6e9cMbWSLHTbCqu9ZxDOmyeQhUmWl9QBV0= +git.novox.be/novox/mesh-host v0.0.0-20261009101157-2673e7a2c95e/go.mod h1:72ZATZjxMLaJfWdvlSDJrygIoBzCmKIjCDMhEXxVzTo= +git.novox.be/novox/mesh-host v0.0.0-20261009103656-1c61b72f354d h1:IrmJ+lz21n+eSqKrmXREtR/7raUCBJ+fZvs+BNhuXVI= +git.novox.be/novox/mesh-host v0.0.0-20261009103656-1c61b72f354d/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/agent_account_test.go b/internal/catalogue/agent_account_test.go new file mode 100644 index 00000000..90efb041 --- /dev/null +++ b/internal/catalogue/agent_account_test.go @@ -0,0 +1,119 @@ +package catalogue + +import ( + "testing" +) + +// The account agents run as (novox/hq ADR 0266): a module names it as a machine fact — the agent account +// where the node names one, the operator's otherwise — and asks the node-engine to judge it never to become +// root only where it is the agents' own. + +func TestTheAgentAccountFactFallsBackToTheOperatorAndIsNeverRootOnlyWhenItsOwn(t *testing.T) { + facts := machineFacts(Resolution{Node: "anchor", Account: "ops"}, nil, "") + if facts["agent-account"] != "ops" || facts["agent-home"] != "/home/ops" || facts["agent-root"] != "" { + t.Errorf("with no agent account named, agents run as the operator: %v", facts) + } + facts = machineFacts(Resolution{Node: "anchor", Account: "ops", AccountHome: "/srv/ops"}, nil, "") + if facts["agent-home"] != "/srv/ops" { + t.Errorf("the operator's stated home is the agent's home when they are one account: %v", facts) + } + facts = machineFacts(Resolution{Node: "anchor", Account: "ops", AgentAccount: "agent"}, nil, "") + if facts["agent-account"] != "agent" || facts["agent-home"] != "/home/agent" || facts["agent-root"] != RootNever { + t.Errorf("a named agent account is the agents', never root: %v", facts) + } + if facts["account"] != "ops" { + t.Errorf("the operator account is still the operator's: %v", facts) + } + facts = machineFacts(Resolution{Node: "anchor", AgentAccount: "agent", AgentAccountHome: "/var/lib/agent"}, nil, "") + if facts["agent-home"] != "/var/lib/agent" || facts["agent-root"] != RootNever { + t.Errorf("an agent account with a stated home on a machine with no operator: %v", facts) + } + if _, has := machineFacts(Resolution{Node: "anchor"}, nil, "")["agent-account"]; has { + t.Error("a machine with no account at all names an agent account") + } +} + +// The agent's module, in the shape the catalogue's declares it: the account, never root where it is its +// own; its directory under that home, owned by it. +const agentModule = `{"module": "agent", "version": "1", "resources": [ + {"id": "account", "type": "user", "name": "${machine:agent-account}", "home": "${machine:agent-home}", + "root": "${machine:agent-root}"}, + {"id": "home", "type": "directory", "path": "${machine:agent-home}/.agent", "mode": "0700", + "owner": "${machine:agent-account}"} +]}` + +func TestTheAgentAccountIsDeclaredNeverRootOnlyToAnEngineThatJudgesIt(t *testing.T) { + m, err := ParseManifest([]byte(agentModule)) + if err != nil { + t.Fatal(err) + } + compose := func(r Resolution, with Rendering) (user, home map[string]any) { + t.Helper() + r.Node, r.Modules = "anchor", []Manifest{m} + out, err := r.Declaration(with) + if err != nil { + t.Fatal(err) + } + return fileNamed(out, "agent.account"), fileNamed(out, "agent.home") + } + + user, home := compose(Resolution{Account: "ops", AgentAccount: "agent"}, Rendering{JudgesRoot: true}) + if user["name"] != "agent" || user[RootField] != RootNever || user["home"] != "/home/agent" { + t.Errorf("an engine that judges root is sent the agent account never to become root: %v", user) + } + if home["path"] != "/home/agent/.agent" || home["owner"] != "agent" { + t.Errorf("the agent's directory is under its own home, its own: %v", home) + } + + user, _ = compose(Resolution{Account: "ops", AgentAccount: "agent", AgentAccountHome: "/srv/agent"}, Rendering{JudgesRoot: true}) + if user["home"] != "/srv/agent" { + t.Errorf("an agent account named with a home of its own is made there: %v", user) + } + + user, _ = compose(Resolution{Account: "ops", AgentAccount: "agent"}, Rendering{}) + if _, sent := user[RootField]; sent || user["name"] != "agent" { + t.Errorf("an older engine, which parses strictly, is sent root: %v", user) + } + + user, home = compose(Resolution{Account: "ops"}, Rendering{JudgesRoot: true}) + if _, sent := user[RootField]; sent || user["name"] != "ops" { + t.Errorf("where agents run as the operator, root asserts nothing and is not sent: %v", user) + } + if home["path"] != "/home/ops/.agent" || home["owner"] != "ops" { + t.Errorf("with no agent account, the agent's directory is the operator's: %v", home) + } +} + +func TestTheRuntimeIsToldTheAgentAccount(t *testing.T) { + with := Rendering{ArtifactStore: "anchor.internal:5101", + Needed: map[string]map[string]string{RuntimeModule: {"broker": "sealed-credential"}}} + envOf := func(r Resolution) map[string]string { + t.Helper() + r.Node, r.Modules = "anchor", []Manifest{aToolsModule(t, "nftables", "tools/index.js"), theRuntime(t)} + out, err := r.Declaration(with) + if err != nil { + t.Fatal(err) + } + process := fileNamed(out, RuntimeModule+"."+RuntimeProcessID()) + if process == nil { + t.Fatal("no runtime process was composed") + } + return process["env"].(map[string]string) + } + env := envOf(Resolution{Account: "ops", AgentAccount: "agent"}) + if env[RuntimeAgentAccount] != "agent" || env[RuntimeAgentHome] != "/home/agent" || env[RuntimeOperatorAccount] != "ops" { + t.Errorf("the runtime is not told whom agents run as: %v", env) + } + env = envOf(Resolution{Account: "ops"}) + if env[RuntimeAgentAccount] != "ops" || env[RuntimeAgentHome] != "/home/ops" { + t.Errorf("with no agent account, agents run as the operator: %v", env) + } + env = envOf(Resolution{}) + if _, set := env[RuntimeAgentAccount]; set { + t.Errorf("a machine with no account names an agent account: %v", env) + } + if problems := bundleEnvProblems("x", Artifact{Name: "b", Kind: ArtifactBundle, Loads: []string{"x"}, + Env: map[string]string{RuntimeAgentAccount: "me"}}); len(problems) == 0 { + t.Error("a bundle may tell the runtime whom agents run as") + } +} diff --git a/internal/catalogue/build.go b/internal/catalogue/build.go index 7a97b3ff..388b769c 100644 --- a/internal/catalogue/build.go +++ b/internal/catalogue/build.go @@ -399,7 +399,7 @@ func versionOf(digest string) string { // telling the runtime what it is, which is the mesh's to say (novox/hq ADR 0192). var bundleEnvWords = map[string]bool{ RuntimeToolModules: true, RuntimeBrokerFile: true, RuntimeOperatorAccount: true, - RuntimeOperatorHome: true, RuntimeToolEnv: true, + RuntimeOperatorHome: true, RuntimeToolEnv: true, RuntimeAgentAccount: true, RuntimeAgentHome: true, } // bundleEnvProblems says what is wrong with what a bundle says it is given (novox/hq ADR 0192): diff --git a/internal/catalogue/declaration.go b/internal/catalogue/declaration.go index 9e8b5cc7..fadab8da 100644 --- a/internal/catalogue/declaration.go +++ b/internal/catalogue/declaration.go @@ -241,6 +241,31 @@ type Rendering struct { // refuses a field it does not know, whole — so to it the field is not sent, and what it runs is // judged by liveness alone. ReadsHealth bool + + // JudgesRoot says this machine's node-engine judges a user's declared `root` (novox/hq ADR 0266: its + // statement's contract is link.RootContract or later). To an older, strict engine the field is not + // sent, and the account it names is not judged — which the self-check says, as not judged. + JudgesRoot bool +} + +// RootField is a user resource's field saying the account must never become root without a person +// (novox/hq ADR 0266). +const RootField = "root" + +// rootInto composes a user's `root` for the node-engine: taken away when it asserts nothing (empty — a +// machine where agents run as the operator) or when the engine is older than the field and parses +// strictly; kept as "never" otherwise. +func rootInto(resource map[string]any, with Rendering) { + if resource["type"] != "user" { + return + } + value, has := resource[RootField] + if !has { + return + } + if s, _ := value.(string); s == "" || !with.JudgesRoot { + delete(resource, RootField) + } } // machinePort is where a module's port lives on this machine, or the port itself when the mesh has @@ -981,6 +1006,9 @@ func (r Resolution) compose(with Rendering, owner map[string]string, // How it is ready, in the node-engine's words: its endpoint as the port this machine // published it on — or not sent at all to an engine older than the field (ADR 0240). healthInto(copied, m, with) + // And a user's `root` (novox/hq ADR 0266): sent only when it asserts something, to an engine + // that judges it. + rootInto(copied, with) // The account's environment and every module's shell code, where this module holds the // seat that places them (novox/hq ADR 0203, ADR 0204). Gathered from every module on // the node, as the jails are, and **last of every placeholder pass**: shell code is a diff --git a/internal/catalogue/machine_into_files.go b/internal/catalogue/machine_into_files.go index aa79e711..f0e38de6 100644 --- a/internal/catalogue/machine_into_files.go +++ b/internal/catalogue/machine_into_files.go @@ -78,9 +78,47 @@ func machineFacts(r Resolution, names map[string]string, meshRange string) map[s out["account"] = r.Account out["account-home"] = accountHomeOf(r.Account, r.AccountHome) } + // The account agents run as here, and whether it must never become root (novox/hq ADR 0266). The agent + // account where the node names one; the operator account otherwise, so a module writing the agent's + // home names one fact on every machine. `agent-root` is "never" only for an account of the agents' own: + // the user resource naming it then asks the node-engine to judge it, and on a machine where agents run + // as the operator it is empty, asserting nothing — the operator's account may become root there. + if agent, home := r.agentAccount(); agent != "" { + out["agent-account"] = agent + out["agent-home"] = home + out["agent-root"] = "" + if r.AgentAccount != "" { + out["agent-root"] = RootNever + } + } return out } +// RootNever is what a user resource's `root` says of an account that must never become root without a +// person (novox/hq ADR 0266); the node-engine judges it. +const RootNever = "never" + +// agentAccount is the account agents run as on this machine and its home: the agent account when the node +// names one (novox/hq ADR 0266), else the operator account; empty when neither is known. +func (r Resolution) agentAccount() (string, string) { + if r.AgentAccount != "" { + return r.AgentAccount, agentHomeOf(r.AgentAccount, r.AgentAccountHome) + } + if r.Account != "" { + return r.Account, accountHomeOf(r.Account, r.AccountHome) + } + return "", "" +} + +// agentHomeOf is where the agent account's home is: what was stored, or /home/. Never /root: the +// agent account is never root. +func agentHomeOf(account, home string) string { + if home != "" { + return home + } + return "/home/" + account +} + // accountHomeOf is where an account's home is: what was stored, or the derived default — /root for // root, /home/ otherwise. The one place the default is written, so a fact and the store // cannot disagree about it. @@ -105,8 +143,12 @@ func machineInto(resource map[string]any, facts map[string]string, module string // (novox/hq to-be 29), the same reason its content names ${machine:address}. And the name a // `user` shape sets the login shell of, and the user a user-scoped unit or a process runs as: // the shell module makes the operator's account its holder's login shell, and the desktop's - // watchers run as that account (novox/hq ADR 0176, ADR 0177) — neither can name the person. - for _, field := range []string{"path", "owner", "content", "name", "user"} { + // watchers run as that account (novox/hq ADR 0176, ADR 0177) — neither can name the person. And a + // user's `root`: the agent's module declares the account agents run as with ${machine:agent-root}, + // "never" only where that account is the agents' own (novox/hq ADR 0266), and its `home`, so an account + // the operator named with a home of its own is made there (${machine:agent-home}); the node-engine reads a + // user's home only when it creates the account, so an existing one is never moved. + for _, field := range []string{"path", "owner", "content", "name", "user", "root", "home"} { s, ok := resource[field].(string) if !ok { continue diff --git a/internal/catalogue/resolve.go b/internal/catalogue/resolve.go index 2cfd163c..de3b5305 100644 --- a/internal/catalogue/resolve.go +++ b/internal/catalogue/resolve.go @@ -32,6 +32,11 @@ type Node struct { // to-be 29). What a home-scoped file is owned by and what ${machine:account} resolves to. Account string AccountHome string + // AgentAccount is the login agents run as here when it is not the operator's, AgentAccountHome its + // home when not derived (novox/hq ADR 0266). What ${machine:agent-account} resolves to; empty means + // agents run as the operator account. + AgentAccount string + AgentAccountHome string } // World is what the rest of the mesh already has. @@ -134,6 +139,10 @@ type Resolution struct { // here without a store lookup. Account string AccountHome string + // AgentAccount and AgentAccountHome are the account agents run as here when it is not the + // operator's, and its home (novox/hq ADR 0266); empty when agents run as the operator account. + AgentAccount string + AgentAccountHome string // Capabilities are the machine's, as its profile reported them, carried from the node so a // contribution placed only where the machine has something (`if-capability`, novox/hq ADR 0255) // is decided here without a store lookup. @@ -703,7 +712,8 @@ func Resolve(catalogue map[string]Manifest, assigned []string, node Node, world } resolution := Resolution{Node: node.Name, At: node.At, PublicDomain: node.PublicDomain, - Account: node.Account, AccountHome: node.AccountHome, Capabilities: node.Capabilities, + Account: node.Account, AccountHome: node.AccountHome, AgentAccount: node.AgentAccount, + AgentAccountHome: node.AgentAccountHome, Capabilities: node.Capabilities, Because: because, Needs: needs, Unhostable: unhostable, Kept: kept} for _, n := range providersFirst(order, catalogue) { resolution.Modules = append(resolution.Modules, catalogue[n]) diff --git a/internal/catalogue/runtime.go b/internal/catalogue/runtime.go index 65d326b8..71a5c87a 100644 --- a/internal/catalogue/runtime.go +++ b/internal/catalogue/runtime.go @@ -83,6 +83,11 @@ const ( RuntimeBrokerFile = "MESH_BROKER_FILE" RuntimeOperatorAccount = "MESH_OPERATOR_ACCOUNT" RuntimeOperatorHome = "MESH_OPERATOR_HOME" + // RuntimeAgentAccount and RuntimeAgentHome are the account agents run as on the machine and its home + // (novox/hq ADR 0266): the agent account where the node names one, the operator account otherwise. + // The agent's module writes the agent's home from them; absent where neither account is known. + RuntimeAgentAccount = "MESH_AGENT_ACCOUNT" + RuntimeAgentHome = "MESH_AGENT_HOME" // RuntimeToolEnv is every served module's composed environment, as JSON (novox/hq ADR 0192): // {"": {"": ""}}. The runtime takes it at start, removes it from its own // environment and hands each module's words to that module's bundles alone. In the unit, so a @@ -215,6 +220,10 @@ func (r Resolution) runtimeProcess(with Rendering) (map[string]any, error) { env[RuntimeOperatorHome] = accountHomeOf(r.Account, r.AccountHome) process["user"] = r.Account } + if agent, home := r.agentAccount(); agent != "" { + env[RuntimeAgentAccount] = agent + env[RuntimeAgentHome] = home + } // Routed through the artifact store as this network reaches it now, like everything the mesh // built; refused with the same words when there is no store to route through. if err := artifactsInto(process, RuntimeModule, with); err != nil { diff --git a/internal/catalogue/verbs.go b/internal/catalogue/verbs.go index 332cb4ea..6f2525fa 100644 --- a/internal/catalogue/verbs.go +++ b/internal/catalogue/verbs.go @@ -237,9 +237,9 @@ var ControllerVerbs = []Verb{ "node": "the machine that runs the module", "module": "the module's name", }, []string{"node", "module"})}, - {Name: "token", Description: "Issue a one-time token for a machine to join with. Give the public half of the " + - "tunnel key the machine made (`nox-mesh-host key`): the machine is given its address and made a peer of " + - "the hub, and joins through the tunnel. The token is shown once, in the answer.", + {Name: "token", Description: "Refused through a verb since novox/hq ADR 0266: the one-time token a machine " + + "joins with is answered to its caller, and whoever may call a verb includes agents. Issue it at the " + + "controller's terminal: `mesh-controller token issue --new --overlay-key `.", Input: schema(map[string]string{ "node": "a machine the mesh already has a record for", "new": "or the name of a machine to create the record for", @@ -267,10 +267,14 @@ var ControllerVerbs = []Verb{ "list": "\"preferences\": every module's preferences — key, default and why — and the value on each " + "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.", + {Name: "command", Description: "Run one reading command line of the controller's own, as you would type it at " + + "its shell — `node show ace`, `module list`, `plans`, `conditions show ` — and answer what it " + + "printed. The generic verb beside the named ones (novox/hq ADR 0154), and since ADR 0266 it only reads: " + + "status, version, help, seats, healers, hand-acts, durations, collection, images, artifacts, data, builds, " + + "queue, plan, plans (not stop/close/go), doctor (not run), conditions list/show/history, node list/show, " + + "module list, settings show/preferences, retire list, cleanup list, delivery plan/walks, bus, mirrors (not " + + "--record/--confirm). What writes has its named verb; what sets a key, issues a credential or a token, or " + + "accepts, recovers or exports a secret is the controller's terminal's alone.", 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/facts/facts.go b/internal/facts/facts.go index 368dc0c1..e010916d 100644 --- a/internal/facts/facts.go +++ b/internal/facts/facts.go @@ -131,6 +131,10 @@ type Machine struct { // home is when that is not the derived one. Account string `json:"account,omitempty"` AccountHome string `json:"account-home,omitempty"` + // AgentAccount is the account agents run as there when it is not the operator's (a pseudonym), and + // AgentAccountHome its home when not derived (novox/hq ADR 0266). + AgentAccount string `json:"agent-account,omitempty"` + AgentAccountHome string `json:"agent-account-home,omitempty"` // PublicDomain is the domain it answers for, its labels replaced. PublicDomain string `json:"public-domain,omitempty"` // Assigned is every module assigned there. diff --git a/internal/inventory/agent_account_test.go b/internal/inventory/agent_account_test.go new file mode 100644 index 00000000..44139bb8 --- /dev/null +++ b/internal/inventory/agent_account_test.go @@ -0,0 +1,93 @@ +package inventory + +import ( + "context" + "errors" + "strings" + "testing" +) + +// The agent account (novox/hq ADR 0266): recorded and read back with every node, its home derived when +// not stated, cleared by an empty name — and refused when it is root, the operator's own account, or no +// login at all, because each of those would say agents have an account of their own while they do not. +func TestAgentAccountIsRecordedAndRefusedWhereItWouldNotConfine(t *testing.T) { + inv := ForTest(t) + ctx := context.Background() + if _, err := inv.AddNode(ctx, "anchor"); err != nil { + t.Fatal(err) + } + if err := inv.SetAccount(ctx, "anchor", "operator", ""); err != nil { + t.Fatal(err) + } + + n, err := inv.NodeByName(ctx, "anchor") + if err != nil { + t.Fatal(err) + } + if n.AgentAccount != "" || n.AgentHome() != "" { + t.Fatalf("a node that names none has agent account %q, home %q", n.AgentAccount, n.AgentHome()) + } + + if err := inv.SetAgentAccount(ctx, "anchor", "agent", ""); err != nil { + t.Fatal(err) + } + n, _ = inv.NodeByName(ctx, "anchor") + if n.AgentAccount != "agent" || n.AgentHome() != "/home/agent" { + t.Fatalf("agent account %q, home %q; want agent, /home/agent", n.AgentAccount, n.AgentHome()) + } + all, err := inv.Nodes(ctx) + if err != nil || len(all) != 1 || all[0].AgentAccount != "agent" { + t.Fatalf("the listing does not carry the agent account: %+v %v", all, err) + } + + if err := inv.SetAgentAccount(ctx, "anchor", "agent", "/srv/agent"); err != nil { + t.Fatal(err) + } + if n, _ = inv.NodeByName(ctx, "anchor"); n.AgentHome() != "/srv/agent" { + t.Fatalf("the stated home is %q", n.AgentHome()) + } + + for _, c := range []struct{ account, home, says string }{ + {"root", "", "may not run as root"}, + {"operator", "", "operator account"}, + {"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) + if err == nil || !strings.Contains(err.Error(), c.says) { + t.Errorf("%q %q: %v; want a refusal saying %q", c.account, c.home, err, c.says) + } + } + if n, _ = inv.NodeByName(ctx, "anchor"); n.AgentAccount != "agent" { + 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) + } + if err := inv.SetAgentAccount(ctx, "nowhere", "agent", ""); !errors.Is(err, ErrNoSuchNode) { + t.Fatalf("an unknown node: %v", err) + } +} diff --git a/internal/inventory/catalogue.go b/internal/inventory/catalogue.go index 3112962c..71350a92 100644 --- a/internal/inventory/catalogue.go +++ b/internal/inventory/catalogue.go @@ -169,6 +169,25 @@ func (i *Inventory) RegisterModule(ctx context.Context, m catalogue.Manifest, fr return nil } +// SourceIdentity is the forge's id of the repository a module is registered from, 0 when none is recorded. +func (i *Inventory) SourceIdentity(ctx context.Context, module string) (int64, error) { + var id *int64 + err := i.store.Pool().QueryRow(ctx, `select source_repo_id from module where name = $1`, module).Scan(&id) + if errors.Is(err, pgx.ErrNoRows) { + return 0, fmt.Errorf("%w: %s", ErrNoSuchModule, module) + } + if err != nil || id == nil { + return 0, err + } + return *id, nil +} + +// SetSourceIdentity records the forge's id of the repository a module is registered from; 0 records none. +func (i *Inventory) SetSourceIdentity(ctx context.Context, module string, id int64) error { + _, err := i.store.Pool().Exec(ctx, `update module set source_repo_id = nullif($2::bigint, 0) where name = $1`, module, id) + return err +} + // hasModule is whether the catalogue holds a module of that name. func (i *Inventory) hasModule(ctx context.Context, name string) (bool, error) { var one int diff --git a/internal/inventory/gate.go b/internal/inventory/gate.go index 58bc4e80..7baa82e7 100644 --- a/internal/inventory/gate.go +++ b/internal/inventory/gate.go @@ -7,6 +7,7 @@ import ( "encoding/json" "errors" "fmt" + "strings" "time" "github.com/jackc/pgx/v5" @@ -169,6 +170,12 @@ func (i *Inventory) PreviousBuild(ctx context.Context, module, commit string, fa if b.ID == failed.ID || (commit != "" && b.Commit != commit) { continue } + // Only a build of the repository the failed build was made from — the module's, since its take-in + // registered it (novox/hq ADR 0266): a build of the module's name from another repository is recorded + // and was never registered, and putting it back would register it now. + if failed.Repository != "" && !sameRepositoryAs(b.Repository, failed.Repository) { + continue + } if !failed.AskedOrAt().IsZero() && !b.AskedOrAt().Before(failed.AskedOrAt()) { continue } @@ -190,6 +197,14 @@ func (i *Inventory) PreviousBuild(ctx context.Context, module, commit string, fa return Build{}, false, nil } +// sameRepositoryAs says two recorded repositories are one, however their case or `.git` is spelled. +func sameRepositoryAs(a, b string) bool { + trim := func(s string) string { + return strings.TrimSuffix(strings.TrimRight(strings.ToLower(strings.TrimSpace(s)), "/"), ".git") + } + return trim(a) == trim(b) +} + // RestoreModule puts a module's registered build back to an earlier one: its manifest, the commit it // was built from, and when it was asked — as now, so the build that failed its gate, asked before, can // never register over it again (issue 219's order). The source's head is left where the merge moved it: diff --git a/internal/inventory/health.go b/internal/inventory/health.go index 3272fd74..8f3797a7 100644 --- a/internal/inventory/health.go +++ b/internal/inventory/health.go @@ -31,6 +31,9 @@ type ResourceHealth struct { // Account is the account whose own service manager runs it, or the account a resource of kind account // is (novox/hq ADR 0254). Account string `json:"account,omitempty"` + // Root is "never" on an account verdict that judged whether the account can become root without a + // person (novox/hq ADR 0266). + Root string `json:"root,omitempty"` } // NodeHealth is a machine's newest statement, as kept. diff --git a/internal/inventory/migrations/0083-a-node-names-the-account-its-agents-run-as.sql b/internal/inventory/migrations/0083-a-node-names-the-account-its-agents-run-as.sql new file mode 100644 index 00000000..2f8fbee0 --- /dev/null +++ b/internal/inventory/migrations/0083-a-node-names-the-account-its-agents-run-as.sql @@ -0,0 +1,13 @@ +-- A node names the account its agents run as (novox/hq ADR 0266). +-- +-- On the control node every agent session ran as the operator's account, which may become root without +-- a password: any agent there could become root without a person. The decision is an account of the +-- agents' own, without sudo, beside the operator's, who keeps theirs. Stated by the operator at the +-- controller's terminal, like the operator account (migration 0036), and never by a verb or a setting, +-- so no agent can change which account it is. +-- +-- Empty rather than null, as the operator account is: empty is a real state, "agents run as the +-- operator's account here" — a workstation's today. The home is stored only when it is not +-- /home/; empty means derive it. +alter table node add column agent_account text not null default ''; +alter table node add column agent_account_home text not null default ''; diff --git a/internal/inventory/migrations/0084-a-build-asked-at-the-terminal-says-so.sql b/internal/inventory/migrations/0084-a-build-asked-at-the-terminal-says-so.sql new file mode 100644 index 00000000..650a8cf8 --- /dev/null +++ b/internal/inventory/migrations/0084-a-build-asked-at-the-terminal-says-so.sql @@ -0,0 +1,18 @@ +-- A build asked at the controller's terminal says so (novox/hq ADR 0266). +-- +-- A build's outcome registers its module, and a module is what the next push sends: a module named `sudo` +-- built from a repository an agent made would grant whoever wrote it root on every node it is assigned. +-- So an outcome may register a module only from the repository the catalogue already builds it from — or, +-- for a module new to the catalogue, from a repository the catalogue already builds another module from. +-- Anything else — a module moved to another repository, a new module from a new repository — is the +-- operator's, at the controller's terminal. This column is how the take-in tells: true only for a build +-- request kept by a `build` or `replay --register` run at the terminal, never through a verb (whoever may +-- call a verb includes agents). False for every request kept before this column existed. +alter table build_request add column at_terminal boolean not null default false; + +-- And which repository a module is registered from, by the forge's own id for it (novox/hq ADR 0266): a name +-- is not an identity. A repository deleted and made again under the same name is another repository, with +-- none of the protection the first had until someone sets it; its outcome must not register as the module's. +-- Recorded when a build of it is registered from the mesh's own forge; null until then, and for a source the +-- forge does not hold. +alter table module add column source_repo_id bigint; diff --git a/internal/inventory/migrations/0085-the-controller-keeps-when-a-root-search-began-pending.sql b/internal/inventory/migrations/0085-the-controller-keeps-when-a-root-search-began-pending.sql new file mode 100644 index 00000000..66c10a2a --- /dev/null +++ b/internal/inventory/migrations/0085-the-controller-keeps-when-a-root-search-began-pending.sql @@ -0,0 +1,9 @@ +-- The controller keeps when it first saw an agent account's root verdict waiting for the node-engine's search +-- for setuid programs (novox/hq ADR 0266), cleared by the next complete verdict. +-- +-- The self-check does not raise `agent-can-become-root` while that search is within its bound, so a restart is +-- not an urgent condition each time. The node-engine's own "since" starts again at every restart: an agent +-- that restarted the engine in a loop kept the condition quiet for ever. This time is the controller's, kept +-- across the engine's restarts and its own, so the quiet ends once the bound has passed since the search first +-- read as pending, however often the engine started again. +alter table node add column agent_root_pending_since timestamptz; diff --git a/internal/inventory/nodes.go b/internal/inventory/nodes.go index 4c13ad05..03f970b6 100644 --- a/internal/inventory/nodes.go +++ b/internal/inventory/nodes.go @@ -9,6 +9,7 @@ import ( "encoding/json" "errors" "fmt" + "regexp" "sort" "strings" "time" @@ -69,6 +70,14 @@ type Node struct { Account string AccountHome string + // AgentAccount is the login agents run as on this machine when it is not the operator's — `agent` + // on the control node (novox/hq ADR 0266): an account of their own, without sudo, so no agent there + // can become root without a person. Empty means agents run as the operator account. Stated at the + // controller's terminal only, never by a verb or a setting. AgentAccountHome is its home when not + // /home/; empty means derive it. + AgentAccount string + AgentAccountHome string + // HostVersion is the version of the host this machine reported running (novox/hq 04-ISSUES/087). // Empty when it has not said since the mesh began keeping it — which is not the same as running // no host, so nothing derives "behind" from an empty one. @@ -91,6 +100,17 @@ func (n Node) Home() string { } } +// AgentHome is the agent account's home, derived when not stored; empty when no agent account is named. +func (n Node) AgentHome() string { + if n.AgentAccount == "" { + return "" + } + if n.AgentAccountHome != "" { + return n.AgentAccountHome + } + return "/home/" + n.AgentAccount +} + // Silent is how long since this node was last heard from, and whether it ever was. func (n Node) Silent() (time.Duration, bool) { if n.LastSeen.IsZero() { @@ -147,14 +167,14 @@ func (i *Inventory) AddNodeAs(ctx context.Context, name string, adopted bool) (N // nodeColumns and scanNode are the one reading of a node row, so every way of finding a node // says whether it is adopted. const nodeColumns = `id, name, created, last_seen, adopted, adopted_since, account, account_home, - host_version` + agent_account, agent_account_home, host_version` func scanNode(row pgx.Row) (Node, error) { var n Node var seen, since *time.Time var host *string if err := row.Scan(&n.ID, &n.Name, &n.Created, &seen, &n.Adopted, &since, - &n.Account, &n.AccountHome, &host); err != nil { + &n.Account, &n.AccountHome, &n.AgentAccount, &n.AgentAccountHome, &host); err != nil { return Node{}, err } if host != nil { @@ -172,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 { @@ -184,6 +220,86 @@ func (i *Inventory) SetAccount(ctx context.Context, node, account, home string) return nil } +// loginName is what a login may be called: what useradd accepts by default, lower case, a letter or +// an underscore first. +var loginName = regexp.MustCompile(`^[a-z_][a-z0-9_-]{0,31}$`) + +// SetAgentAccount records the account agents run as on a node, and optionally its home (novox/hq ADR +// 0266). An empty account clears it: agents run as the operator account again. +// +// **Refused, rather than recorded and judged later:** root, which is the very thing the account exists +// to keep agents from; the node's operator account, which may become root without a password and is +// what agents ran as before — naming it here would say the agents have an account of their own while +// they do not; and a name no machine would accept as a login. +func (i *Inventory) SetAgentAccount(ctx context.Context, node, account, home string) error { + account, home = strings.TrimSpace(account), strings.TrimSpace(home) + if account == "" && home != "" { + return errors.New("a home without an agent account says nothing; name the account too") + } + if account != "" { + if err := AgentAccountRefusal(account, home); err != nil { + return err + } + n, err := i.NodeByName(ctx, node) + if err != nil { + return err + } + if n.Account != "" && n.Account == account { + return fmt.Errorf("%s is %s's operator account: agents would run as the operator, who may become "+ + "root; clear the agent account instead (node agent-account %s --clear)", account, node, node) + } + } + tag, err := i.store.Pool().Exec(ctx, + `update node set agent_account = $1, agent_account_home = $2 where name = $3`, account, home, node) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return fmt.Errorf("%w: %s", ErrNoSuchNode, node) + } + 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 { + if account == "root" { + return errors.New("agents may not run as root: the agent account exists to keep them from it " + + "(novox/hq ADR 0266)") + } + if !loginName.MatchString(account) { + 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) + } + return nil +} + // Nodes are every node record, oldest first. func (i *Inventory) Nodes(ctx context.Context) ([]Node, error) { rows, err := i.store.Pool().Query(ctx, @@ -1176,3 +1292,24 @@ func (i *Inventory) Sequence(ctx context.Context, id string) (int64, error) { } return *n, nil } + +// RootSearchPending keeps when the controller first saw this node's agent account waiting for the node-engine's +// setuid search (novox/hq ADR 0266) and answers it: the first time it is seen since the last complete verdict, +// at, kept; seen again, what was kept. Kept across the engine's restarts and the controller's own. +func (i *Inventory) RootSearchPending(ctx context.Context, node string, at time.Time) (time.Time, error) { + var since time.Time + err := i.store.Pool().QueryRow(ctx, + `update node set agent_root_pending_since = coalesce(agent_root_pending_since, $2) + where name = $1 returning agent_root_pending_since`, node, at).Scan(&since) + if errors.Is(err, pgx.ErrNoRows) { + return time.Time{}, fmt.Errorf("%w: %s", ErrNoSuchNode, node) + } + return since, err +} + +// RootSearchJudged forgets when a search began pending: a complete verdict came. +func (i *Inventory) RootSearchJudged(ctx context.Context, node string) error { + _, err := i.store.Pool().Exec(ctx, + `update node set agent_root_pending_since = null where name = $1 and agent_root_pending_since is not null`, node) + return err +} diff --git a/internal/inventory/pending.go b/internal/inventory/pending.go index e9d82f57..0e761e31 100644 --- a/internal/inventory/pending.go +++ b/internal/inventory/pending.go @@ -38,6 +38,9 @@ type BuildRequest struct { // unknown. Read as in flight until its outcome or its bound. OutcomeUnknown string At time.Time + // AtTerminal says the request was asked at the controller's terminal, never through a verb (novox/hq ADR + // 0266): what lets its outcome register a module from a repository the catalogue does not build it from. + AtTerminal bool } // Name is the module this request is expected to register, read from its directory: the last element of @@ -73,16 +76,31 @@ func (i *Inventory) RecordBuildRequest(ctx context.Context, a BuildRequest) erro notAsked = &a.NotAsked } if _, err := i.store.Pool().Exec(ctx, - `insert into build_request (id, repository, seat, source_path, ref, commit_hash, asked_for, not_asked, asked_at) - values ($1, $2, $3, $4, $5, $6, $7, $8, $9) + `insert into build_request (id, repository, seat, source_path, ref, commit_hash, asked_for, not_asked, asked_at, + at_terminal) + values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) on conflict (id) do nothing`, - a.ID, a.Repository, a.Seat, strings.Trim(a.Path, "/"), a.Ref, a.Commit, a.For, notAsked, at); err != nil { + a.ID, a.Repository, a.Seat, strings.Trim(a.Path, "/"), a.Ref, a.Commit, a.For, notAsked, at, + a.AtTerminal); err != nil { return err } _, err := i.store.Pool().Exec(ctx, `delete from build_request where asked_at < $1`, time.Now().Add(-KeptFor)) return err } +// BuildRequestByID is the build request kept under this id, and whether one was kept. +func (i *Inventory) BuildRequestByID(ctx context.Context, id string) (BuildRequest, bool, error) { + var a BuildRequest + err := i.store.Pool().QueryRow(ctx, + `select id, repository, seat, source_path, ref, commit_hash, asked_for, at_terminal, asked_at + from build_request where id = $1`, id).Scan(&a.ID, &a.Repository, &a.Seat, &a.Path, &a.Ref, &a.Commit, + &a.For, &a.AtTerminal, &a.At) + if errors.Is(err, pgx.ErrNoRows) { + return BuildRequest{}, false, nil + } + return a, err == nil, err +} + // MarkNotAsked says a kept build request was never handed over: the words are kept, unless its outcome was // heard first. func (i *Inventory) MarkNotAsked(ctx context.Context, id, why string) error { diff --git a/internal/link/meshcli_test.go b/internal/link/meshcli_test.go index d255be6d..2a9f9e0b 100644 --- a/internal/link/meshcli_test.go +++ b/internal/link/meshcli_test.go @@ -2,6 +2,7 @@ package link import ( "context" + "encoding/base64" "encoding/json" "sort" "strings" @@ -203,13 +204,13 @@ func TestAMeshCLIRecordKeepsNeitherItsLineNorItsAnswerOnTheBus(t *testing.T) { asked, _ := json.Marshal(CLIAsked{Line: []string{"settings", "set", "x", `{"password":"s3cret"}`}, Account: "op"}) l.serveCall(CLISeat, "laptop", asked, "_INBOX.node.laptop.abcdefghijklmnopqrstuv", func(context.Context, json.RawMessage) (any, error) { - return CLIAnswer{Stdout: []byte("token s3cret-join")}, nil + return CLIAnswer{Stdout: []byte("s3cret-join")}, nil }, a.respond, nil) _ = a.only() close(writes) for c := range writes { - if strings.Contains(string(c.Args), "s3cret") || strings.Contains(string(c.Answer), "s3cret") { + if carries(c.Args, "s3cret") || carries(c.Answer, "s3cret-join") { t.Fatalf("the bus was sent %s / %s", c.Args, c.Answer) } if !strings.Contains(string(c.Args), "settings") || !strings.Contains(string(c.Args), `"op"`) { @@ -234,7 +235,7 @@ func TestCallsNeverShowsAMeshCLILinesAnswer(t *testing.T) { if err != nil || !found { t.Fatalf("the line is not shown at all: %v %v", found, err) } - if strings.Contains(string(shown.Answer), "s3cret-join") { + if carries(shown.Answer, "s3cret-join") { t.Fatalf("calls shows a mesh-cli line's answer: %s", shown.Answer) } b := newAnswers(t) @@ -246,3 +247,19 @@ func TestCallsNeverShowsAMeshCLILinesAnswer(t *testing.T) { t.Fatalf("another call's answer is withheld: %s", shown.Answer) } } + +// carries says whether a record holds a secret as text or as the base64 JSON writes bytes in: a line's output is +// bytes, so a search for its text alone finds nothing whatever the record keeps (review of ADR 0272). +func carries(body []byte, secret string) bool { + return strings.Contains(string(body), secret) || + strings.Contains(string(body), base64.StdEncoding.EncodeToString([]byte(secret))) +} + +// The two tests above hold what they claim: each fails when the protection it names is taken away. +func TestTheWithholdingTestsHoldSomething(t *testing.T) { + // The answer as a mesh-cli line's record would carry it, were it kept: the search finds it. + body, _ := json.Marshal(map[string]any{"result": CLIAnswer{Stdout: []byte("s3cret-join")}}) + if !carries(body, "s3cret-join") { + t.Fatalf("a record carrying the answer is not found carrying it: %s", body) + } +} diff --git a/internal/link/protocol.go b/internal/link/protocol.go index ad959149..9c73db5f 100644 --- a/internal/link/protocol.go +++ b/internal/link/protocol.go @@ -8,6 +8,7 @@ package link import ( "encoding/base64" + "github.com/novox/mesh-host/rootsearch" "strconv" "strings" "time" @@ -420,6 +421,29 @@ const LivenessContract = 1 // because an older one parses strictly and would refuse the whole declaration for it. const ReadinessContract = 2 +// RootContract is the statement of an engine that also judges a user's declared `root` (novox/hq ADR 0266): +// whether an account declared never to become root without a person can — uid 0, a group that grants root, +// a sudo rule, a secret of the mesh it may read. Only to such an engine is the field sent: an older one +// parses strictly and would refuse the whole declaration for it. +const RootContract = 3 + +// ReasonRoot starts the reason of an account verdict that found a way to root (ADR 0266); the node-engine's +// own words (mesh-host internal/accounts ReasonRoot). +const ReasonRoot = "can become root without a person" + +// ReasonRootPending starts the reason of an account verdict the node-engine cannot give yet because its search +// for setuid programs, started when the engine started, has not finished: not judged yet, said as such, never a +// pass. The node-engine's own words (mesh-host rootsearch.ReasonPending), read from it rather than copied. +const ReasonRootPending = rootsearch.ReasonPending + +// RootSearchBound is the longest the node-engine lets one search for setuid programs run (mesh-host +// rootsearch.Bound): the one value both read. +const RootSearchBound = rootsearch.Bound + +// RootNever is the value of a user's `root`, and of a verdict's Root, that the account must never become +// root without a person (ADR 0266). +const RootNever = "never" + // Health is one statement of a machine's long-running resources (to-be 48 §4): in every report, as the // event HealthSubject between reports on each change, and again every minute while one is not healthy. // The node-engine's own (mesh-host internal/link Health); a test on each side holds the field names. @@ -550,6 +574,10 @@ type ResourceHealth struct { // manager, and the account itself for a resource of kind KindAccount (novox/hq ADR 0254). Empty from an // engine older than that, and for anything the machine's own manager or runtime runs. Account string `json:"account,omitempty"` + // Root is "never" on a verdict of kind KindAccount whose account is declared never to become root + // without a person (novox/hq ADR 0266): the engine judged that too, and a healthy verdict says it cannot. + // Empty from an engine older than RootContract, and on every other verdict. + Root string `json:"root,omitempty"` } // HealthSaid is the health event's body: the machine and its statement. The machine is read from the diff --git a/vendor/github.com/novox/mesh-host/internal/declaration/declaration.go b/vendor/github.com/novox/mesh-host/internal/declaration/declaration.go index 2472adf1..ea03cf57 100644 --- a/vendor/github.com/novox/mesh-host/internal/declaration/declaration.go +++ b/vendor/github.com/novox/mesh-host/internal/declaration/declaration.go @@ -198,6 +198,12 @@ type File struct { // by looking rather than by knowing which field won. Sealed string `json:"sealed,omitempty"` + // Trusted is the catalogue's word on whether the settings this file's content took are trusted (novox/hq + // issue 339). It is the controller's to act on, and a controller takes it out before it sends a declaration. + // Accepted here, and nothing is done with it, so that a controller that passes it on — an older one, or one + // rolled back to — never costs a node its whole declaration. Not part of the file's digest. + Trusted *bool `json:"trusted,omitempty"` + // Secrets are sealed values put into Content where it says `${secret:name}`. // // **The one place a secret and a configuration meet, and it happens on the machine.** A @@ -383,8 +389,25 @@ type User struct { // has it not. On the account rather than on the unit, because it is the account's: two units of // one account cannot disagree about it, and undeclaring one of them must not stop the other. Linger *bool `json:"linger,omitempty"` + + // Root says whether this account may become root without a person (novox/hq ADR 0266). "never" + // is the agents' own account: the login an agent session runs as on a machine where it must not + // reach root by itself. Empty asserts nothing, as Shell's does. + // + // **A statement the engine judges, never one it acts on.** The apply gives an account it creates + // no password, no sudo rule and no group beyond those declared, as it always has, and takes none + // away from one it finds: a sudo rule or a group granted by hand is a person's to remove, and a + // declaration that silently stripped them would be the mesh deciding what a person's machine + // grants. What "never" adds is the look: on every look the engine reads whether the account can + // become root by itself — by its uid, a group that grants root, any sudo rule, or a secret the mesh + // placed that it can read — and says it unhealthy while it can (internal/accounts), so the + // controller can tell a machine where it holds from one where it does not. + Root string `json:"root,omitempty"` } +// RootNever is the one value Root takes besides empty: the account never becomes root without a person. +const RootNever = "never" + // Network is a named network on this machine. // // **A name and nothing else.** Not a driver, a subnet or a gateway: each of those is something a @@ -478,6 +501,9 @@ func (u *User) validate(where string, _ bool) []string { if u.Home != "" && !strings.HasPrefix(u.Home, "/") { problems = append(problems, where+": a home directory is an absolute path") } + if u.Root != "" && u.Root != RootNever { + problems = append(problems, fmt.Sprintf("%s: root is %q or absent, and %q is neither", where, RootNever, u.Root)) + } return problems } diff --git a/vendor/github.com/novox/mesh-host/rootsearch/rootsearch.go b/vendor/github.com/novox/mesh-host/rootsearch/rootsearch.go new file mode 100644 index 00000000..a1c70c0b --- /dev/null +++ b/vendor/github.com/novox/mesh-host/rootsearch/rootsearch.go @@ -0,0 +1,19 @@ +// Package rootsearch holds what the node-engine's search for setuid programs and the controller that reads its +// verdicts must agree on (novox/hq ADR 0266): how long one search may run, and the words a verdict starts with +// while it has no answer. Public, so the controller imports these rather than keeps copies that could drift. +package rootsearch + +import "time" + +// Bound is how long one search for setuid programs may run. It governs the whole search, the walk and the +// package manager's answers together; the controller does not raise an agent account as not judged while the +// first search after a start is within it. +const Bound = 15 * time.Minute + +// The reasons of a root verdict the search could not answer yet. +const ( + // ReasonPending starts the reason while the first search since the engine started runs. + ReasonPending = "not judged yet (search running)" + // ReasonIncomplete starts the reason when no search has finished: one failed or ran out its bound. + ReasonIncomplete = "not judged (search incomplete)" +) diff --git a/vendor/modules.txt b/vendor/modules.txt index 6eb9699c..b8440549 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -75,9 +75,10 @@ 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-20261007162834-56e2ebec4bac +# github.com/novox/mesh-host v0.0.0 => git.novox.be/novox/mesh-host v0.0.0-20261009103656-1c61b72f354d ## explicit; go 1.26.0 github.com/novox/mesh-host/internal/declaration +github.com/novox/mesh-host/rootsearch github.com/novox/mesh-host/validate # go.uber.org/automaxprocs v1.6.0 ## explicit; go 1.20 @@ -133,4 +134,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-20261007162834-56e2ebec4bac +# github.com/novox/mesh-host => git.novox.be/novox/mesh-host v0.0.0-20261009103656-1c61b72f354d