Ask a node's engine for a fresh setuid search at the terminal (hq issue 361)
mesh/merge-gate pass: builds build-agent, mesh-controller, route-proxy → ace, g14, novox, shanks; no bus step; every machine composes with the change as it…
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered

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 <node> 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.
This commit is contained in:
jochen
2026-10-10 01:56:56 +02:00
parent 272ca2a578
commit 9cef820117
9 changed files with 237 additions and 15 deletions
+8 -1
View File
@@ -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.
+3 -1
View File
@@ -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"})
+1 -1
View File
@@ -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: {
+5
View File
@@ -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
+16
View File
@@ -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)
}
}
}
+48 -11
View File
@@ -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})
}
+59
View File
@@ -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)
}
}