Vouch for the account that runs mesh-cli, and ask the controller its line (hq ADR 0272)
mesh/merge-gate pass: builds mesh-host → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request

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.<node>.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.
This commit is contained in:
jochen
2026-10-09 12:05:53 +02:00
parent a390bce5f2
commit 021d860ca4
10 changed files with 469 additions and 0 deletions
+5
View File
@@ -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.<node>.>`), 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 {
+17
View File
@@ -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) {
+166
View File
@@ -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)
}
+168
View File
@@ -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, " ")
}
+50
View File
@@ -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 ""
}
+16
View File
@@ -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" }