Say the login session a mesh-cli line comes from, and pin the SDK at go/v0.1.9 (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 delivered

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-<id>.scope under the account's own user-<uid>.slice), or nothing;
the controller makes nothing else the terminal. The SDK is pinned at its
tag now that go/v0.1.9 exists.
This commit is contained in:
jochen
2026-10-09 13:40:17 +02:00
parent 1ac7741c63
commit 4a5dcb0209
6 changed files with 82 additions and 5 deletions
+5
View File
@@ -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-<id>.scope` under the account's own `user-<uid>.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
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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=
+1 -1
View File
@@ -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
}
+36 -1
View File
@@ -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-<id>.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-<uid>.slice/session-<id>.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@<uid>.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)}))
}
+37
View File
@@ -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)
}
}