From 7385d9c092cc94645e4ab3c96790048f4d250781 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 12 Oct 2026 14:17:01 +0200 Subject: [PATCH] Ask a node's engine which of its directories the walk would refuse, read-only (issue 358) Before the node-engine gives a mesh directory its mode and owner only through a walk that follows no link another account could make, the operator is to see which directories that walk would refuse on every node, the control-node included, where no tool reads a path. `node judge-directories ` signs the ask as a hand-over is, under its own context, and prints the engine's one line per directory; it changes nothing, so it is a seat verb an agent may call. The node is granted the subject, the controller alone its publish. --- cmd/mesh-controller/handover_test.go | 51 ++++++++++++++++++++ cmd/mesh-controller/nodes.go | 63 ++++++++++++++++++++++++- cmd/mesh-controller/seatverbs.go | 9 +++- cmd/mesh-controller/status_summary.go | 2 +- internal/broker/nats.go | 11 ++++- internal/broker/nats_test.go | 4 +- internal/broker/testdata/composed.conf | 2 +- internal/broker/writers.go | 5 ++ internal/broker/writers_test.go | 19 ++++++++ internal/catalogue/verbs.go | 7 +++ internal/link/handover.go | 22 +++++++++ internal/link/judge_directories_test.go | 60 +++++++++++++++++++++++ module.json | 1 + 13 files changed, 249 insertions(+), 7 deletions(-) create mode 100644 internal/link/judge_directories_test.go diff --git a/cmd/mesh-controller/handover_test.go b/cmd/mesh-controller/handover_test.go index 60b12285..449f2f9e 100644 --- a/cmd/mesh-controller/handover_test.go +++ b/cmd/mesh-controller/handover_test.go @@ -167,3 +167,54 @@ func TestASetuidSearchAsksOnlyAKnownNodeAndFailsOnARefusal(t *testing.T) { t.Fatalf("a start: %v, printed %q", err, out.String()) } } + +// The directory judgement's line asks only a known node, one name and nothing else, fails on a refusal, and says it +// changed nothing (novox/hq issue 358). +func TestADirectoryJudgementAsksOnlyAKnownNodeAndSaysItIsReadOnly(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 := judgeDirectoriesAsked(args, known, ask(link.HandOverAnswer{Said: "x"}), &out); err == nil || asked != 0 { + t.Fatalf("%v was asked: %v", args, err) + } + } + if err := judgeDirectoriesAsked([]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 := judgeDirectoriesAsked([]string{"novox"}, known, ask(link.HandOverAnswer{Refused: "no; nothing was judged"}), + &out); err == nil || !strings.Contains(err.Error(), "novox refused") || out.Len() != 0 { + t.Fatalf("a refusal: %v, printed %q", err, out.String()) + } + said := "2 directories declared: 1 placed, 1 would be refused\n/var/lib/a: placed\n/var/lib/b: would be refused: …" + if err := judgeDirectoriesAsked([]string{"novox"}, known, ask(link.HandOverAnswer{Said: said}), &out); err != nil || + out.String() != "novox (read-only: nothing on novox was changed)\n"+said+"\n" { + t.Fatalf("a judgement: %v, printed %q", err, out.String()) + } +} + +// The judgement is a seat verb an agent may call — it reads, and changes nothing on the node — and runs exactly +// `node judge-directories ` (novox/hq issue 358). +func TestTheDirectoryJudgementIsAVerbThatOnlyReads(t *testing.T) { + argv, err := argvFor("judge-directories", map[string]any{"node": "novox"}) + if err != nil || strings.Join(argv, " ") != "node judge-directories novox" { + t.Fatalf("the verb runs %v, %v", argv, err) + } + if _, err := argvFor("judge-directories", map[string]any{}); err == nil { + t.Fatal("the verb ran with no node") + } + if !readingVerbs["judge-directories"] { + t.Fatal("the judgement is not counted a reading verb") + } +} diff --git a/cmd/mesh-controller/nodes.go b/cmd/mesh-controller/nodes.go index def9e9a3..8a1e4ac4 100644 --- a/cmd/mesh-controller/nodes.go +++ b/cmd/mesh-controller/nodes.go @@ -127,9 +127,14 @@ func nodeCommand(ctx context.Context, args []string) error { // and whoever may call a verb includes agents. return nodeSetuidSearch(ctx, open, args[1:]) + case "judge-directories": + // Which of a node's declared directories the walk that follows no link another account could make would + // refuse (novox/hq issue 358). Read-only on the node and here, so a verb may run it too. + return nodeJudgeDirectories(ctx, open, args[1:]) + default: - return fmt.Errorf("node has no %q; it has add, list, show, public-domain, account, agent-account, hand-over "+ - "and setuid-search", args[0]) + return fmt.Errorf("node has no %q; it has add, list, show, public-domain, account, agent-account, hand-over, "+ + "setuid-search and judge-directories", args[0]) } } @@ -937,3 +942,57 @@ func printJSONTo(w io.Writer, v any) error { fmt.Fprintln(w, string(body)) return nil } + +const judgeDirectoriesUsage = "node judge-directories — read-only: which of the directories is " + + "declared the node-engine's walk that follows no link another account could make would refuse, one line each " + + "(novox/hq issue 358). Nothing is made, chmodded or chowned" + +// nodeJudgeDirectories asks the node's engine to judge its declared directories, signed with the mesh's key as a +// hand-over is. Read-only: the engine changes nothing and keeps nothing. +func nodeJudgeDirectories(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.AskJudgeDirectories(ctx, js.Conn(), ident, node, by, link.HandOverWithin) + } + return judgeDirectoriesAsked(args, known, ask, os.Stdout) +} + +// judgeDirectoriesAsked 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 judgeDirectoriesAsked(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(judgeDirectoriesUsage) + } + 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.Fprintf(out, "%s (read-only: nothing on %s was changed)\n", node, node) + fmt.Fprintln(out, answer.Said) + return nil +} diff --git a/cmd/mesh-controller/seatverbs.go b/cmd/mesh-controller/seatverbs.go index 2b013154..d9414ad9 100644 --- a/cmd/mesh-controller/seatverbs.go +++ b/cmd/mesh-controller/seatverbs.go @@ -314,6 +314,11 @@ func (a *verbArguments) commandLine() ([]string, error) { return nil, err } return []string{"node", "show", str("node")}, nil + case "judge-directories": + if err := need("node"); err != nil { + return nil, err + } + return []string{"node", "judge-directories", str("node")}, nil case "modules": return []string{"module", "list", "--json"}, nil case "seats": @@ -1479,7 +1484,9 @@ func seatAnnouncement(handlers map[string]link.ToolHandler) micro.Info { } // nodeReads are the `node` subcommands a verb may run: the ones that only read. -var nodeReads = map[string]bool{"list": true, "show": true} +var nodeReads = map[string]bool{"list": true, "show": true, + // Read-only on the node too: the engine judges its directories and changes nothing (novox/hq issue 358). + "judge-directories": true} // flagsOnly says a command line's rest names no subcommand: empty, or beginning with a flag. For a command // with no subcommands every word is a flag, its value or a name it reads. diff --git a/cmd/mesh-controller/status_summary.go b/cmd/mesh-controller/status_summary.go index a81a0b8e..52d29591 100644 --- a/cmd/mesh-controller/status_summary.go +++ b/cmd/mesh-controller/status_summary.go @@ -172,7 +172,7 @@ func composeStatus(open *stores) func(context.Context) ([]byte, error) { var readingVerbs = map[string]bool{ "tools": true, "calls": true, "status": true, "nodes": true, "node": true, "modules": true, "seats": true, "builds": true, "plan": true, "queue": true, "durations": true, "hand-acts": true, - "doctor": true, "conditions": true, "healers": true, + "doctor": true, "conditions": true, "healers": true, "judge-directories": true, } // nudgingListener is the enrolment, nudging the summary when a machine said something new. diff --git a/internal/broker/nats.go b/internal/broker/nats.go index ab48ea46..3e5a42ca 100644 --- a/internal/broker/nats.go +++ b/internal/broker/nats.go @@ -336,6 +336,13 @@ func AskHandOverSubject(node string) string { return "mesh.node." + node + ".ask // a hand-over is (link.SetuidSearchContext), for the same reason. func AskSetuidSearchSubject(node string) string { return "mesh.node." + node + ".ask.setuid-search" } +// AskJudgeDirectoriesSubject is where the controller asks one machine's node-engine to judge, read-only, which of +// its declared directories the walk that follows no link another account could make would refuse (novox/hq issue +// 358): a request answered once, signed as a hand-over is (link.JudgeDirectoriesContext), for the same reason. +func AskJudgeDirectoriesSubject(node string) string { + return "mesh.node." + node + ".ask.judge-directories" +} + // 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. @@ -614,7 +621,9 @@ func PermissionsFor(p Principal) (Permissions, error) { // issue 356), which it answers on the request's reply: the one request a node is asked. AskHandOverSubject(p.Node), // And asking it for a fresh search for setuid programs (novox/hq issue 361), answered the same way. - AskSetuidSearchSubject(p.Node)} + AskSetuidSearchSubject(p.Node), + // And asking it to judge its declared directories, read-only (novox/hq issue 358), answered the same way. + AskJudgeDirectoriesSubject(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 bfd52cfb..b0243280 100644 --- a/internal/broker/nats_test.go +++ b/internal/broker/nats_test.go @@ -114,7 +114,9 @@ func TestOnlyWhatCanBeAskedMayAnswer(t *testing.T) { node, _ := PermissionsFor(Principal{Kind: KindNode, Node: "one", PasswordHash: "x"}) if !node.AllowResponses || !slices.Contains(node.Subscribe, AskHandOverSubject("one")) || !slices.Contains(node.Subscribe, AskSetuidSearchSubject("one")) || - slices.Contains(node.Subscribe, AskSetuidSearchSubject("two")) { + slices.Contains(node.Subscribe, AskSetuidSearchSubject("two")) || + !slices.Contains(node.Subscribe, AskJudgeDirectoriesSubject("one")) || + slices.Contains(node.Subscribe, AskJudgeDirectoriesSubject("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 b3003837..8270aaf3 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.ask.setuid-search", "mesh.node.one.declare"] } + subscribe: { allow: ["_DELIVER.one", "_DELIVER.one.>", "_INBOX.node.one.>", "mesh.node.one.ask.hand-over", "mesh.node.one.ask.judge-directories", "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 c99dcde6..6099dfb0 100644 --- a/internal/broker/writers.go +++ b/internal/broker/writers.go @@ -96,6 +96,11 @@ var WritersTable = []WriterRow{ {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}, + // A read-only judgement of a machine's declared directories asked of its engine (novox/hq issue 358): signed as a + // hand-over is, under a signing context of its own. It changes nothing on the machine. + {State: "a judgement of a machine's directories asked of it", Writer: "controller", + KeptIn: "nowhere: the engine answers it and keeps nothing", Others: "the engine verifies the mesh's signature and answers, or refuses", + Subjects: []string{"mesh.node.*.ask.judge-directories"}, 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 a5e0f069..124de422 100644 --- a/internal/broker/writers_test.go +++ b/internal/broker/writers_test.go @@ -17,6 +17,7 @@ var designRows = []string{ "a machine's declaration", "a hand-over asked of a machine", "a fresh setuid search asked of a machine", + "a judgement of a machine's directories asked of it", "a machine's applied state and its report", "the controller lease", "plans and their tiers", @@ -186,3 +187,21 @@ func TestASetuidSearchAskHasOnePublisher(t *testing.T) { } } } + +// **A judgement of a machine's directories asked of it has one publisher, the controller** (novox/hq issue 358), as a +// hand-over has. +func TestADirectoryJudgementAskHasOnePublisher(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{AskJudgeDirectoriesSubject("laptop")}) + if err == nil || !strings.Contains(err.Error(), "a judgement of a machine's directories asked of it") { + t.Errorf("%s may ask a judgement of directories: %v", p.Username(), err) + } + } + if err := CheckWriters(Principal{Kind: KindController}, []string{AskJudgeDirectoriesSubject("laptop")}); err != nil { + t.Fatalf("the controller may not ask a judgement: %v", err) + } +} diff --git a/internal/catalogue/verbs.go b/internal/catalogue/verbs.go index 013f5c77..0fbe2c6c 100644 --- a/internal/catalogue/verbs.go +++ b/internal/catalogue/verbs.go @@ -490,6 +490,13 @@ var ControllerVerbs = []Verb{ "why": "with consumer or older-than: why — required, and recorded in the hand-act log", "cause": "with consumer or older-than: the cause in a word (cleanup-waiting when absent)", }, nil, "confirm")}, + {Name: "judge-directories", Description: "Read-only: which of the directories one machine is declared its " + + "node-engine's walk that follows no link another account could make would refuse (novox/hq issue 358), one line " + + "each: placed, would be refused and why (the link and who could replace it), refused already by the placement " + + "guard, or below a home. The engine judges the declaration it keeps and makes, chmods and chowns nothing.", + Input: schema(map[string]string{ + "node": "the machine's name", + }, []string{"node"})}, // What a consumer gave up on (novox/hq issue 330): kept in DEAD_LETTERS until a person acts on it. {Name: "root-free", Description: "Whether each machine named is root-free now (novox/hq ADR 0259 §8): no agent " + "there can become root without a person. Judged when asked, never from a condition: free only when the machine " + diff --git a/internal/link/handover.go b/internal/link/handover.go index 18c2d675..fb569571 100644 --- a/internal/link/handover.go +++ b/internal/link/handover.go @@ -148,6 +148,28 @@ func AskSetuidSearch(ctx context.Context, conn *nats.Conn, signer Signer, node, "there", record: "a start"}) } +// JudgeDirectoriesContext is prefixed to a directory judgement ask's bytes before signing (novox/hq issue 358): never +// another ask's signature, nor a declaration's. The engine holds the same words. +const JudgeDirectoriesContext = "novox-mesh judge-directories v1\n" + +// AskJudgeDirectories asks one machine's node-engine to judge, read-only, which of the directories it is declared +// the walk that follows no link another account could make would refuse (novox/hq issue 358): the ask a hand-over +// is, naming no path, signed under JudgeDirectoriesContext. The engine changes nothing and answers one line per +// directory. +func AskJudgeDirectories(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, JudgeDirectoriesContext, HandOverAsk{Node: node, By: by}) + if err != nil { + return HandOverAnswer{}, err + } + return askSigned(ctx, conn, broker.AskJudgeDirectoriesSubject(node), body, node, timeout, askWords{ + what: "a judgement of its directories", nothing: "nothing was judged, and nothing is changed by one", + issue: "issue 358", unknown: "ask again: a judgement changes nothing", record: "a judgement"}) +} + // 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) { diff --git a/internal/link/judge_directories_test.go b/internal/link/judge_directories_test.go new file mode 100644 index 00000000..10cfdde1 --- /dev/null +++ b/internal/link/judge_directories_test.go @@ -0,0 +1,60 @@ +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 judgement of a machine's directories 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 358). The engine holds the +// same subject and context (mesh-host internal/link, TestTheDirectoryJudgementAskKeepsItsSubjectAndContext). +func TestADirectoryJudgementIsAskedOfTheMachineSignedUnderItsOwnContext(t *testing.T) { + if broker.AskJudgeDirectoriesSubject("laptop") != "mesh.node.laptop.ask.judge-directories" || + JudgeDirectoriesContext != "novox-mesh judge-directories v1\n" { + t.Fatalf("the subject %q, the context %q", broker.AskJudgeDirectoriesSubject("laptop"), JudgeDirectoriesContext) + } + 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.AskJudgeDirectoriesSubject("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) || + !ed25519.Verify(public, append([]byte(JudgeDirectoriesContext), signed.Ask...), signed.Signature) { + _ = msg.Respond([]byte(`{"refused":"not signed as a judgement"}`)) + return + } + _ = json.Unmarshal(signed.Ask, &heard) + body, _ := json.Marshal(HandOverAnswer{Said: "3 directories declared, asked by " + heard.By}) + _ = msg.Respond(body) + }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = engine.Unsubscribe() }) + a, err := AskJudgeDirectories(context.Background(), conn, signer, "laptop", "jo", 5*time.Second) + if err != nil || a.Said != "3 directories declared, 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 := AskJudgeDirectories(context.Background(), conn, signer, "anchor", "jo", 5*time.Second); err == nil || + !strings.Contains(err.Error(), "nothing was judged") { + t.Fatalf("a machine with no engine listening: %v", err) + } +} diff --git a/module.json b/module.json index f3d79f41..5c98f461 100644 --- a/module.json +++ b/module.json @@ -89,6 +89,7 @@ "cleanup", "dead-letters", "root-free", + "judge-directories", "data", "build", "artifacts",