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) + } +}