From 021d860ca4ad2c742e4fe1a60358c98bf1b99286 Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 9 Oct 2026 12:05:53 +0200 Subject: [PATCH] Vouch for the account that runs mesh-cli, and ask the controller its line (hq ADR 0272) The records send the operator to "the controller's terminal", and nothing reaches it (hq issue 343). mesh-cli holds no credential: it asks the engine on its own machine over a socket. The engine reads the asking account from the kernel (SO_PEERCRED), never from the program, and asks the controller on mesh.control..cli, its own node's subject, so the bus server says which node asked. The controller decides what the line may do; nothing received on the socket is run or applied here, and the socket has no network address (ADR 0004 stands). The local protocol is the SDK's go/cli package, at a pseudo-version of its branch until that merges and is tagged go/v0.1.8. --- cmd/mesh-host/main.go | 5 + cmd/mesh-host/meshcli.go | 39 +++++++ go.mod | 1 + go.sum | 2 + internal/link/bus.go | 5 + internal/link/queue.go | 17 ++++ internal/meshcli/meshcli.go | 166 ++++++++++++++++++++++++++++++ internal/meshcli/meshcli_test.go | 168 +++++++++++++++++++++++++++++++ internal/meshcli/peer_linux.go | 50 +++++++++ internal/meshcli/peer_other.go | 16 +++ 10 files changed, 469 insertions(+) create mode 100644 cmd/mesh-host/meshcli.go create mode 100644 internal/meshcli/meshcli.go create mode 100644 internal/meshcli/meshcli_test.go create mode 100644 internal/meshcli/peer_linux.go create mode 100644 internal/meshcli/peer_other.go diff --git a/cmd/mesh-host/main.go b/cmd/mesh-host/main.go index f5b47ec..ab7a5cb 100644 --- a/cmd/mesh-host/main.go +++ b/cmd/mesh-host/main.go @@ -1218,6 +1218,9 @@ func runLink(ctx context.Context, opts options) error { } // And the core builds this host placed are judged, whichever host placed them (to-be 45 §8). go watchWhatThisHostPlaced(aside, mine.Node, queue, say) + // And mesh-cli is answered on this machine: the account from the kernel, the line asked of the controller + // (novox/hq ADR 0272). + go serveMeshCLI(aside, queue, say) held := link.HoldRoused(aside, membership, queue, say, opts.timeout, rousedBySignal(ctx)) // The act in hand finishes and is kept before this process exits: an apply that stood aside with @@ -1364,6 +1367,8 @@ func (w *adoptionWatch) changed(r link.Report) bool { // be a control surface on every machine, reachable by anything that can reach the machine, in // exchange for saving twenty seconds — and the whole security argument rests on there not being // one. A signal is delivered by the service manager to a process it already supervises. +// (mesh-cli's socket is no such surface: it has no network address and nothing sent to it acts on +// the machine — it is asked of the controller, novox/hq ADR 0272 §3.) // // SIGHUP, because that is the signal a long-running program conventionally reads as *look again*, // and nothing here is being reloaded from a file that a different signal would suit better. diff --git a/cmd/mesh-host/meshcli.go b/cmd/mesh-host/meshcli.go new file mode 100644 index 0000000..15013c3 --- /dev/null +++ b/cmd/mesh-host/meshcli.go @@ -0,0 +1,39 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + + "git.novox.be/novox/mesh-sdk/go/cli" + "github.com/novox/mesh-host/internal/link" + "github.com/novox/mesh-host/internal/meshcli" +) + +// serveMeshCLI answers mesh-cli on this machine for as long as this engine serves (novox/hq ADR 0272): the account +// from the kernel, the line asked of the controller over the link open at the time. A socket that cannot be served +// is said once, and this engine goes on applying; mesh-cli then says the engine does not answer. +func serveMeshCLI(ctx context.Context, queue *link.Queue, say func(string)) { + if err := meshcli.Serve(ctx, cli.Socket, forwardOver(queue), say); err != nil { + say("mesh-cli is not answered on this machine: " + err.Error()) + } +} + +// forwardOver asks the controller over the queue's link, and reads its answer as mesh-cli's. +func forwardOver(queue *link.Queue) meshcli.Forward { + return func(ctx context.Context, asked meshcli.Asked) (cli.Answer, error) { + body, err := json.Marshal(asked) + if err != nil { + return cli.Answer{}, err + } + reply, err := queue.AskController(ctx, body) + if err != nil { + return cli.Answer{}, err + } + var a cli.Answer + if err := json.Unmarshal(reply, &a); err != nil { + return cli.Answer{}, fmt.Errorf("the controller's answer is not one: %w", err) + } + return a, nil + } +} diff --git a/go.mod b/go.mod index 2c02e75..70c549c 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/novox/mesh-host go 1.26.0 require ( + git.novox.be/novox/mesh-sdk/go v0.1.8-0.20261009100314-bfe82315f42c github.com/nats-io/nats.go v1.54.0 golang.org/x/crypto v0.57.0 ) diff --git a/go.sum b/go.sum index 65100b8..2f85253 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +git.novox.be/novox/mesh-sdk/go v0.1.8-0.20261009100314-bfe82315f42c h1:8hjQ3FYt7mGdZv+HX0Vk+n3pdaS3W5EIlwsrvSXXhRY= +git.novox.be/novox/mesh-sdk/go v0.1.8-0.20261009100314-bfe82315f42c/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA= github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA= diff --git a/internal/link/bus.go b/internal/link/bus.go index da91d15..fe3ffb2 100644 --- a/internal/link/bus.go +++ b/internal/link/bus.go @@ -67,6 +67,11 @@ func (b OverNATS) Health(ctx context.Context, node string, body []byte) error { return b.Conn.FlushWithContext(flush) } +// CLISubject is where this node's engine asks the controller a line `mesh-cli` was given on this machine +// (novox/hq ADR 0272 §3): its own node's, inside the grant every engine already has (`mesh.control..>`), so the +// bus server is the authority on which node asked. Core NATS, never the CONTROL stream, which names its own subjects. +func CLISubject(node string) string { return "mesh.control." + node + ".cli" } + // ToolSubject is a module's tool on one machine's node tools — the subject a node-engine asks a declared // tool check on (novox/hq ADR 0240, to-be 48 §3), granted it for its own machine's instance and no other. func ToolSubject(module, tool, node string) string { diff --git a/internal/link/queue.go b/internal/link/queue.go index 1ae5f5c..9447b50 100644 --- a/internal/link/queue.go +++ b/internal/link/queue.go @@ -544,6 +544,23 @@ func (q *Queue) AskTool(ctx context.Context, module, tool string) (bool, string, return ReadToolAnswer(reply) } +// AskController asks the controller a line mesh-cli was given on this machine, on this node's own subject, over +// the link open now (novox/hq ADR 0272 §3), and returns its answer as it came. No link open is said, not waited for. +func (q *Queue) AskController(ctx context.Context, body []byte) ([]byte, error) { + q.init() + q.mu.Lock() + bus := q.bus + q.mu.Unlock() + if bus == nil { + return nil, errors.New("this machine has no link to the bus open now") + } + asker, ok := bus.(ToolBus) + if !ok { + return nil, errors.New("the link to the bus open now cannot ask the controller") + } + return asker.Ask(ctx, CLISubject(q.Membership.Node), body) +} + // 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/meshcli.go b/internal/meshcli/meshcli.go new file mode 100644 index 0000000..1f4ba63 --- /dev/null +++ b/internal/meshcli/meshcli.go @@ -0,0 +1,166 @@ +// Package meshcli is the node-engine's side of `mesh-cli` (novox/hq ADR 0272): a socket on this machine that any +// account may connect to, the account read from the kernel, and the line asked of the controller. +// +// **The node-engine vouches; it does not decide.** It adds what only it can know — which account on this machine +// is asking, from the kernel's peer credentials, never from anything the program says — and sends the request out +// over its own link, on its own node's subject. The controller judges whether the line runs as its terminal, as an +// ordinary call, or not at all (ADR 0272 §4). Nothing received here is run or applied on the machine. +// +// **No listening control surface** (novox/hq ADR 0004, ADR 0272 §3): the socket has no network address, so +// nothing on another machine reaches it, and nothing sent to it acts on this one. +package meshcli + +import ( + "context" + "errors" + "fmt" + "net" + "os" + "os/user" + "path/filepath" + "strconv" + "sync" + "time" + + "git.novox.be/novox/mesh-sdk/go/cli" +) + +// Asked is what the node-engine sends the controller on its own node's subject: the line, and the account the +// kernel says is asking. The controller's side holds the same field names (a test on each side). +type Asked struct { + Line []string `json:"line"` + // Account is the login of the user id the kernel gave, from this machine's user database. + Account string `json:"account"` + // UID is the user id itself, so a renamed or duplicated login is still the same id. + UID uint32 `json:"uid"` +} + +// Forward asks the controller, and is answered. An error is said to the program as a refusal: nothing ran. +type Forward func(ctx context.Context, asked Asked) (cli.Answer, error) + +// 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 + +// atOnce bounds how many lines are asked at the same time. A person types one at a time; more than this is a +// program on the machine, and it waits. +const atOnce = 8 + +// peerOf reads the connecting process's credentials from the kernel. A variable so a test can say what the kernel +// would; it is the only source of the account. +var peerOf = peerCredentials + +// lookupAccount names a user id from this machine's user database. +var lookupAccount = func(uid uint32) (string, error) { + u, err := user.LookupId(strconv.FormatUint(uint64(uid), 10)) + if err != nil { + return "", err + } + return u.Username, nil +} + +// Serve listens on socket until ctx ends, and answers each connection: one request, one answer. +// +// The directory is made 0755 and the socket 0666: every account on the machine may ask, because the account is +// what is vouched for and the controller decides what it may do. A file at the socket's path that is not a socket +// is never removed: the engine says so and serves nothing. +func Serve(ctx context.Context, socket string, forward Forward, say func(string)) error { + dir := filepath.Dir(socket) + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("mesh-cli's socket directory: %w", err) + } + // The directory is the engine's: a link, or one another account owns, could put the socket where that + // account replaces it with its own. Said, and nothing served. + if problem := notOwnDirectory(dir); problem != "" { + return fmt.Errorf("mesh-cli is not served on this machine: %s", problem) + } + if err := os.Chmod(dir, 0o755); err != nil { + return fmt.Errorf("mesh-cli's socket directory: %w", err) + } + if info, err := os.Lstat(socket); err == nil { + if info.Mode()&os.ModeSocket == 0 { + return fmt.Errorf("%s is there and is not a socket, and the node-engine did not put it there; "+ + "mesh-cli is not served on this machine until it is moved", socket) + } + // A socket left by the engine before this one: nothing listens on it any more. + if err := os.Remove(socket); err != nil { + return fmt.Errorf("the socket a previous engine left at %s: %w", socket, err) + } + } + l, err := net.ListenUnix("unix", &net.UnixAddr{Name: socket, Net: "unix"}) + if err != nil { + return fmt.Errorf("listening for mesh-cli at %s: %w", socket, err) + } + if err := os.Chmod(socket, 0o666); err != nil { + l.Close() + return fmt.Errorf("opening mesh-cli's socket to every account: %w", err) + } + go func() { + <-ctx.Done() + l.Close() + }() + slots := make(chan struct{}, atOnce) + var wg sync.WaitGroup + defer wg.Wait() + for { + conn, err := l.AcceptUnix() + if err != nil { + if ctx.Err() != nil { + return nil + } + var ne net.Error + if errors.As(err, &ne) && ne.Timeout() { + continue + } + return fmt.Errorf("mesh-cli's socket: %w", err) + } + select { + case slots <- struct{}{}: + case <-ctx.Done(): + conn.Close() + return nil + } + wg.Add(1) + go func() { + defer wg.Done() + defer func() { <-slots }() + answer(ctx, conn, forward, say) + }() + } +} + +// answer serves one connection. +func answer(ctx context.Context, conn *net.UnixConn, forward Forward, say func(string)) { + defer conn.Close() + reply := func(a cli.Answer) { + _ = conn.SetWriteDeadline(time.Now().Add(readWithin)) + if err := cli.WriteAnswer(conn, a); err != nil && say != nil { + say(fmt.Sprintf("mesh-cli: the answer could not be written back: %v", err)) + } + } + uid, err := peerOf(conn) + if err != nil { + // Never sent as anyone: an account the kernel did not give is no account at all. + reply(cli.Refusal("the node-engine could not read which account is asking, so nothing was asked: " + err.Error())) + return + } + account, err := lookupAccount(uid) + if err != nil || account == "" { + reply(cli.Refusal(fmt.Sprintf("user id %d is not in this machine's user database, so nothing was asked", uid))) + return + } + _ = conn.SetReadDeadline(time.Now().Add(readWithin)) + req, err := cli.ReadRequest(conn) + if err != nil { + reply(cli.Refusal("the request could not be read, so nothing was asked: " + err.Error())) + return + } + asking, cancel := context.WithTimeout(ctx, cli.AskWithin-time.Minute/2) + defer cancel() + a, err := forward(asking, Asked{Line: req.Line, Account: account, UID: uid}) + if err != nil { + reply(cli.Refusal("the controller was not reached, so nothing ran: " + err.Error())) + return + } + reply(a) +} diff --git a/internal/meshcli/meshcli_test.go b/internal/meshcli/meshcli_test.go new file mode 100644 index 0000000..02191a7 --- /dev/null +++ b/internal/meshcli/meshcli_test.go @@ -0,0 +1,168 @@ +package meshcli + +import ( + "context" + "encoding/json" + "errors" + "net" + "os" + "os/user" + "path/filepath" + "sort" + "strings" + "testing" + "time" + + "git.novox.be/novox/mesh-sdk/go/cli" +) + +// serving starts Serve on a socket of the test's own, and stops it when the test ends. +func serving(t *testing.T, forward Forward) string { + t.Helper() + socket := filepath.Join(t.TempDir(), "run", "cli.sock") + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { done <- Serve(ctx, socket, forward, func(string) {}) }() + t.Cleanup(func() { + cancel() + if err := <-done; err != nil { + t.Errorf("Serve ended with %v", err) + } + }) + for i := 0; i < 200; i++ { + if _, err := os.Stat(socket); err == nil { + return socket + } + time.Sleep(5 * time.Millisecond) + } + t.Fatal("the socket never appeared") + return "" +} + +// The account sent is the one the kernel gives for the connecting process, and the answer comes back as it was. +func TestTheAccountAskingIsTheKernelsAndTheAnswerComesBack(t *testing.T) { + var got Asked + socket := serving(t, func(_ context.Context, asked Asked) (cli.Answer, error) { + got = asked + return cli.Answer{Stdout: []byte("done\n"), Exit: 0, Terminal: true, Why: "the terminal"}, nil + }) + a, err := cli.Ask(context.Background(), socket, []string{"settings", "show", "zsh"}) + if err != nil { + t.Fatal(err) + } + if string(a.Stdout) != "done\n" || !a.Terminal || a.Refused != "" { + t.Fatalf("the answer came back as %+v", a) + } + me, err := user.Current() + if err != nil { + t.Skipf("this machine cannot name the test's account: %v", err) + } + if int(got.UID) != os.Getuid() || got.Account != me.Username { + t.Fatalf("asked as %d %q, and the kernel says %d %q", got.UID, got.Account, os.Getuid(), me.Username) + } + if strings.Join(got.Line, " ") != "settings show zsh" { + t.Fatalf("the line was sent as %q", got.Line) + } + if info, err := os.Stat(socket); err != nil || info.Mode().Perm() != 0o666 { + t.Fatalf("the socket is not open to every account: %v %v", info.Mode(), err) + } +} + +// Credentials the kernel did not give are no account: refused, and nothing is asked. +func TestAConnectionWhoseAccountCannotBeReadIsNeverAskedAsAnyone(t *testing.T) { + before := peerOf + peerOf = func(*net.UnixConn) (uint32, error) { return 0, errors.New("no credentials") } + t.Cleanup(func() { peerOf = before }) + asked := false + socket := serving(t, func(context.Context, Asked) (cli.Answer, error) { + asked = true + return cli.Answer{}, nil + }) + a, err := cli.Ask(context.Background(), socket, []string{"status"}) + if err != nil { + t.Fatal(err) + } + if asked || a.Refused == "" || a.Exit == 0 { + t.Fatalf("a connection with no account was asked (%v) or not refused: %+v", asked, a) + } +} + +// A controller out of reach is said: nothing ran, and why. +func TestAControllerOutOfReachIsSaidAsNothingRan(t *testing.T) { + socket := serving(t, func(context.Context, Asked) (cli.Answer, error) { + return cli.Answer{}, errors.New("this machine has no link to the bus open now") + }) + a, err := cli.Ask(context.Background(), socket, []string{"status"}) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(a.Refused, "nothing ran") || !strings.Contains(a.Refused, "no link") || a.Exit != 1 { + t.Fatalf("an unreached controller was not said: %+v", a) + } +} + +// A request over the bound is refused and never asked. +func TestARequestOverTheBoundIsRefused(t *testing.T) { + asked := false + socket := serving(t, func(context.Context, Asked) (cli.Answer, error) { + asked = true + return cli.Answer{}, nil + }) + a, err := cli.Ask(context.Background(), socket, []string{strings.Repeat("x", cli.MaxRequest)}) + if err != nil { + t.Fatal(err) + } + if asked || a.Refused == "" { + t.Fatalf("a request over the bound was asked (%v): %+v", asked, a) + } +} + +// A file at the socket's path that is not a socket is never removed. +func TestAFileThatIsNotASocketIsKept(t *testing.T) { + dir := t.TempDir() + socket := filepath.Join(dir, "cli.sock") + if err := os.WriteFile(socket, []byte("mine"), 0o644); err != nil { + t.Fatal(err) + } + err := Serve(context.Background(), socket, nil, nil) + if err == nil || !strings.Contains(err.Error(), "not a socket") { + t.Fatalf("a file at the socket's path was not refused: %v", err) + } + if body, _ := os.ReadFile(socket); string(body) != "mine" { + t.Fatal("the file at the socket's path was touched") + } +} + +// The request the controller reads holds these field names; the controller's side holds the same list +// (mesh-controller, the mesh-cli request's test). +func TestTheRequestToTheControllerKeepsItsFieldNames(t *testing.T) { + body, err := json.Marshal(Asked{Line: []string{"status"}, Account: "a", UID: 1}) + if err != nil { + t.Fatal(err) + } + if got := keysOf(t, body); got != "account line uid" { + t.Fatalf("the request's fields are %q", got) + } + body, err = json.Marshal(cli.Answer{Stdout: []byte("o"), Stderr: []byte("e"), Exit: 1, Terminal: true, Why: "w", + Refused: "r", Cut: true}) + if err != nil { + t.Fatal(err) + } + if got := keysOf(t, body); got != "cut exit refused stderr stdout terminal why" { + t.Fatalf("the answer's fields are %q", got) + } +} + +func keysOf(t *testing.T, body []byte) string { + t.Helper() + var m map[string]any + if err := json.Unmarshal(body, &m); err != nil { + t.Fatal(err) + } + var keys []string + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return strings.Join(keys, " ") +} diff --git a/internal/meshcli/peer_linux.go b/internal/meshcli/peer_linux.go new file mode 100644 index 0000000..fc4a23c --- /dev/null +++ b/internal/meshcli/peer_linux.go @@ -0,0 +1,50 @@ +//go:build linux + +package meshcli + +import ( + "errors" + "fmt" + "net" + "os" + "syscall" +) + +// peerCredentials is the user id of the process at the other end, as the kernel recorded it when it connected +// (SO_PEERCRED). Nothing the process says is read. +func peerCredentials(conn *net.UnixConn) (uint32, error) { + raw, err := conn.SyscallConn() + if err != nil { + return 0, err + } + var cred *syscall.Ucred + var inner error + if err := raw.Control(func(fd uintptr) { + cred, inner = syscall.GetsockoptUcred(int(fd), syscall.SOL_SOCKET, syscall.SO_PEERCRED) + }); err != nil { + return 0, err + } + if inner != nil { + return 0, inner + } + if cred == nil { + return 0, errors.New("the kernel gave no credentials") + } + return cred.Uid, nil +} + +// notOwnDirectory says what is wrong with the socket's directory, or nothing: it must be a directory, not a +// link, owned by the account the engine runs as. +func notOwnDirectory(dir string) string { + info, err := os.Lstat(dir) + if err != nil { + return err.Error() + } + if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { + return dir + " is not a directory the engine made" + } + if st, ok := info.Sys().(*syscall.Stat_t); ok && int(st.Uid) != os.Geteuid() { + return fmt.Sprintf("%s belongs to user id %d, not to the engine's account", dir, st.Uid) + } + return "" +} diff --git a/internal/meshcli/peer_other.go b/internal/meshcli/peer_other.go new file mode 100644 index 0000000..6ad18e8 --- /dev/null +++ b/internal/meshcli/peer_other.go @@ -0,0 +1,16 @@ +//go:build !linux + +package meshcli + +import ( + "errors" + "net" +) + +// peerCredentials is unknown where the kernel's way of saying it is not built here: refused, never guessed. +func peerCredentials(*net.UnixConn) (uint32, error) { + return 0, errors.New("this engine reads a peer's account on Linux only") +} + +// notOwnDirectory is never satisfied where peers cannot be read. +func notOwnDirectory(string) string { return "not served on this system" }