diff --git a/cmd/mesh-controller/agent_account.go b/cmd/mesh-controller/agent_account.go index e23d9b74..90c1a7e3 100644 --- a/cmd/mesh-controller/agent_account.go +++ b/cmd/mesh-controller/agent_account.go @@ -119,9 +119,11 @@ func judgedConfined(agent string, h inventory.NodeHealth, had bool, now time.Tim } // 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 +// search before that is itself the urgent condition: the longest a search is expected to take (link.RootSearchQuiet, +// the engine's own value; novox/hq issue 361 — the search runs to completion, with no bound of its own), counted +// from when this controller first saw it waiting, never from the engine's start. Quiet raises nothing; it never +// makes the agent account confined, which only a healthy verdict from a complete, fresh search does. +const searchQuietFor = link.RootSearchQuiet // The kinds of an agent account's verdict, for the quiet a search earns. const ( @@ -207,10 +209,10 @@ func probeAgentAccounts(ctx context.Context, d *doctor) ([]conditions.Observatio if confined { continue } - // Not judged yet only because the first search since the node-engine started is still running: not the + // Not judged yet only because the node-engine's search runs and no complete, fresh one judges: 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. + // waits past searchQuietFor, or the statement goes stale. if quiet { continue } diff --git a/cmd/mesh-controller/agent_account_test.go b/cmd/mesh-controller/agent_account_test.go index f05790a1..ee859684 100644 --- a/cmd/mesh-controller/agent_account_test.go +++ b/cmd/mesh-controller/agent_account_test.go @@ -199,14 +199,20 @@ func TestTheNodeVerbOnlyShows(t *testing.T) { } } -// 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. +// Until a complete search for setuid programs judges — none since the node-engine's state was kept, or the last +// older than the engine lets one judge — the agent account is not judged, and the search runs to its end with no +// bound (novox/hq issue 361). DA does not raise that as urgent within searchQuietFor, 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) + if searchQuietFor != rootsearch.Quiet { + t.Fatalf("the quiet is %s and the node-engine's %s: they are one value", searchQuietFor, rootsearch.Quiet) + } + // A daily search that finishes within the quiet never leaves the account unjudged between two of them. + if rootsearch.FreshFor < rootsearch.Every+rootsearch.Quiet { + t.Fatalf("a complete search judges for %s, less than a day's search (%s) and the quiet (%s)", + rootsearch.FreshFor, rootsearch.Every, rootsearch.Quiet) } open := aMesh(t) ctx := t.Context() diff --git a/cmd/mesh-controller/probe_agent_root.go b/cmd/mesh-controller/probe_agent_root.go index de18aaf4..95111959 100644 --- a/cmd/mesh-controller/probe_agent_root.go +++ b/cmd/mesh-controller/probe_agent_root.go @@ -25,7 +25,9 @@ import ( // 1. the machine names an account agents run as (novox/hq ADR 0266), so no agent runs as the operator's // account, which may become root; // 2. its node-engine — running as root, which no agent controls — judged that account unable to become root -// without a person, in a statement heard within the last 15 minutes (agentConfined, judgedConfined); +// without a person, in a statement heard within the last 15 minutes (agentConfined, judgedConfined). The +// engine gives that verdict only from a complete search for setuid programs younger than mesh-host's +// rootsearch.FreshFor; before one, it says "not judged yet", which is no pass (novox/hq issue 361); // 3. the login shell's `execute` is not served there (novox/hq ADR 0268): its holder's setting withholds it // **and** the bus was asked and heard no `execute` answered there. `execute` runs commands as the machine's // runtime account, which the mesh's acting tools give passwordless sudo; that account is taken to become @@ -95,8 +97,8 @@ type rootFacts struct { // Execute is whether the login shell's execute is served there; ExecuteWhy why, in words. Execute bool ExecuteWhy string - // SearchPending is the agent account unjudged only because the node-engine's first setuid search runs, - // within its bound (ADR 0266's quiet window). + // SearchPending is the agent account unjudged only because the node-engine's setuid search runs, within + // the quiet the controller gives it (searchQuietFor; ADR 0266's quiet window, issue 361). SearchPending bool } @@ -106,7 +108,7 @@ type rootVerdict struct { Free bool `json:"free"` Why string `json:"why"` Judged time.Time `json:"judged"` - // Quiet is a machine not free only because its first setuid search still runs, within its bound: the + // Quiet is a machine not free only because its setuid search still runs, within searchQuietFor: the // self-check raises nothing for it then (ADR 0266's quiet window). It is never free for it. Quiet bool `json:"quiet,omitempty"` } @@ -136,7 +138,7 @@ func judgeRoot(f rootFacts, now time.Time) rootVerdict { } if len(not) > 0 { v.Why = strings.Join(not, "; ") - // The one failure is the agent account not judged yet, because its first search runs. + // The one failure is the agent account not judged yet, because its search runs. v.Quiet = len(not) == 1 && f.SearchPending && f.AgentNamed && !f.Confined && len(f.Unread) == 0 && !f.Execute return v } @@ -154,8 +156,8 @@ type rootReader struct { asked error // confined is agentConfined; settings the login shell holder's settings on a machine. Replaceable in a test. confined func(ctx context.Context, node string, now time.Time) (named, confined bool, why string, err error) - // quiet says the one thing keeping a machine's agent account unjudged is the node-engine's first setuid - // search, within its bound (ADR 0266, searchStillRunning). Read by the self-check alone, to raise nothing + // quiet says the one thing keeping a machine's agent account unjudged is the node-engine's setuid + // search, within searchQuietFor (ADR 0266, searchStillRunning). Read by the self-check alone, to raise nothing // then; nil reads no quiet. It never makes a machine root-free. quiet func(ctx context.Context, node string, now time.Time) bool settings func(ctx context.Context, node, module string) ([]catalogue.Layer, error) @@ -304,8 +306,8 @@ func probeAgentRoot(ctx context.Context, d *doctor) ([]conditions.Observation, e if r.read != nil { return nil, r.read } - // The self-check alone reads ADR 0266's quiet window: nothing raised while a machine's first setuid search - // runs, within its bound. The root-free verb never reads it, so the machine still answers not free. + // The self-check alone reads ADR 0266's quiet window: nothing raised while a machine's setuid search + // runs, within searchQuietFor. The root-free verb never reads it, so the machine still answers not free. r.quiet = func(ctx context.Context, node string, now time.Time) bool { n, err := inv.NodeByName(ctx, node) if err != nil || n.AgentAccount == "" { diff --git a/go.mod b/go.mod index 9d115b51..00345e7c 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/novox/mesh-controller go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.10-0.20261009115850-16984aafc689 + git.novox.be/novox/mesh-sdk/go v0.1.11-0.20261009143344-f047d0a4a970 github.com/jackc/pgx/v5 v5.10.0 github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 @@ -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-20261009141741-d8ff1540df56 +replace github.com/novox/mesh-host => git.novox.be/novox/mesh-host v0.0.0-20261009225305-241079686980 diff --git a/go.sum b/go.sum index 8fa5f4c3..bca4c940 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,7 @@ -git.novox.be/novox/mesh-host v0.0.0-20261009141741-d8ff1540df56 h1:Vut7OdwSAL0rFaavaFmv3+FIGS3ISM4jA+xTiS88nvg= -git.novox.be/novox/mesh-host v0.0.0-20261009141741-d8ff1540df56/go.mod h1:mBxSf6wULwn0bdpHkIHUnhTvNzqnULnjoRN28dUgSBU= -git.novox.be/novox/mesh-sdk/go v0.1.10-0.20261009115850-16984aafc689 h1:Ti2P9nwders7YQ/hq3X/dPo+CXMj5pdUcfA5P/c12CU= -git.novox.be/novox/mesh-sdk/go v0.1.10-0.20261009115850-16984aafc689/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= +git.novox.be/novox/mesh-host v0.0.0-20261009225305-241079686980 h1:gS23SZY/HiggBbnfoDOEadk6xnJb+5BqmkwW7VxwWZk= +git.novox.be/novox/mesh-host v0.0.0-20261009225305-241079686980/go.mod h1:K3/xEzVgmrNKLMV2vv4M80MwmPnQNXqvQ4C5Jj0fJT4= +git.novox.be/novox/mesh-sdk/go v0.1.11-0.20261009143344-f047d0a4a970 h1:9tFDQsgmI+4X7/BpZGXIr+HemPKE7YddYGqWV0lINAI= +git.novox.be/novox/mesh-sdk/go v0.1.11-0.20261009143344-f047d0a4a970/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= 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/link/protocol.go b/internal/link/protocol.go index 5872b1c9..7ce043de 100644 --- a/internal/link/protocol.go +++ b/internal/link/protocol.go @@ -431,14 +431,16 @@ const RootContract = 3 // 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. +// ReasonRootPending starts the reason of an account verdict the node-engine cannot give yet because no complete +// search for setuid programs judges — none has finished, or the last is older than the engine lets one judge +// (mesh-host rootsearch.FreshFor) — and one runs: 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 +// RootSearchQuiet is how long an agent account may wait for the node-engine's search for setuid programs before +// the waiting is itself the urgent condition (mesh-host rootsearch.Quiet, novox/hq issue 361): the search has no +// bound of its own, and this is the longest one is expected to take at idle priority. The one value both read. +const RootSearchQuiet = rootsearch.Quiet // 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). diff --git a/vendor/github.com/novox/mesh-host/rootsearch/rootsearch.go b/vendor/github.com/novox/mesh-host/rootsearch/rootsearch.go index a1c70c0b..7692529e 100644 --- a/vendor/github.com/novox/mesh-host/rootsearch/rootsearch.go +++ b/vendor/github.com/novox/mesh-host/rootsearch/rootsearch.go @@ -1,19 +1,43 @@ // 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. +// verdicts must agree on (novox/hq ADR 0266, issue 361): how often a full search runs, how long a complete one +// judges, how long the controller stays quiet while one runs, 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. +// +// **Why a complete search may judge for a day and more.** The search looks for a setuid- or setgid-root program +// no package owns, a way for the agent account to become root. Only root can make such a file: the agent account +// can neither set the setuid bit on a file root owns nor give a file it owns to root. So a search that walked +// everything stays sound until root acts — and root's acts through the mesh (an apply that changes an account, a +// group, a sudo rule, a package or runs an action) start a new search at once. What is left is root acting by +// hand, which is the operator; FreshFor bounds how long that goes unseen. +// +// **And why no bound on one search.** On a machine with millions of files the walk takes longer than any bound +// worth stating (the control-node's did not finish in fifteen minutes, issue 361), and a search ended early +// judges nothing, so a bound made such a machine never judged. The search runs to its end at idle priority, +// once at a time; an incomplete search is never "free". 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 +const ( + // Every is how often a full search runs at most, counted from the start of the last complete one; an + // apply that changes what root controls starts one sooner. + Every = 24 * time.Hour + // FreshFor is how long a complete search judges, counted from its start: after it, the verdict is "not + // judged yet" until a newer search completes. Every plus Quiet, so a daily search that finishes within + // the controller's quiet leaves no hour unjudged. + FreshFor = Every + Quiet + // Quiet is how long the controller raises nothing while an agent account waits for a search, counted + // from when it first saw it waiting: the longest a full search is expected to take at idle priority on + // the largest machine. Past it, waiting is itself the urgent condition. It never makes a machine free. + Quiet = 12 * time.Hour +) // 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 starts the reason while no complete search judges: none has finished since the engine's + // state was kept, or the last one is older than FreshFor, and one runs. ReasonPending = "not judged yet (search running)" - // ReasonIncomplete starts the reason when no search has finished: one failed or ran out its bound. + // ReasonIncomplete starts the reason when no complete search judges and the last one failed: it is tried + // again after a back-off. ReasonIncomplete = "not judged (search incomplete)" ) diff --git a/vendor/modules.txt b/vendor/modules.txt index 48e84f6e..66000609 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -1,4 +1,4 @@ -# git.novox.be/novox/mesh-sdk/go v0.1.10-0.20261009115850-16984aafc689 +# git.novox.be/novox/mesh-sdk/go v0.1.11-0.20261009143344-f047d0a4a970 ## explicit; go 1.22 git.novox.be/novox/mesh-sdk/go/asks # github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op @@ -78,7 +78,7 @@ github.com/nats-io/nkeys # github.com/nats-io/nuid v1.0.1 ## explicit github.com/nats-io/nuid -# github.com/novox/mesh-host v0.0.0 => git.novox.be/novox/mesh-host v0.0.0-20261009141741-d8ff1540df56 +# github.com/novox/mesh-host v0.0.0 => git.novox.be/novox/mesh-host v0.0.0-20261009225305-241079686980 ## explicit; go 1.26.0 github.com/novox/mesh-host/internal/declaration github.com/novox/mesh-host/rootsearch @@ -135,4 +135,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-20261009141741-d8ff1540df56 +# github.com/novox/mesh-host => git.novox.be/novox/mesh-host v0.0.0-20261009225305-241079686980