Keep quiet for the setuid search the engine now runs to completion (hq issue 361)
The node-engine's search has no bound any more: it runs at idle priority and judges from its last complete, fresh result. The controller's quiet while an agent account waits is the engine's rootsearch.Quiet, not the old fifteen-minute bound, and the node-engine is pinned at its pull request.
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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).
|
||||
|
||||
+32
-8
@@ -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)"
|
||||
)
|
||||
|
||||
Vendored
+3
-3
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user