Files
mesh-host/internal/link/handover.go
T
jochen 8e79403c21
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 fail: its merge-check.sh failed: --- FAIL: TestNatsAnEnrolmentWithNoReplyAddressIsNotAnswered (0.00s)
mesh/delivery superseded: a newer head of the same pull request
Show how far the setuid search got, and start a fresh one when the terminal asks (hq issue 361)
The walk kept its progress every minute and nobody could read it: root-free said only that a search ran.
The account verdict now carries the progress (counts, times, mount points; never a path) as fields and in
its reason, outside what makes a verdict changed. A signed ask from the controller's terminal throws the last
search away and starts a full one, after the operator removed by hand what it found.
2026-10-10 01:55:58 +02:00

265 lines
11 KiB
Go

package link
import (
"crypto/ed25519"
"encoding/hex"
"encoding/json"
"fmt"
"strings"
"sync"
"time"
)
// A hand-over asked over the bus (novox/hq issue 356, issue 339).
//
// A directory used as found is handed to the mesh by its operator, at the controller's terminal:
// `nox node hand-over <node> <path>` on the control-node (novox/hq ADR 0272). The controller asks this
// node's engine on AskHandOverSubject, a request on core NATS, and the engine answers once, on the reply
// the request carried.
//
// **The subject proves nothing; the signature does** (review of issue 356). Only the controller is granted
// a publish on the subject, but the bus lets any principal allowed to answer reply to a message it received,
// on whatever reply subject that message named — a tool server asked on its own subject with this reply
// would deliver a body of its choosing here. So the controller signs the ask with the mesh's key, over
// HandOverContext and the ask's bytes, and the engine verifies it with the key it verifies declarations with
// before it reads anything out of it; then it holds the ask to this node, to its expiry, and to a nonce it
// takes once. Then it judges every value and records the hand-over, or refuses and records nothing; the next
// apply gives the directory its declared owner and mode. The controller holds the same shapes in its own link
// code, with a test on each side holding the field names.
// AskHandOverSubject is where the controller asks this node's engine to take a directory used as found as
// the mesh's.
func AskHandOverSubject(node string) string { return "mesh.node." + node + ".ask.hand-over" }
// HandOverAsk is one hand-over asked: the node it is for, the directory's absolute path as the engine states
// it, who asked in the controller's words, when it stops being good, and a nonce taken once.
type HandOverAsk struct {
Node string `json:"node"`
Path string `json:"path"`
By string `json:"by"`
Expires time.Time `json:"expires"`
Nonce string `json:"nonce"`
}
// SignedHandOver is the ask as it travels: its bytes exactly as signed, and the mesh's signature.
type SignedHandOver struct {
Ask []byte `json:"ask"`
Signature []byte `json:"signature"`
}
// HandOverContext is prefixed to an ask's bytes before signing: a hand-over's signature is never a
// declaration's, which the same key makes over a declaration's bytes alone.
const HandOverContext = "novox-mesh hand-over v1\n"
// HandOverGood is how long the controller makes an ask good for; HandOverSkew is how far the machines' clocks
// may differ. An ask whose expiry is further ahead than both together is refused: it would be good too long.
const (
HandOverGood = time.Minute
HandOverSkew = time.Minute
)
// HandOverAnswer is the engine's answer: what it recorded, or why it refused — never both, never neither.
type HandOverAnswer struct {
Said string `json:"said,omitempty"`
Refused string `json:"refused,omitempty"`
}
// HandOverFunc judges and records one hand-over: the words to answer with, or the refusal.
type HandOverFunc func(path, by string) (string, error)
// MaxHandOverAsk bounds a request: a path, a name, a time, a nonce and a signature are hundreds of bytes.
const MaxHandOverAsk = 8 << 10
// MaxHandOverBy bounds who asked.
const MaxHandOverBy = 200
// HandOverCheck is what an ask is held to: the mesh's key, this node's name, the clock, and the nonces taken.
type HandOverCheck struct {
Signer ed25519.PublicKey
Node string
Now func() time.Time
Nonces *Nonces
}
// Nonces are the nonces of asks taken, each kept until its ask expires: an ask is taken once. Kept in memory —
// an ask outlives no restart by more than its minute, and taking one again records the same hand-over again.
type Nonces struct {
mu sync.Mutex
seen map[string]time.Time
}
// take records a nonce until its expiry, and says false when it was already taken.
func (n *Nonces) take(nonce string, expires, now time.Time) bool {
n.mu.Lock()
defer n.mu.Unlock()
if n.seen == nil {
n.seen = map[string]time.Time{}
}
for k, until := range n.seen {
if now.After(until) {
delete(n.seen, k)
}
}
if _, taken := n.seen[nonce]; taken {
return false
}
n.seen[nonce] = expires
return true
}
// HandOverNonces are this process's: every link it opens takes from the same set, so a reconnect is no replay.
var HandOverNonces = &Nonces{}
func refusedAsk(why string) HandOverAnswer {
return HandOverAnswer{Refused: why + "; nothing was handed over"}
}
// AnswerHandOver is the engine's judgement of one ask's bytes: the signature first, then this node, the expiry
// and the nonce, then every value — and only then the handler, given only what passed. A refusal records nothing.
func AnswerHandOver(check HandOverCheck, handle HandOverFunc, body []byte) HandOverAnswer {
if handle == nil {
return refusedAsk("this engine takes no hand-over")
}
ask, refused := believed(check, HandOverContext, true, body)
if refused != "" {
return refusedAsk(refused)
}
said, err := handle(ask.Path, ask.By)
if err != nil {
return HandOverAnswer{Refused: err.Error()}
}
return HandOverAnswer{Said: said}
}
// believed is an ask's bytes held to what every signed ask is: signed by the mesh over context and its bytes,
// for this node, unexpired and good no longer than an ask may be, with a nonce of its own taken once here, and
// one short line naming who asked, and a path when withPath and none otherwise. The nonce is taken last, after
// every other check passed. It answers the ask with who asked trimmed, or why it is refused.
func believed(check HandOverCheck, context string, withPath bool, body []byte) (HandOverAsk, string) {
if len(body) > MaxHandOverAsk {
return HandOverAsk{}, fmt.Sprintf("the ask is %d bytes, more than an ask", len(body))
}
var signed SignedHandOver
if err := json.Unmarshal(body, &signed); err != nil {
return HandOverAsk{}, "the ask is not a signed ask: " + err.Error()
}
if len(check.Signer) != ed25519.PublicKeySize ||
!ed25519.Verify(check.Signer, append([]byte(context), signed.Ask...), signed.Signature) {
return HandOverAsk{}, "the ask is not signed by the mesh"
}
var ask HandOverAsk
if err := json.Unmarshal(signed.Ask, &ask); err != nil {
return HandOverAsk{}, "the signed ask is not an ask: " + err.Error()
}
now := time.Now()
if check.Now != nil {
now = check.Now()
}
nonces := check.Nonces
if nonces == nil {
nonces = HandOverNonces
}
ask.By = strings.TrimSpace(ask.By)
nonce, nonceErr := hex.DecodeString(ask.Nonce)
switch {
case check.Node == "" || ask.Node != check.Node:
return HandOverAsk{}, fmt.Sprintf("the ask is for %q, and this is %q", ask.Node, check.Node)
case ask.Expires.IsZero() || !now.Before(ask.Expires):
return HandOverAsk{}, "the ask has expired"
case ask.Expires.After(now.Add(HandOverGood + HandOverSkew)):
return HandOverAsk{}, "the ask is good for longer than an ask may be"
case nonceErr != nil || len(nonce) < 16:
return HandOverAsk{}, "the ask carries no nonce of its own"
case ask.By == "":
return HandOverAsk{}, "the ask names nobody as asking"
case len(ask.By) > MaxHandOverBy || strings.ContainsAny(ask.By, "\r\n\x00"):
return HandOverAsk{}, "who asked is not one short line"
case withPath && ask.Path == "":
return HandOverAsk{}, "the ask names no directory"
case !withPath && ask.Path != "":
return HandOverAsk{}, "the ask names a path, and this ask takes none"
case !nonces.take(ask.Nonce, ask.Expires, now):
return HandOverAsk{}, "the ask was taken already"
}
return ask, ""
}
// HandOverReply is what a hand-over request is answered with, and whether to answer: a request with no reply
// subject is nobody's question, so nothing is judged, recorded or said.
func HandOverReply(check HandOverCheck, handle HandOverFunc, reply string, body []byte) ([]byte, bool) {
if reply == "" {
return nil, false
}
answer, err := json.Marshal(AnswerHandOver(check, handle, body))
if err != nil {
return nil, false
}
return answer, true
}
// HandOvers is a link that hears the controller asking this node to hand a directory over. Optional, as
// Asked is.
type HandOvers interface {
HearHandOvers(HandOverFunc)
}
// A search for setuid programs asked over the bus (novox/hq issue 361).
//
// The operator who changed by hand what the last search found — removed a setuid-root copy no package owns —
// asks for a fresh one at the controller's terminal: `nox node setuid-search <node>` on the control-node. The
// controller asks this node's engine on AskSetuidSearchSubject, signed as a hand-over is and held to the same
// checks (believed), under a signing context of its own so neither ask's signature is the other's. The engine
// throws away the last search and starts a full one; it never names a path in its answer.
//
// **At the terminal alone, never a verb** (decided in issue 361): a search can never make a machine free
// wrongly — it judges only once complete — but one asked again and again would keep the machine unjudged and
// its disks busy, and whoever may call a verb includes agents.
// AskSetuidSearchSubject is where the controller asks this node's engine for a fresh search.
func AskSetuidSearchSubject(node string) string { return "mesh.node." + node + ".ask.setuid-search" }
// SetuidSearchContext is prefixed to a search ask's bytes before signing: never a hand-over's, nor a
// declaration's.
const SetuidSearchContext = "novox-mesh setuid-search v1\n"
// SetuidSearchFunc throws away the last search and starts a full one, asked by by: the words to answer with.
type SetuidSearchFunc func(by string) (string, error)
func refusedSearch(why string) HandOverAnswer {
return HandOverAnswer{Refused: why + "; no search was started"}
}
// AnswerSetuidSearch is the engine's judgement of one search ask's bytes: believed as a hand-over is, a path
// refused, and only then the handler.
func AnswerSetuidSearch(check HandOverCheck, handle SetuidSearchFunc, body []byte) HandOverAnswer {
if handle == nil {
return refusedSearch("this engine searches for setuid programs on no ask")
}
ask, refused := believed(check, SetuidSearchContext, false, body)
if refused != "" {
return refusedSearch(refused)
}
said, err := handle(ask.By)
if err != nil {
return refusedSearch(err.Error())
}
return HandOverAnswer{Said: said}
}
// SetuidSearchReply is what a search request is answered with, and whether to answer, as HandOverReply.
func SetuidSearchReply(check HandOverCheck, handle SetuidSearchFunc, reply string, body []byte) ([]byte, bool) {
if reply == "" {
return nil, false
}
answer, err := json.Marshal(AnswerSetuidSearch(check, handle, body))
if err != nil {
return nil, false
}
return answer, true
}
// SetuidSearches is a link that hears the controller asking this node for a fresh search. Optional.
type SetuidSearches interface {
HearSetuidSearches(SetuidSearchFunc)
}