From 9cef8201179727495687363ea52f09b0d4c57665 Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 10 Oct 2026 01:56:56 +0200 Subject: [PATCH] Ask a node's engine for a fresh setuid search at the terminal (hq issue 361) After the operator removes by hand what the last search found, no apply says so and nothing searched again until the next day. node setuid-search signs the ask as a hand-over is, under its own context, on a subject only the node's engine hears and only the controller may publish. --- cmd/mesh-controller/handover_test.go | 35 ++++++++++++++ cmd/mesh-controller/nodes.go | 63 +++++++++++++++++++++++++- internal/broker/nats.go | 9 +++- internal/broker/nats_test.go | 4 +- internal/broker/testdata/composed.conf | 2 +- internal/broker/writers.go | 5 ++ internal/broker/writers_test.go | 16 +++++++ internal/link/handover.go | 59 +++++++++++++++++++----- internal/link/setuid_search_test.go | 59 ++++++++++++++++++++++++ 9 files changed, 237 insertions(+), 15 deletions(-) create mode 100644 internal/link/setuid_search_test.go diff --git a/cmd/mesh-controller/handover_test.go b/cmd/mesh-controller/handover_test.go index 740ea7e9..60b12285 100644 --- a/cmd/mesh-controller/handover_test.go +++ b/cmd/mesh-controller/handover_test.go @@ -132,3 +132,38 @@ func TestAHandOverAsksOnlyAKnownNodeAndFailsOnARefusal(t *testing.T) { t.Fatal("an ask that failed was a success") } } + +// The setuid search's line asks only a known node, one name and nothing else, and fails on a refusal +// (novox/hq issue 361). +func TestASetuidSearchAsksOnlyAKnownNodeAndFailsOnARefusal(t *testing.T) { + t.Setenv(link.CallerVar, "jo through mesh-cli on anchor") + asked := 0 + ask := func(answer link.HandOverAnswer) func(node, by string) (link.HandOverAnswer, error) { + return func(node, by string) (link.HandOverAnswer, error) { + asked++ + if node != "novox" || by != "jo through mesh-cli on anchor" { + t.Fatalf("asked %q %q", node, by) + } + return answer, nil + } + } + known := func(string) error { return nil } + var out bytes.Buffer + for _, args := range [][]string{nil, {"novox", "extra"}, {"-x"}} { + if err := setuidSearchAsked(args, known, ask(link.HandOverAnswer{Said: "x"}), &out); err == nil || asked != 0 { + t.Fatalf("%v was asked: %v", args, err) + } + } + if err := setuidSearchAsked([]string{"novox"}, func(string) error { return errors.New("no node called novox") }, + ask(link.HandOverAnswer{Said: "x"}), &out); err == nil || asked != 0 { + t.Fatalf("an unknown node: %v", err) + } + if err := setuidSearchAsked([]string{"novox"}, known, ask(link.HandOverAnswer{Refused: "no; no search was started"}), + &out); err == nil || !strings.Contains(err.Error(), "novox refused") || out.Len() != 0 { + t.Fatalf("a refusal: %v, printed %q", err, out.String()) + } + if err := setuidSearchAsked([]string{"novox"}, known, ask(link.HandOverAnswer{Said: "a new search starts"}), + &out); err != nil || !strings.HasPrefix(out.String(), "a new search starts\n") { + t.Fatalf("a start: %v, printed %q", err, out.String()) + } +} diff --git a/cmd/mesh-controller/nodes.go b/cmd/mesh-controller/nodes.go index ee878a9b..a89adcf2 100644 --- a/cmd/mesh-controller/nodes.go +++ b/cmd/mesh-controller/nodes.go @@ -120,8 +120,16 @@ func nodeCommand(ctx context.Context, args []string) error { // the module declares, and whoever may call a verb includes agents. return nodeHandOver(ctx, open, args[1:]) + case "setuid-search": + // A fresh search for setuid programs on a node (novox/hq issue 361), after the operator changed by hand + // what the last one found. Here, at the controller's terminal, and nowhere else: a search never makes a + // machine free wrongly, but asked again and again it would keep the machine unjudged and its disks busy, + // and whoever may call a verb includes agents. + return nodeSetuidSearch(ctx, open, args[1:]) + default: - return fmt.Errorf("node has no %q; it has add, list, show, public-domain, account, agent-account and hand-over", args[0]) + return fmt.Errorf("node has no %q; it has add, list, show, public-domain, account, agent-account, hand-over "+ + "and setuid-search", args[0]) } } @@ -210,6 +218,59 @@ func handOverAsked(args []string, known func(node string) error, return nil } +const setuidSearchUsage = "node setuid-search — throw away the node-engine's last search for setuid " + + "programs on and start a full one: after a setuid-root program it found was removed by hand. Until it " + + "completes, root-free says the node is not judged yet" + +// nodeSetuidSearch asks the node's engine for a fresh search, signed with the mesh's key as a hand-over is. +func nodeSetuidSearch(ctx context.Context, open *stores, args []string) error { + known := func(node string) error { + _, err := open.inventory.NodeByName(ctx, node) + return err + } + ask := func(node, by string) (link.HandOverAnswer, error) { + ident, err := open.Identity(ctx) + if err != nil { + return link.HandOverAnswer{}, fmt.Errorf("the mesh's signing key cannot be read, so nothing was asked of %s: %w", + node, err) + } + address, err := broker.BusAddress() + if err != nil { + return link.HandOverAnswer{}, err + } + js, err := broker.Dial(address) + if err != nil { + return link.HandOverAnswer{}, fmt.Errorf("cannot reach the bus, so nothing was asked of %s: %w", node, err) + } + defer js.Close() + return link.AskSetuidSearch(ctx, js.Conn(), ident, node, by, link.HandOverWithin) + } + return setuidSearchAsked(args, known, ask, os.Stdout) +} + +// setuidSearchAsked is the line with its two acts given: whether the mesh knows the node, and the ask. The +// engine's refusal is this command's failure. +func setuidSearchAsked(args []string, known func(node string) error, + ask func(node, by string) (link.HandOverAnswer, error), out io.Writer) error { + if len(args) != 1 || args[0] == "" || strings.HasPrefix(args[0], "-") { + return errors.New(setuidSearchUsage) + } + node := args[0] + if err := known(node); err != nil { + return fmt.Errorf("nothing was asked: %w", err) + } + answer, err := ask(node, handOverBy()) + if err != nil { + return err + } + if answer.Refused != "" { + return fmt.Errorf("%s refused: %s", node, answer.Refused) + } + fmt.Fprintln(out, answer.Said) + fmt.Fprintf(out, " the controller's root-free verb shows the search's progress until it completes\n") + return nil +} + // addNode creates a node record, adopted when the operator says so (novox/hq ADR 0100). func addNode(ctx context.Context, inv *inventory.Inventory, args []string) error { set := flag.NewFlagSet("node add", flag.ContinueOnError) diff --git a/internal/broker/nats.go b/internal/broker/nats.go index 156e9d3e..06c41c48 100644 --- a/internal/broker/nats.go +++ b/internal/broker/nats.go @@ -297,6 +297,11 @@ func AskReportSubject(node string) string { return "mesh.node." + node + ".ask.r // the mesh's key (link.SignedHandOver), and the engine verifies it before reading anything out of it. func AskHandOverSubject(node string) string { return "mesh.node." + node + ".ask.hand-over" } +// AskSetuidSearchSubject is where the controller's terminal asks one machine's node-engine to throw its last +// search for setuid programs away and start a full one (novox/hq issue 361): a request answered once, signed as +// a hand-over is (link.SetuidSearchContext), for the same reason. +func AskSetuidSearchSubject(node string) string { return "mesh.node." + node + ".ask.setuid-search" } + // inbox is a principal's own reply space. No user is ever granted a bare `_INBOX.>` (design 25 // §4): with one account, inbox privacy is the permission list or it is nothing, so each user's // inbox is derived from its own identity and its permissions name that prefix and no other. @@ -573,7 +578,9 @@ func PermissionsFor(p Principal) (Permissions, error) { AskReportSubject(p.Node), // And the controller's terminal asking it to hand a directory used as found to the mesh (novox/hq // issue 356), which it answers on the request's reply: the one request a node is asked. - AskHandOverSubject(p.Node)} + AskHandOverSubject(p.Node), + // And asking it for a fresh search for setuid programs (novox/hq issue 361), answered the same way. + AskSetuidSearchSubject(p.Node)} // The node-engine witnesses the core builds it places (novox/hq to-be 45 §8, ADR 0236; the // contract is lease/witness.go): it asks its own machine's node tools PING, and, where the // machine runs the controller, reads the lease's one key — read, never written. diff --git a/internal/broker/nats_test.go b/internal/broker/nats_test.go index 4a736f38..23d3f8d3 100644 --- a/internal/broker/nats_test.go +++ b/internal/broker/nats_test.go @@ -112,7 +112,9 @@ func TestOnlyWhatCanBeAskedMayAnswer(t *testing.T) { t.Fatal("a module cannot answer a tool call on its own namespace") } node, _ := PermissionsFor(Principal{Kind: KindNode, Node: "one", PasswordHash: "x"}) - if !node.AllowResponses || !slices.Contains(node.Subscribe, AskHandOverSubject("one")) { + if !node.AllowResponses || !slices.Contains(node.Subscribe, AskHandOverSubject("one")) || + !slices.Contains(node.Subscribe, AskSetuidSearchSubject("one")) || + slices.Contains(node.Subscribe, AskSetuidSearchSubject("two")) { t.Fatalf("a node cannot answer the hand-over it is asked: %v %v", node.AllowResponses, node.Subscribe) } person, _ := PermissionsFor(Principal{Kind: KindPerson, Node: "one", Module: "jo", PasswordHash: "x"}) diff --git a/internal/broker/testdata/composed.conf b/internal/broker/testdata/composed.conf index 3e13d4a3..8f6c6e55 100644 --- a/internal/broker/testdata/composed.conf +++ b/internal/broker/testdata/composed.conf @@ -34,7 +34,7 @@ accounts { } } { user: "node.one", password: "$2a$11$nnnnnnnnnnnnnnnnnnnnnn", permissions: { publish: { allow: ["$JS.ACK.NODES.one.>", "$JS.API.CONSUMER.INFO.NODES.one", "$SRV.PING.node-tools.one", "mesh.control.one.>"] } - subscribe: { allow: ["_DELIVER.one", "_DELIVER.one.>", "_INBOX.node.one.>", "mesh.node.one.ask.hand-over", "mesh.node.one.ask.report", "mesh.node.one.declare"] } + subscribe: { allow: ["_DELIVER.one", "_DELIVER.one.>", "_INBOX.node.one.>", "mesh.node.one.ask.hand-over", "mesh.node.one.ask.report", "mesh.node.one.ask.setuid-search", "mesh.node.one.declare"] } allow_responses: { max: 1, ttl: "1m" } } } { user: "one.nats", password: "$2a$11$bbbbbbbbbbbbbbbbbbbbbb", permissions: { diff --git a/internal/broker/writers.go b/internal/broker/writers.go index 59382a96..32065552 100644 --- a/internal/broker/writers.go +++ b/internal/broker/writers.go @@ -91,6 +91,11 @@ var WritersTable = []WriterRow{ {State: "a hand-over asked of a machine", Writer: "controller, at its terminal", KeptIn: "the machine, beside its state", Others: "the engine verifies the mesh's signature and records it, or refuses", Subjects: []string{"mesh.node.*.ask.hand-over"}, Writes: isController}, + // The operator asking a machine's engine for a fresh search for setuid programs, at the controller's + // terminal (novox/hq issue 361): signed as a hand-over is, under a signing context of its own. + {State: "a fresh setuid search asked of a machine", Writer: "controller, at its terminal", + KeptIn: "the machine, which throws its last search away", Others: "the engine verifies the mesh's signature and starts it, or refuses", + Subjects: []string{"mesh.node.*.ask.setuid-search"}, Writes: isController}, {State: "a machine's applied state and its report", Writer: "the node-engine's apply queue", KeptIn: "the machine; the report on the bus", Others: "the reconcile and a delivery enqueue, never apply", // And its health statement between reports (novox/hq ADR 0240): the same writer stating the same diff --git a/internal/broker/writers_test.go b/internal/broker/writers_test.go index 5f8c07f5..a5e0f069 100644 --- a/internal/broker/writers_test.go +++ b/internal/broker/writers_test.go @@ -16,6 +16,7 @@ import ( var designRows = []string{ "a machine's declaration", "a hand-over asked of a machine", + "a fresh setuid search asked of a machine", "a machine's applied state and its report", "the controller lease", "plans and their tiers", @@ -170,3 +171,18 @@ func TestAHandOverAskHasOnePublisher(t *testing.T) { t.Fatalf("the controller may not ask a hand-over: %v", err) } } + +// **A fresh setuid search asked of a machine has one publisher, the controller** (novox/hq issue 361), as a +// hand-over has. +func TestASetuidSearchAskHasOnePublisher(t *testing.T) { + for _, p := range []Principal{ + {Kind: KindNode, Node: "laptop"}, + {Kind: KindNodeTools, Node: "laptop", Module: RuntimeModule}, + {Kind: KindModule, Node: "laptop", Module: "notes"}, + } { + err := CheckWriters(p, []string{AskSetuidSearchSubject("laptop")}) + if err == nil || !strings.Contains(err.Error(), "a fresh setuid search asked of a machine") { + t.Errorf("%s may ask a setuid search: %v", p.Username(), err) + } + } +} diff --git a/internal/link/handover.go b/internal/link/handover.go index d090119c..18c2d675 100644 --- a/internal/link/handover.go +++ b/internal/link/handover.go @@ -74,9 +74,20 @@ func AskHandOver(ctx context.Context, conn *nats.Conn, signer Signer, node, path if err != nil { return HandOverAnswer{}, err } + return askSigned(ctx, conn, broker.AskHandOverSubject(node), body, node, timeout, askWords{ + what: "a hand-over", nothing: "nothing was handed over", issue: "issue 356", + unknown: "whether it was recorded is not known — the module's condition says whether the directory is " + + "still used as found", record: "a record"}) +} + +// askWords are what a signed ask's failures say of it. +type askWords struct{ what, nothing, issue, unknown, record string } + +// askSigned sends one signed ask on subject and reads the engine's answer. +func askSigned(ctx context.Context, conn *nats.Conn, subject string, body []byte, node string, timeout time.Duration, + w askWords) (HandOverAnswer, error) { asking, cancel := context.WithTimeout(ctx, timeout) defer cancel() - subject := broker.AskHandOverSubject(node) refused, stop := refusalsOf(conn, subject) defer stop() type replied struct { @@ -89,38 +100,64 @@ func AskHandOver(ctx context.Context, conn *nats.Conn, signer Signer, node, path done <- replied{msg, err} }() var reply *nats.Msg + var err error select { case r := <-done: reply, err = r.msg, r.err case why := <-refused: cancel() - return HandOverAnswer{}, fmt.Errorf("the bus refused the controller asking %s for a hand-over: %v", node, why) + return HandOverAnswer{}, fmt.Errorf("the bus refused the controller asking %s for %s: %v", node, w.what, why) } switch { case errors.Is(err, nats.ErrNoResponders): - return HandOverAnswer{}, fmt.Errorf("nothing on %s answers a hand-over: its node-engine is not running, is not "+ - "on the bus, or is older than this ask (novox/hq issue 356); nothing was handed over", node) + return HandOverAnswer{}, fmt.Errorf("nothing on %s answers %s: its node-engine is not running, is not "+ + "on the bus, or is older than this ask (novox/hq %s); %s", node, w.what, w.issue, w.nothing) case errors.Is(err, context.DeadlineExceeded), errors.Is(err, nats.ErrTimeout): - return HandOverAnswer{}, fmt.Errorf("%s did not answer the hand-over within %s; whether it was recorded is not "+ - "known — the module's condition says whether the directory is still used as found", node, timeout) + return HandOverAnswer{}, fmt.Errorf("%s did not answer %s within %s; %s", node, w.what, timeout, w.unknown) case err != nil: return HandOverAnswer{}, err } var answer HandOverAnswer if err := json.Unmarshal(reply.Data, &answer); err != nil { - return HandOverAnswer{}, fmt.Errorf("%s answered the hand-over with something unreadable: %w", node, err) + return HandOverAnswer{}, fmt.Errorf("%s answered %s with something unreadable: %w", node, w.what, err) } if answer.Said == "" && answer.Refused == "" { - return HandOverAnswer{}, fmt.Errorf("%s answered the hand-over with neither a record nor a refusal", node) + return HandOverAnswer{}, fmt.Errorf("%s answered %s with neither %s nor a refusal", node, w.what, w.record) } return answer, nil } +// SetuidSearchContext is prefixed to a setuid search ask's bytes before signing (novox/hq issue 361): never a +// hand-over's signature, nor a declaration's. The engine holds the same words. +const SetuidSearchContext = "novox-mesh setuid-search v1\n" + +// AskSetuidSearch asks one machine's node-engine to throw its last search for setuid programs away and start a +// full one (novox/hq issue 361): the ask a hand-over is, naming no path, signed under SetuidSearchContext. +func AskSetuidSearch(ctx context.Context, conn *nats.Conn, signer Signer, node, by string, + timeout time.Duration) (HandOverAnswer, error) { + if conn == nil { + return HandOverAnswer{}, errors.New("this controller is not on the bus") + } + body, err := signAsk(ctx, signer, SetuidSearchContext, HandOverAsk{Node: node, By: by}) + if err != nil { + return HandOverAnswer{}, err + } + return askSigned(ctx, conn, broker.AskSetuidSearchSubject(node), body, node, timeout, askWords{ + what: "a setuid search", nothing: "no search was started", issue: "issue 361", + unknown: "whether it started is not known — the controller's root-free verb says whether a search runs " + + "there", record: "a start"}) +} + // SignHandOver fills the ask's expiry and nonce and signs it with the mesh's key, over HandOverContext and the // ask's bytes exactly as they travel. func SignHandOver(ctx context.Context, signer Signer, ask HandOverAsk) ([]byte, error) { + return signAsk(ctx, signer, HandOverContext, ask) +} + +// signAsk fills an ask's expiry and nonce and signs it over prefix (its signing context) and its bytes. +func signAsk(ctx context.Context, signer Signer, prefix string, ask HandOverAsk) ([]byte, error) { if signer == nil { - return nil, errors.New("no signing key, so no hand-over can be asked") + return nil, errors.New("no signing key, so nothing can be asked of an engine") } nonce := make([]byte, 16) if _, err := rand.Read(nonce); err != nil { @@ -132,9 +169,9 @@ func SignHandOver(ctx context.Context, signer Signer, ask HandOverAsk) ([]byte, if err != nil { return nil, err } - signature, err := signer.Sign(ctx, append([]byte(HandOverContext), raw...)) + signature, err := signer.Sign(ctx, append([]byte(prefix), raw...)) if err != nil { - return nil, fmt.Errorf("cannot sign the hand-over: %w", err) + return nil, fmt.Errorf("cannot sign the ask: %w", err) } return json.Marshal(SignedHandOver{Ask: raw, Signature: signature}) } diff --git a/internal/link/setuid_search_test.go b/internal/link/setuid_search_test.go new file mode 100644 index 00000000..4a05aa57 --- /dev/null +++ b/internal/link/setuid_search_test.go @@ -0,0 +1,59 @@ +package link + +import ( + "context" + "crypto/ed25519" + "encoding/json" + "strings" + "testing" + "time" + + "github.com/nats-io/nats.go" + + "github.com/novox/mesh-controller/internal/broker" + "github.com/novox/mesh-controller/internal/testbus" +) + +// A fresh setuid search is asked of the machine on its own subject, signed under its own context — never a +// hand-over's — naming no path, and the engine's answer comes back (novox/hq issue 361). The engine holds the +// same subject and context (mesh-host internal/link, TestTheSetuidSearchAskKeepsItsSubjectAndContext). +func TestASetuidSearchIsAskedOfTheMachineSignedUnderItsOwnContext(t *testing.T) { + if broker.AskSetuidSearchSubject("laptop") != "mesh.node.laptop.ask.setuid-search" || + SetuidSearchContext != "novox-mesh setuid-search v1\n" { + t.Fatalf("the subject %q, the context %q", broker.AskSetuidSearchSubject("laptop"), SetuidSearchContext) + } + conn, err := nats.Connect(testbus.URL(t)) + if err != nil { + t.Fatal(err) + } + t.Cleanup(conn.Close) + signer, public := testSigner(t) + var heard HandOverAsk + engine, err := conn.Subscribe(broker.AskSetuidSearchSubject("laptop"), func(msg *nats.Msg) { + var signed SignedHandOver + _ = json.Unmarshal(msg.Data, &signed) + if ed25519.Verify(public, append([]byte(HandOverContext), signed.Ask...), signed.Signature) || + !ed25519.Verify(public, append([]byte(SetuidSearchContext), signed.Ask...), signed.Signature) { + _ = msg.Respond([]byte(`{"refused":"not signed as a setuid search"}`)) + return + } + _ = json.Unmarshal(signed.Ask, &heard) + body, _ := json.Marshal(HandOverAnswer{Said: "a new search starts, asked by " + heard.By}) + _ = msg.Respond(body) + }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = engine.Unsubscribe() }) + a, err := AskSetuidSearch(context.Background(), conn, signer, "laptop", "jo", 5*time.Second) + if err != nil || a.Said != "a new search starts, asked by jo" { + t.Fatalf("answered %+v, %v", a, err) + } + if heard.Node != "laptop" || heard.Path != "" || heard.Nonce == "" || heard.Expires.IsZero() { + t.Fatalf("the engine heard %+v", heard) + } + if _, err := AskSetuidSearch(context.Background(), conn, signer, "anchor", "jo", 5*time.Second); err == nil || + !strings.Contains(err.Error(), "no search was started") { + t.Fatalf("a machine with no engine listening: %v", err) + } +}