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. // now is the judging clock, threaded so a caller judging several things at one instant judges them all at it. func agentConfined(ctx context.Context, inv *inventory.Inventory, node string, now time.Time) (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, 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, time.Now()) 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 }