The generic command verb ran node account and node agent-account, so an agent could name itself the operator account and have the next send grant it root (hq ADR 0266 review). Refuse every node subcommand but list and show through any verb; refuse the operator account as the agent account in both directions and well-known service accounts as an agent account; and count a verdict heard more than 15 minutes ago as not judged, so stopping the node-engine cannot freeze a healthy one. Re-pin mesh-host to its review head.
176 lines
7.8 KiB
Go
176 lines
7.8 KiB
Go
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"))
|
|
}
|
|
|
|
// 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
|
|
}
|
|
confined, why := judgedConfined(n.AgentAccount, h, had, time.Now())
|
|
if confined {
|
|
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
|
|
}
|