Files
mesh-host/internal/filling/filling.go
T
mesh-adminandClaude Opus 5.5 2767278228
mesh/merge-gate head 2767278228bc merged into trunk c18d5a8ff38d — pass: builds mesh-host → ace, g14, novox, shanks; no bus step; every machine composes wi…
mesh/repo-check head 2767278228bc merged into trunk c18d5a8ff38d — pass: its merge-check.sh passed
mesh/delivery delivered
Say every refusal kind at once and never lose a count; bound the waiting and the busy per socket
Review 12004 of mesh-host#111: refusals counted after a said one reached the journal only if the same account was refused again a minute later, and a refusal of another kind hid behind it. The first of each (account, kind) is said at once, and what is counted is said on a timer and when the socket stops. Connections waiting to be judged are bounded below the open-file limit, the busy refusals belong to one Serve, and the tests' ask has a deadline. Issue 668.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 01:06:47 +00:00

513 lines
19 KiB
Go

// Package filling is the node-engine's narrow local write verb (novox/hq ADR 0349 §4, design 57 P2): a socket on
// this machine through which the provisioner's client has a file it filled written, and nothing else.
//
// **The client fills; the node-engine writes.** The client renders a module's template with the values each domain
// owns and hands the node-engine the content; the node-engine writes it as the declared file, with the declared
// file's path, owner, mode and ownership at runtime, and makes what reads it take the change. So the one process that
// writes the machine's files is still the node-engine, and the client needs no right to write any of them.
//
// **Narrow by three checks, each from something the client cannot say for itself.** The account asking, from the
// kernel (SO_PEERCRED), in this machine's own namespaces, must be the account the node's current declaration names
// as the provisioner's client's; the path, clean and absolute, must be one the declaration names as filled; and the
// write takes the apply's lock, so a write and an apply never write one file at once. While the declaration names no
// account — every declaration before the provisioner's client has one — every write is refused.
//
// **No listening control surface** (novox/hq ADR 0004): the socket has no network address. And the content is never
// said: the journal has the path, the account and the content's digest.
package filling
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"os"
"os/user"
"path/filepath"
"sort"
"strconv"
"sync"
"sync/atomic"
"time"
"github.com/novox/mesh-host/internal/declaration"
"github.com/novox/mesh-host/internal/meshcli"
)
// Socket is where the node-engine accepts a write of a filled file, beside mesh-cli's socket.
const Socket = "/run/nox-mesh-host/fill.sock"
// MaxRequest bounds one request, its content encoded in it: a configuration file, never an archive.
const MaxRequest = 8 << 20
// Request is one write, sent as one JSON object on one line: the path of a filled file and what the client filled
// into it (base64 in the JSON). The content only: the declaration says everything else about the file.
type Request struct {
Path string `json:"path"`
Content []byte `json:"content"`
}
// Answer is what came of one request. Refused is a write that was not attempted, with why; Failed one that was
// attempted and did not complete, with why — the file, or something that reads it. Otherwise Action is what the
// write did to the file (created, updated, corrected, unchanged, kept) and Digest what the file holds now, as the
// node-engine records it.
type Answer struct {
Path string `json:"path,omitempty"`
Action string `json:"action,omitempty"`
Digest string `json:"digest,omitempty"`
Refused string `json:"refused,omitempty"`
Failed string `json:"failed,omitempty"`
}
// Refusal is an answer that nothing was written, and why.
func Refusal(path, why string) Answer { return Answer{Path: path, Refused: why} }
// Writer is the node-engine's side of the socket.
type Writer interface {
// Accepts judges the asking account before its request is read: nil when the current declaration names it as
// the provisioner's client's.
Accepts(uid uint32) error
// Write judges the request and writes it, holding the apply's lock.
Write(ctx context.Context, uid uint32, req Request) Answer
}
// Engine is the Writer the node-engine serves: the declaration it applies now, read again under the apply's lock.
type Engine struct {
// Declared reads the declaration this node applies now, verified.
Declared func() (*declaration.Declaration, error)
// UIDOf is the user id of an account, by name, from this machine's user database.
UIDOf func(account string) (uint32, error)
// Lock takes the apply's lock and returns what releases it: the same the apply takes, so a write and an apply
// are one at a time.
Lock func() (func(), error)
// Place writes the content into the filled file at path, as the declared file, and makes what reads it take the
// change. Called holding the lock, with the declaration read under it.
Place func(ctx context.Context, d *declaration.Declaration, path string, content []byte) (action, digest string,
err error)
}
// Accepts reads the declaration and judges the account.
func (e *Engine) Accepts(uid uint32) error {
d, err := e.Declared()
if err != nil {
return fmt.Errorf("this node's declaration cannot be read, so no write is accepted: %w", err)
}
return JudgeAccount(d, uid, e.UIDOf)
}
// Write takes the apply's lock, reads the declaration under it, judges the account and the path again — the
// declaration may have changed since the request was accepted — and writes.
func (e *Engine) Write(ctx context.Context, uid uint32, req Request) Answer {
if err := JudgeShape(req.Path); err != nil {
return Refusal(req.Path, err.Error())
}
unlock, err := e.Lock()
if err != nil {
return Refusal(req.Path, "the apply's lock could not be taken, so nothing was written: "+err.Error())
}
defer unlock()
d, err := e.Declared()
if err != nil {
return Refusal(req.Path, "this node's declaration cannot be read, so nothing was written: "+err.Error())
}
if err := JudgeAccount(d, uid, e.UIDOf); err != nil {
return Refusal(req.Path, err.Error())
}
if err := JudgePath(d, req.Path); err != nil {
return Refusal(req.Path, err.Error())
}
action, digest, err := e.Place(ctx, d, req.Path, req.Content)
if err != nil {
return Answer{Path: req.Path, Action: action, Digest: digest, Failed: err.Error()}
}
return Answer{Path: req.Path, Action: action, Digest: digest}
}
// JudgeAccount refuses every account but the one the declaration names as the provisioner's client's, by its user
// id here — and every account while the declaration names none.
func JudgeAccount(d *declaration.Declaration, uid uint32, uidOf func(string) (uint32, error)) error {
if d.Provisioner == "" {
return errors.New("this node's declaration names no provisioner client's account, so no write is accepted")
}
want, err := uidOf(d.Provisioner)
if err != nil {
return fmt.Errorf("the provisioner client's account %s is not on this machine, so no write is accepted: %w",
d.Provisioner, err)
}
if want == 0 {
return fmt.Errorf("the provisioner client's account %s is root here, so no write is accepted", d.Provisioner)
}
if uid != want {
return fmt.Errorf("user id %d is not the provisioner client's account %s (user id %d), so nothing was written",
uid, d.Provisioner, want)
}
return nil
}
// JudgeShape refuses a path that is not clean and absolute: a relative path, `..`, a doubled or trailing slash.
// Compared as written with the declaration's, a path that is not clean could name a file the declaration does not.
func JudgeShape(path string) error {
switch {
case path == "":
return errors.New("the request names no path, so nothing was written")
case !filepath.IsAbs(path):
return fmt.Errorf("%q is not an absolute path, so nothing was written", path)
case filepath.Clean(path) != path:
return fmt.Errorf("%q is not a clean path (it is %q cleaned), so nothing was written", path,
filepath.Clean(path))
}
return nil
}
// JudgePath refuses a path that is not clean and absolute, or that the declaration does not name as filled. Where it
// leads — a link on the way, a place nothing is written — is judged by the write, by the apply's own rules.
func JudgePath(d *declaration.Declaration, path string) error {
if err := JudgeShape(path); err != nil {
return err
}
if !d.Fills(path) {
return fmt.Errorf("%s is not a file this node's declaration names as filled, so nothing was written", path)
}
return nil
}
// UIDOf is the user id of an account by name, from this machine's user database.
func UIDOf(account string) (uint32, error) {
u, err := user.Lookup(account)
if err != nil {
return 0, err
}
id, err := strconv.ParseUint(u.Uid, 10, 32)
if err != nil {
return 0, fmt.Errorf("user id %q: %w", u.Uid, err)
}
return uint32(id), nil
}
// peerOf and sameNamespaces are the kernel's word on the asking process, as mesh-cli's socket reads it. Variables so
// a test can say.
var (
peerOf = meshcli.PeerCredentials
sameNamespaces = meshcli.SameNamespaces
)
// within bounds how long an accepted connection may take to say its request, and refusedWithin how long a busy one
// is read and dropped before its refusal is written (so the client is not answered with a broken pipe). Variables so
// a test need not wait.
var (
within = 30 * time.Second
refusedWithin = 1 * time.Second
)
// atOnce bounds the accepted connections served at the same time: one client, writing one file at a time. One over
// is refused busy. busyWaitingAtOnce bounds how many busy refusals wait for their request at once, per Serve; over it
// a busy refusal is written at once, as mesh-cli's socket does. judgingAtOnce bounds the callers judged at once, and
// judgeWaiting the connections waiting to be judged — each an open file, so far below the engine's open-file limit;
// one over is closed at once, unread. Variables so a test can say.
var (
atOnce = 4
busyWaitingAtOnce = 8
judgingAtOnce = 16
judgeWaiting = 256
)
// refusalsSaidEvery is how often the refusals counted and not yet said are said: each kind of refusal of each account
// is said at once the first time, and what follows it within this time is counted and said at its end, or when the
// socket stops. A variable so a test need not wait.
var refusalsSaidEvery = time.Minute
// acceptBackoff is how long the socket waits after an accept that failed before it accepts again, at first and at
// most: a socket that stopped at the first such failure (too many open files, say) would stop the client's writes
// until the engine restarted. Variables so a test need not wait.
var (
acceptBackoff = 50 * time.Millisecond
acceptBackoffMax = 5 * time.Second
)
// acceptFrom accepts one connection. A variable so a test can make it fail.
var acceptFrom = func(l *net.UnixListener) (*net.UnixConn, error) { return l.AcceptUnix() }
// Serve listens on socket until ctx ends, and answers each connection: one request, one answer.
//
// The directory is made 0755 and the socket 0666, as mesh-cli's: who may write is judged per connection from the
// kernel's word, against the current declaration, so the socket needs no account that may not exist yet. **The
// caller is judged before it holds anything**: one the kernel does not say is the provisioner client's account is
// answered and closed at once, and only the client's connections are served or wait as busy, so no other account can
// hold the socket from it. 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, w Writer, say func(string)) error {
if say == nil {
say = func(string) {}
}
dir := filepath.Dir(socket)
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("the filled files' socket directory: %w", err)
}
if problem := meshcli.NotOwnDirectory(dir); problem != "" {
return fmt.Errorf("filled files are not written on request on this machine: %s", problem)
}
if err := os.Chmod(dir, 0o755); err != nil {
return fmt.Errorf("the filled files' 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; filled "+
"files are not written on request until it is moved", socket)
}
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 filled files at %s: %w", socket, err)
}
if err := os.Chmod(socket, 0o666); err != nil {
l.Close()
return fmt.Errorf("opening the filled files' socket: %w", err)
}
go func() {
<-ctx.Done()
l.Close()
}()
slots := make(chan struct{}, atOnce)
// Judging reads the declaration: at most judging callers are judged at once, and the rest wait their turn — a
// few milliseconds each — rather than being refused, so a flood delays the client's write and never stops it.
judging := make(chan struct{}, judgingAtOnce)
var waiting atomic.Int64
busyWaiting := make(chan struct{}, busyWaitingAtOnce)
refusals := &refusalWords{say: say}
// Said once everything has stopped: what was counted is never lost with the socket.
defer refusals.flush()
var wg sync.WaitGroup
defer wg.Wait()
wg.Add(1)
go func() {
defer wg.Done()
tick := time.NewTicker(refusalsSaidEvery)
defer tick.Stop()
for {
select {
case <-ctx.Done():
return
case <-tick.C:
refusals.flush()
}
}
}()
backoff := time.Duration(0)
for {
conn, err := acceptFrom(l)
if err != nil {
if ctx.Err() != nil {
return nil
}
if errors.Is(err, net.ErrClosed) {
return fmt.Errorf("the filled files' socket was closed: %w", err)
}
// Said, waited for, and accepted again: the failure is the machine's for a moment, not the socket's.
backoff = min(max(2*backoff, acceptBackoff), acceptBackoffMax)
say(fmt.Sprintf("the filled files' socket could not accept a connection (%v); accepting again in %s",
err, backoff))
select {
case <-ctx.Done():
return nil
case <-time.After(backoff):
}
continue
}
backoff = 0
if waiting.Add(1) > int64(judgeWaiting) {
// Too many waiting to be judged already: this one is closed unread, and counted.
waiting.Add(-1)
refusals.said(^uint32(0), "overflow", "too many connections are waiting to be judged; closed unread")
conn.Close()
continue
}
wg.Add(1)
go func() {
defer wg.Done()
// Judged before it holds a slot or is waited for.
judging <- struct{}{}
uid, ok := judgePeer(conn, w, refusals)
<-judging
waiting.Add(-1)
if !ok {
return
}
readBy := time.Now().Add(within)
_ = conn.SetDeadline(readBy)
select {
case slots <- struct{}{}:
defer func() { <-slots }()
answer(ctx, conn, uid, w, say)
default:
busy(conn, readBy, busyWaiting, say)
}
}()
}
}
// judgePeer reads the asking account from the kernel and judges it. A caller that is not the provisioner client's
// account is answered with why and closed at once, its request never read: false.
func judgePeer(conn *net.UnixConn, w Writer, refusals *refusalWords) (uint32, bool) {
closeWith := func(uid uint32, kind, why string) (uint32, bool) {
refusals.said(uid, kind, why)
_ = conn.SetWriteDeadline(time.Now().Add(refusedWithin))
_ = json.NewEncoder(conn).Encode(Refusal("", why))
conn.Close()
return uid, false
}
uid, pid, err := peerOf(conn)
if err != nil {
return closeWith(^uint32(0), "unread", "the node-engine could not read which account is asking, so nothing was "+
"written: "+err.Error())
}
if err := sameNamespaces(pid); err != nil {
return closeWith(uid, "namespace", "the asking process does not run in this machine's own namespaces, so nothing was "+
"written: "+err.Error())
}
if err := w.Accepts(uid); err != nil {
return closeWith(uid, "account", err.Error())
}
return uid, true
}
// refusalWords says refused callers in the journal without letting a flood flood it: the first refusal of each kind
// for each account at once, and those that follow it counted and said by flush — on a timer and when the socket stops
// — so an account that stops still has its count said, and a refusal of another kind never hides behind one said.
type refusalWords struct {
mu sync.Mutex
say func(string)
pending map[refusalKey]*refusalCount
}
type refusalKey struct {
uid uint32
kind string
}
type refusalCount struct {
n int
why string
}
func (r *refusalWords) said(uid uint32, kind, why string) {
r.mu.Lock()
defer r.mu.Unlock()
if r.pending == nil {
r.pending = map[refusalKey]*refusalCount{}
}
k := refusalKey{uid, kind}
if c, ok := r.pending[k]; ok {
c.n++
c.why = why
return
}
r.pending[k] = &refusalCount{}
r.say(fmt.Sprintf("a filled file's write from %s was refused (%s): %s", who(k), kind, why))
}
// flush says what was counted since each was last said, and forgets every key, so the next refusal of each is said at
// once again.
func (r *refusalWords) flush() {
r.mu.Lock()
defer r.mu.Unlock()
keys := make([]refusalKey, 0, len(r.pending))
for k := range r.pending {
keys = append(keys, k)
}
sort.Slice(keys, func(i, j int) bool {
if keys[i].uid != keys[j].uid {
return keys[i].uid < keys[j].uid
}
return keys[i].kind < keys[j].kind
})
for _, k := range keys {
if c := r.pending[k]; c.n > 0 {
r.say(fmt.Sprintf("%d more writes of a filled file from %s were refused (%s) since it was last said; the last: %s",
c.n, who(k), k.kind, c.why))
}
}
r.pending = nil
}
// who names a caller by its user id, or says why it has none.
func who(k refusalKey) string {
switch {
case k.kind == "overflow":
return "a caller not yet judged"
case k.uid == ^uint32(0):
return "an account that could not be read"
}
return fmt.Sprintf("user id %d", k.uid)
}
// answer serves one connection from the provisioner client's account.
func answer(ctx context.Context, conn *net.UnixConn, uid uint32, w Writer, say func(string)) {
var req Request
dec := json.NewDecoder(io.LimitReader(conn, MaxRequest))
dec.DisallowUnknownFields()
if err := dec.Decode(&req); err != nil {
// Answered even when the deadline to say it passed: the deadline covered the write too.
_ = conn.SetWriteDeadline(time.Now().Add(refusedWithin))
reply(conn, Refusal("", "the request could not be read, so nothing was written: "+err.Error()), say)
return
}
// The write waits for the apply's lock as long as an apply takes, and the client waits with it.
_ = conn.SetDeadline(time.Time{})
a := w.Write(ctx, uid, req)
switch {
case a.Refused != "":
say(fmt.Sprintf("a write of %s from user id %d was refused: %s", req.Path, uid, a.Refused))
case a.Failed != "":
say(fmt.Sprintf("a write of %s from user id %d failed: %s", req.Path, uid, a.Failed))
default:
say(fmt.Sprintf("wrote %s for the provisioner's client (user id %d): %s, %s", req.Path, uid, a.Action,
a.Digest))
}
_ = conn.SetWriteDeadline(time.Now().Add(within))
reply(conn, a, say)
}
// busy answers the client's connection over atOnce: its request read and dropped first, briefly, so the client reads
// the answer rather than a broken pipe — while no more than busyWaiting such refusals wait at once; over that, at once.
func busy(conn *net.UnixConn, readBy time.Time, busyWaiting chan struct{}, say func(string)) {
select {
case busyWaiting <- struct{}{}:
by := time.Now().Add(refusedWithin)
if readBy.Before(by) {
by = readBy
}
_ = conn.SetReadDeadline(by)
drain(conn)
<-busyWaiting
default:
}
_ = conn.SetWriteDeadline(time.Now().Add(refusedWithin))
reply(conn, Refusal("", "busy: other writes are being served; ask again"), say)
}
// drain reads and drops a request up to its end — the newline after it, the end of the stream or the deadline —
// and at most MaxRequest bytes, keeping none of it.
func drain(r io.Reader) {
var buf [4096]byte
for read := 0; read < MaxRequest; {
n, err := r.Read(buf[:])
read += n
if bytes.IndexByte(buf[:n], '\n') >= 0 || err != nil {
return
}
}
}
func reply(conn *net.UnixConn, a Answer, say func(string)) {
defer conn.Close()
if err := json.NewEncoder(conn).Encode(a); err != nil {
say("a filled file's answer could not be written back: " + err.Error())
}
}