mesh-cli: follow a line to its answer, bound each account, refuse other namespaces (review of 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

- A request that timed out was said as "the controller was not reached,
  nothing ran", though the line may have run. The engine now follows a line
  the controller answers as running, by its call, until it ends; when no
  answer comes in time it says the line may have run and names the call.
  "Nothing ran" is said only of a request no controller took.
- One account could hold the socket: accepting waited for a free slot, and
  a connection could stall before its deadline was set. The deadline is set
  at accept, before any lookup; each user id has two lines at once and the
  machine eight, and one over is answered busy at once.
- A container running with the operator's user id shares the number, not
  the machine: a peer whose mount or user namespace is not the engine's is
  refused, and nothing is asked.

SDK pinned at the go/cli branch's newest commit until go/v0.1.9 is tagged.
This commit is contained in:
jochen
2026-10-09 12:48:20 +02:00
parent 021d860ca4
commit 564948c2f2
9 changed files with 361 additions and 76 deletions
+69 -27
View File
@@ -28,28 +28,65 @@ import (
// 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"`
Line []string `json:"line,omitempty"`
// 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"`
// Follow is a call a line runs as, asked about again by the account that asked it (AskTheController).
Follow string `json:"follow,omitempty"`
}
// 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)
// Forward asks the controller and follows the line to what came of it (AskTheController): what it printed, that
// nothing ran and why, or that it is not known.
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
// 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
// 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
// engine never stops accepting for it, so one account cannot hold the socket from every other (review of ADR 0272).
var (
atOnce = 8
perAccount = 2
)
// 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.
// slots counts the lines being asked, in all and per user id.
type slots struct {
mu sync.Mutex
all int
byUID map[uint32]int
}
func (s *slots) take(uid uint32) bool {
s.mu.Lock()
defer s.mu.Unlock()
if s.all >= atOnce || s.byUID[uid] >= perAccount {
return false
}
s.all++
s.byUID[uid]++
return true
}
func (s *slots) give(uid uint32) {
s.mu.Lock()
defer s.mu.Unlock()
s.all--
if s.byUID[uid]--; s.byUID[uid] <= 0 {
delete(s.byUID, uid)
}
}
// peerOf reads the connecting process's credentials from the kernel: its user id and process id. A variable so a
// test can say what the kernel would; it is the only source of the account.
var peerOf = peerCredentials
// sameNamespaces refuses a peer whose mount or user namespace is not the engine's. A variable so a test can say.
var sameNamespaces = namespacesOf
// 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))
@@ -99,7 +136,7 @@ func Serve(ctx context.Context, socket string, forward Forward, say func(string)
<-ctx.Done()
l.Close()
}()
slots := make(chan struct{}, atOnce)
held := &slots{byUID: map[uint32]int{}}
var wg sync.WaitGroup
defer wg.Wait()
for {
@@ -114,23 +151,19 @@ func Serve(ctx context.Context, socket string, forward Forward, say func(string)
}
return fmt.Errorf("mesh-cli's socket: %w", err)
}
select {
case slots <- struct{}{}:
case <-ctx.Done():
conn.Close()
return nil
}
// Bounded from the first byte: nothing below may wait on a connection for longer than this before the
// line is read, whatever the peer does (review of ADR 0272).
_ = conn.SetDeadline(time.Now().Add(readWithin))
wg.Add(1)
go func() {
defer wg.Done()
defer func() { <-slots }()
answer(ctx, conn, forward, say)
answer(ctx, conn, held, forward, say)
}()
}
}
// answer serves one connection.
func answer(ctx context.Context, conn *net.UnixConn, forward Forward, say func(string)) {
func answer(ctx context.Context, conn *net.UnixConn, held *slots, forward Forward, say func(string)) {
defer conn.Close()
reply := func(a cli.Answer) {
_ = conn.SetWriteDeadline(time.Now().Add(readWithin))
@@ -138,29 +171,38 @@ func answer(ctx context.Context, conn *net.UnixConn, forward Forward, say func(s
say(fmt.Sprintf("mesh-cli: the answer could not be written back: %v", err))
}
}
uid, err := peerOf(conn)
uid, pid, 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
}
// The user id is this machine's account only in this machine's namespaces: a container running with the
// operator's id shares the number and nothing else (review of ADR 0272).
if err := sameNamespaces(pid); err != nil {
reply(cli.Refusal("the asking process does not run in this machine's own namespaces, so it is not this " +
"machine's account and nothing was asked: " + err.Error()))
return
}
if !held.take(uid) {
reply(cli.Refusal(fmt.Sprintf("busy: user id %d has %d lines being asked already, or the machine %d; ask "+
"again when one has ended. Nothing was asked", uid, perAccount, atOnce)))
return
}
defer held.give(uid)
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)
// From here the wait is the controller's: as long as mesh-cli waits, and no longer.
_ = conn.SetDeadline(time.Now().Add(cli.AskWithin))
asking, cancel := context.WithTimeout(ctx, cli.AskWithin-30*time.Second)
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)
reply(forward(asking, Asked{Line: req.Line, Account: account, UID: uid}))
}