From f684967c3be6829b3d36a03451b351bb527a61f4 Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 9 Oct 2026 13:09:48 +0200 Subject: [PATCH] Merge main, and close what the confirmation review found (hq ADR 0272) - Any failure of a request the link may have delivered now says the line may have run; "nothing ran" is said only of a request the link knows it never asked (no link open, nobody listening: link.ErrNotAsked). - A test holds that a connection saying nothing is answered within its deadline, set at accept, and gives its slot back. - The README says the socket refuses a process outside the machine's own namespaces, so flatpak, toolbox and distrobox shells are refused. --- README.md | 13 ++++++++++ internal/link/hearing_nats.go | 3 ++- internal/link/queue.go | 8 ++++-- internal/meshcli/ask.go | 13 +++++++--- internal/meshcli/ask_test.go | 29 +++++++++++++++++++-- internal/meshcli/deadline_test.go | 42 +++++++++++++++++++++++++++++++ internal/meshcli/meshcli.go | 4 +-- 7 files changed, 101 insertions(+), 11 deletions(-) create mode 100644 internal/meshcli/deadline_test.go diff --git a/README.md b/README.md index a197ca9..52e710f 100644 --- a/README.md +++ b/README.md @@ -151,6 +151,19 @@ carries. The claim is not. The foundation is four container services, and: So `foundation.lock` here is a real bundle with a placeholder's content. Saying that plainly beats shipping a host that claims a foundation it has never raised. +## mesh-cli's socket (novox/hq ADR 0272) + +The engine serves `/run/nox-mesh-host/cli.sock` for `mesh-cli`, the operator's command (`internal/meshcli`). It +reads the asking account from the kernel (`SO_PEERCRED`) and asks the controller on this node's own subject; the +controller decides what the line may do, and the engine runs nothing it receives. One line per connection, each +connection's deadline set the moment it is accepted, two lines at once per account and eight for the machine, +"busy" beyond that. + +**A process outside the machine's own namespaces is refused**, whatever its user id: the peer's mount and user +namespace must be the engine's. So `mesh-cli` run inside a flatpak, a toolbox or distrobox container, or any other +container — even one running as the operator's own id — is refused with the reason; run it from the machine's own +shell. + ## A capability is detected, never assumed The reason this is the first thing built rather than a detail of it. diff --git a/internal/link/hearing_nats.go b/internal/link/hearing_nats.go index cbc57e2..712fae1 100644 --- a/internal/link/hearing_nats.go +++ b/internal/link/hearing_nats.go @@ -230,7 +230,8 @@ func (l *natsLink) Ask(ctx context.Context, subject string, body []byte) ([]byte case err == nil: return reply, nil case errors.Is(err, nats.ErrNoResponders): - return nil, fmt.Errorf("nothing on this machine answers %s", subject) + // The server says nobody subscribes the subject: delivered to no one. + return nil, fmt.Errorf("%w: nothing on this machine answers %s", ErrNotAsked, subject) } if refused := l.refusal(subject, before); refused != "" { return nil, fmt.Errorf("%s: %v%s", subject, err, refused) diff --git a/internal/link/queue.go b/internal/link/queue.go index 9447b50..5d938b8 100644 --- a/internal/link/queue.go +++ b/internal/link/queue.go @@ -552,15 +552,19 @@ func (q *Queue) AskController(ctx context.Context, body []byte) ([]byte, error) bus := q.bus q.mu.Unlock() if bus == nil { - return nil, errors.New("this machine has no link to the bus open now") + return nil, fmt.Errorf("%w: this machine has no link to the bus open now", ErrNotAsked) } asker, ok := bus.(ToolBus) if !ok { - return nil, errors.New("the link to the bus open now cannot ask the controller") + return nil, fmt.Errorf("%w: the link to the bus open now cannot ask the controller", ErrNotAsked) } return asker.Ask(ctx, CLISubject(q.Membership.Node), body) } +// ErrNotAsked is a request the link knows it never delivered: no link open, or nobody listening on its subject. Any +// other failure of a request may come after it was delivered, so what came of it is not known (novox/hq ADR 0272). +var ErrNotAsked = errors.New("not asked") + // ReadToolAnswer reads a tool's reply envelope — `{result, error}`, as the node tools answer every call — // as a health answer: an error is not healthy, and so is a result that does not say `healthy: true`. func ReadToolAnswer(reply []byte) (bool, string, error) { diff --git a/internal/meshcli/ask.go b/internal/meshcli/ask.go index 1a55d3e..7d80345 100644 --- a/internal/meshcli/ask.go +++ b/internal/meshcli/ask.go @@ -8,6 +8,7 @@ import ( "time" "git.novox.be/novox/mesh-sdk/go/cli" + "github.com/novox/mesh-host/internal/link" ) // The controller answers a line as it answers every call (novox/hq ADR 0272, review): in an envelope, `result` or @@ -15,6 +16,9 @@ import ( // followed by the same account on the same node until it ends. Its side holds the same field names (mesh-controller // internal/link, the mesh-cli field names' test). +// ErrNotAsked is the link's: a request it knows it never delivered. +var ErrNotAsked = link.ErrNotAsked + // AskFunc asks the controller one request on this node's subject and returns its reply. type AskFunc func(ctx context.Context, body []byte) ([]byte, error) @@ -40,11 +44,12 @@ func AskTheController(ctx context.Context, ask AskFunc, asked Asked) cli.Answer body, _ := json.Marshal(asked) reply, err := ask(ctx, body) if err != nil { - if errors.Is(err, context.DeadlineExceeded) || ctx.Err() != nil { - return cli.NotKnown("", "no answer from the controller in time") + // **Only a request the link knows it never asked ran nothing**: no link open, nobody listening. Any other + // failure may come after the request was delivered, and the line may have run (review of ADR 0272). + if errors.Is(err, ErrNotAsked) { + return cli.Refusal("the controller was not reached, so nothing ran: " + err.Error()) } - // Never delivered to a controller: no link, no subscriber, the bus refusing the publish. - return cli.Refusal("the controller was not reached, so nothing ran: " + err.Error()) + return cli.NotKnown("", "no answer from the controller ("+err.Error()+")") } call := "" for { diff --git a/internal/meshcli/ask_test.go b/internal/meshcli/ask_test.go index a1af43f..3291f65 100644 --- a/internal/meshcli/ask_test.go +++ b/internal/meshcli/ask_test.go @@ -83,16 +83,41 @@ func TestNoAnswerInTimeSaysTheLineMayHaveRun(t *testing.T) { // A request the bus never carried to a controller ran nothing, and says so. func TestARequestNobodyTookSaysNothingRan(t *testing.T) { c := &controllerSays{script: []func(Asked) ([]byte, error){func(Asked) ([]byte, error) { - return nil, fmt.Errorf("nothing on this machine answers mesh.control.laptop.cli") + return nil, fmt.Errorf("%w: nothing on this machine answers mesh.control.laptop.cli", ErrNotAsked) }}} a := AskTheController(context.Background(), c.ask, Asked{Line: []string{"status"}, Account: "op"}) if !strings.Contains(a.Refused, "nothing ran") || a.Unknown != "" { t.Fatalf("a request nobody took reads %+v", a) } c = &controllerSays{script: []func(Asked) ([]byte, error){func(Asked) ([]byte, error) { - return nil, errors.New("this machine has no link to the bus open now") + return nil, fmt.Errorf("%w: this machine has no link to the bus open now", ErrNotAsked) }}} if a := AskTheController(context.Background(), c.ask, Asked{Line: []string{"status"}}); !strings.Contains(a.Refused, "nothing ran") { t.Fatalf("no link reads %+v", a) } } + +// **Any error once the request may have been delivered is not known, never "nothing ran"** (review of ADR 0272): +// only a request the link says it never asked — no link open, nobody listening — ran nothing. +func TestOnlyARequestNeverAskedSaysNothingRan(t *testing.T) { + for _, c := range []struct { + err error + nothing bool + }{ + {fmt.Errorf("%w: this machine has no link to the bus open now", ErrNotAsked), true}, + {fmt.Errorf("%w: nothing on this machine answers mesh.control.laptop.cli", ErrNotAsked), true}, + {errors.New("nats: connection closed"), false}, + {errors.New("mesh.control.laptop.cli: permissions violation"), false}, + {context.DeadlineExceeded, false}, + } { + err := c.err + ctl := &controllerSays{script: []func(Asked) ([]byte, error){func(Asked) ([]byte, error) { return nil, err }}} + a := AskTheController(context.Background(), ctl.ask, Asked{Line: []string{"push"}, Account: "op"}) + if c.nothing && (!strings.Contains(a.Refused, "nothing ran") || a.Unknown != "") { + t.Errorf("%v reads %+v; it was never asked", err, a) + } + if !c.nothing && (a.Refused != "" || !strings.Contains(a.Unknown, "may have run")) { + t.Errorf("%v reads %+v; it may have been asked", err, a) + } + } +} diff --git a/internal/meshcli/deadline_test.go b/internal/meshcli/deadline_test.go new file mode 100644 index 0000000..05c4d88 --- /dev/null +++ b/internal/meshcli/deadline_test.go @@ -0,0 +1,42 @@ +package meshcli + +import ( + "context" + "net" + "strings" + "testing" + "time" + + "git.novox.be/novox/mesh-sdk/go/cli" +) + +// **A connection that says nothing is bounded from the moment it is accepted** (review of ADR 0272): its deadline is +// set before any lookup, so it is answered and closed within readWithin, holds no slot after, and asks nothing. +func TestAConnectionThatSaysNothingIsAnsweredWithinItsDeadline(t *testing.T) { + was := readWithin + readWithin = 200 * time.Millisecond + t.Cleanup(func() { readWithin = was }) + asked := false + socket := serving(t, func(context.Context, Asked) cli.Answer { + asked = true + return cli.Answer{} + }) + conn, err := net.Dial("unix", socket) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + _ = conn.SetDeadline(time.Now().Add(5 * time.Second)) + a, err := cli.ReadAnswer(conn) + if err != nil { + t.Fatalf("a silent connection was not answered within its deadline: %v", err) + } + if asked || a.Refused == "" || !strings.Contains(a.Refused, "nothing was asked") { + t.Fatalf("a silent connection was asked (%v) or answered %+v", asked, a) + } + // Its slot was given back: the account's next line is not busy. + b, err := cli.Ask(context.Background(), socket, []string{"status"}) + if err != nil || strings.Contains(b.Refused, "busy") || !asked { + t.Fatalf("the next line after a silent one reads %+v (%v), asked %v", b, err, asked) + } +} diff --git a/internal/meshcli/meshcli.go b/internal/meshcli/meshcli.go index 4c36dd1..aaee7d2 100644 --- a/internal/meshcli/meshcli.go +++ b/internal/meshcli/meshcli.go @@ -42,8 +42,8 @@ type Asked struct { type Forward func(ctx context.Context, asked Asked) cli.Answer // readWithin bounds how long a connection may take to say its line: a program that connects and says nothing -// holds nothing for long. -const readWithin = 30 * time.Second +// holds nothing for long. A variable so a test need not wait. +var readWithin = 30 * time.Second // atOnce bounds how many lines are asked at the same time, and perAccount how many of them one account's: a person // types one at a time, so more is a program on the machine. One over either bound is answered busy at once — the