From 4a5dcb0209ff6256e089c5301e63801c0cb9d106 Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 9 Oct 2026 13:40:17 +0200 Subject: [PATCH] Say the login session a mesh-cli line comes from, and pin the SDK at go/v0.1.9 (hq ADR 0272) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A service running as the operator — the tool runner, a unit of the account's user manager — shares the operator's user id, and was as much the terminal on the control-node as a person typing. The engine now reads the asking process's cgroup and sends its login session's scope (session-.scope under the account's own user-.slice), or nothing; the controller makes nothing else the terminal. The SDK is pinned at its tag now that go/v0.1.9 exists. --- README.md | 5 +++++ go.mod | 2 +- go.sum | 4 ++-- internal/meshcli/ask.go | 2 +- internal/meshcli/meshcli.go | 37 +++++++++++++++++++++++++++++++- internal/meshcli/meshcli_test.go | 37 ++++++++++++++++++++++++++++++++ 6 files changed, 82 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 52e710f..63ded39 100644 --- a/README.md +++ b/README.md @@ -159,6 +159,11 @@ controller decides what the line may do, and the engine runs nothing it receives connection's deadline set the moment it is accepted, two lines at once per account and eight for the machine, "busy" beyond that. +**Only a login session can be the terminal.** The engine reads the asking process's cgroup and sends its login +session's scope (`session-.scope` under the account's own `user-.slice`), or nothing; the controller makes +nothing else the terminal. A service running as the operator — the tool runner, a unit of the account's user +manager — is at most an ordinary call. + **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 diff --git a/go.mod b/go.mod index 9ae94b3..a3de83e 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/novox/mesh-host go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.8-0.20261009103748-a5a040c9049b + git.novox.be/novox/mesh-sdk/go v0.1.9 github.com/nats-io/nats.go v1.54.0 golang.org/x/crypto v0.57.0 golang.org/x/sys v0.48.0 diff --git a/go.sum b/go.sum index 3c8e7c7..9c0ea19 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.8-0.20261009103748-a5a040c9049b h1:HmasScQ02QeFlAFDnmRCOEserbtDJJtutK+acBmGlNs= -git.novox.be/novox/mesh-sdk/go v0.1.8-0.20261009103748-a5a040c9049b/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= +git.novox.be/novox/mesh-sdk/go v0.1.9 h1:2zq9tbwoXEYi+dDBxPoeNmn39KdRRKRp8CK33F4LWd0= +git.novox.be/novox/mesh-sdk/go v0.1.9/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/meshcli/ask.go b/internal/meshcli/ask.go index 7d80345..eb281b5 100644 --- a/internal/meshcli/ask.go +++ b/internal/meshcli/ask.go @@ -83,7 +83,7 @@ func AskTheController(ctx context.Context, ask AskFunc, asked Asked) cli.Answer return cli.NotKnown(call, fmt.Sprintf("no answer within the time mesh-cli waits (%s)", cli.AskWithin)) case <-time.After(followEvery): } - body, _ := json.Marshal(Asked{Follow: call, Account: asked.Account, UID: asked.UID}) + body, _ := json.Marshal(Asked{Follow: call, Account: asked.Account, UID: asked.UID, Session: asked.Session}) if reply, err = ask(ctx, body); err == nil { break } diff --git a/internal/meshcli/meshcli.go b/internal/meshcli/meshcli.go index aaee7d2..709fbb8 100644 --- a/internal/meshcli/meshcli.go +++ b/internal/meshcli/meshcli.go @@ -19,6 +19,7 @@ import ( "os/user" "path/filepath" "strconv" + "strings" "sync" "time" @@ -35,6 +36,11 @@ type Asked struct { UID uint32 `json:"uid"` // Follow is a call a line runs as, asked about again by the account that asked it (AskTheController). Follow string `json:"follow,omitempty"` + // Session is the login session the asking process runs in — its systemd scope, `session-.scope` under the + // account's own slice — as the kernel's cgroup says it; empty for anything else: a system service, a user unit, + // a process another account's session started. Only a login session is a person at a terminal (novox/hq ADR + // 0272): the controller makes nothing else the terminal. + Session string `json:"session,omitempty"` } // Forward asks the controller and follows the line to what came of it (AskTheController): what it printed, that @@ -84,6 +90,35 @@ func (s *slots) give(uid uint32) { // test can say what the kernel would; it is the only source of the account. var peerOf = peerCredentials +// sessionOf reads the login session a process runs in, from the kernel. A variable so a test can say. +var sessionOf = func(pid int32, uid uint32) string { + raw, err := os.ReadFile(fmt.Sprintf("/proc/%d/cgroup", pid)) + if err != nil { + return "" + } + return loginSessionIn(string(raw), uid) +} + +// loginSessionIn is the login session scope a process's cgroup names, for its own account: the unified hierarchy's +// line, `0::/user.slice/user-.slice/session-.scope`, and nothing below or beside it. Anything else is no +// login session — a service under system.slice, a unit under the account's user manager (user@.service), a +// scope of another account's. +func loginSessionIn(cgroup string, uid uint32) string { + for _, line := range strings.Split(cgroup, "\n") { + path, ok := strings.CutPrefix(line, "0::") + if !ok { + continue + } + prefix := fmt.Sprintf("/user.slice/user-%d.slice/", uid) + scope, ok := strings.CutPrefix(path, prefix) + if !ok || strings.Contains(scope, "/") || !strings.HasPrefix(scope, "session-") || !strings.HasSuffix(scope, ".scope") { + return "" + } + return scope + } + return "" +} + // sameNamespaces refuses a peer whose mount or user namespace is not the engine's. A variable so a test can say. var sameNamespaces = namespacesOf @@ -204,5 +239,5 @@ func answer(ctx context.Context, conn *net.UnixConn, held *slots, forward Forwar _ = conn.SetDeadline(time.Now().Add(cli.AskWithin)) asking, cancel := context.WithTimeout(ctx, cli.AskWithin-30*time.Second) defer cancel() - reply(forward(asking, Asked{Line: req.Line, Account: account, UID: uid})) + reply(forward(asking, Asked{Line: req.Line, Account: account, UID: uid, Session: sessionOf(pid, uid)})) } diff --git a/internal/meshcli/meshcli_test.go b/internal/meshcli/meshcli_test.go index 4599f1d..497d6bf 100644 --- a/internal/meshcli/meshcli_test.go +++ b/internal/meshcli/meshcli_test.go @@ -126,6 +126,11 @@ func TestTheRequestToTheControllerKeepsItsFieldNames(t *testing.T) { if err != nil { t.Fatal(err) } + body, _ = json.Marshal(Asked{Line: []string{"status"}, Account: "a", UID: 1, Session: "session-3.scope"}) + if got := keysOf(t, body); got != "account line session uid" { + t.Fatalf("the request's fields are %q", got) + } + body, _ = json.Marshal(Asked{Line: []string{"status"}, Account: "a", UID: 1}) if got := keysOf(t, body); got != "account line uid" { t.Fatalf("the request's fields are %q", got) } @@ -216,3 +221,35 @@ func TestAPeerInAnotherNamespaceIsRefused(t *testing.T) { t.Fatalf("a peer in another namespace was asked (%v): %+v", asked, a) } } + +// **The session a line comes from is the kernel's word, and only a login session counts** (review of ADR 0272): a +// service running as the operator — the tool runner, a user unit — is not a person at a terminal. The engine reads +// the peer's cgroup and sends the login session's scope, or nothing; the controller makes no other terminal. +func TestTheLoginSessionAskingIsReadFromTheKernel(t *testing.T) { + for _, c := range []struct{ cgroup, session string }{ + {"0::/user.slice/user-1000.slice/session-3.scope\n", "session-3.scope"}, + {"0::/user.slice/user-1000.slice/session-c1.scope\n", "session-c1.scope"}, + {"0::/user.slice/user-1000.slice/user@1000.service/app.slice/tmux.scope\n", ""}, + {"0::/system.slice/node-tools.service\n", ""}, + {"0::/user.slice/user-1001.slice/session-3.scope\n", ""}, // another account's session + {"12:pids:/user.slice\n0::/user.slice/user-1000.slice/session-9.scope/sub\n", ""}, + {"", ""}, + } { + if got := loginSessionIn(c.cgroup, 1000); got != c.session { + t.Errorf("%q read as %q, want %q", c.cgroup, got, c.session) + } + } + // And it is sent: the test process's own, whatever it is, as the kernel says it. + var got Asked + socket := serving(t, func(_ context.Context, asked Asked) cli.Answer { + got = asked + return cli.Answer{} + }) + if _, err := cli.Ask(context.Background(), socket, []string{"status"}); err != nil { + t.Fatal(err) + } + raw, _ := os.ReadFile("/proc/self/cgroup") + if want := loginSessionIn(string(raw), uint32(os.Getuid())); got.Session != want { + t.Fatalf("sent session %q, the kernel says %q", got.Session, want) + } +}