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
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>
513 lines
19 KiB
Go
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())
|
|
}
|
|
}
|