Files
mesh-controller/internal/link/handover.go
T
jochen 9cef820117
mesh/merge-gate pass: builds build-agent, mesh-controller, route-proxy → ace, g14, novox, shanks; no bus step; every machine composes with the change as it…
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
Ask a node's engine for a fresh setuid search at the terminal (hq issue 361)
After the operator removes by hand what the last search found, no apply says so and nothing searched again
until the next day. node setuid-search <node> signs the ask as a hand-over is, under its own context, on a
subject only the node's engine hears and only the controller may publish.
2026-10-10 01:56:56 +02:00

178 lines
7.6 KiB
Go

package link
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/nats-io/nats.go"
"github.com/novox/mesh-controller/internal/broker"
)
// A hand-over asked of a machine's node-engine (novox/hq issue 356, issue 339).
//
// The operator hands a directory the node-engine uses as found to the mesh at the controller's terminal:
// `nox node hand-over <node> <path>` on the control-node (ADR 0272). The controller asks that machine's
// engine on its own subject, a request on core NATS the engine answers once; the engine judges every
// value and records the hand-over, or refuses and records nothing. The engine holds the same two shapes
// in its own link code (mesh-host internal/link HandOverAsk, HandOverAnswer); a test on each side holds
// the field names.
// HandOverAsk is what the controller asks: the node it is for, the directory's absolute path as the engine states
// it, who asked in the controller's words, when the ask stops being good, and a nonce the engine takes 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 signature.
//
// **Signed, because the subject proves nothing** (review of issue 356). Only the controller may publish
// `mesh.node.<node>.ask.hand-over`, but the bus lets any principal allowed to answer reply to a message it
// received, on whatever reply subject that message named — so a tool server asked on its own subject with that
// reply could hand the engine an ask the controller never made. The engine verifies this signature, with the
// key it verifies declarations with, before it reads anything out of the ask.
type SignedHandOver struct {
Ask []byte `json:"ask"`
Signature []byte `json:"signature"`
}
// HandOverContext is prefixed to an ask's bytes before signing, so a hand-over's signature is never a
// declaration's: the same key signs both, and a declaration is signed over its bytes alone.
const HandOverContext = "novox-mesh hand-over v1\n"
// HandOverGood is how long a signed ask is good for: the engine refuses one past it, and one further ahead.
const HandOverGood = time.Minute
// HandOverAnswer is the engine's answer: what it recorded, or why it refused.
type HandOverAnswer struct {
Said string `json:"said,omitempty"`
Refused string `json:"refused,omitempty"`
}
// HandOverWithin is how long the controller waits for the engine's answer: a file write, on a machine that is
// up; a machine that is down is said as not answering.
const HandOverWithin = 30 * time.Second
// AskHandOver asks one machine's node-engine to hand a directory used as found to the mesh, and reads its
// answer. An error is the ask not reaching an engine, or an answer that is not one; a refusal is the engine's
// and comes back in the answer.
func AskHandOver(ctx context.Context, conn *nats.Conn, signer Signer, node, path, by string,
timeout time.Duration) (HandOverAnswer, error) {
if conn == nil {
return HandOverAnswer{}, errors.New("this controller is not on the bus")
}
body, err := SignHandOver(ctx, signer, HandOverAsk{Node: node, Path: path, By: by})
if err != nil {
return HandOverAnswer{}, err
}
return askSigned(ctx, conn, broker.AskHandOverSubject(node), body, node, timeout, askWords{
what: "a hand-over", nothing: "nothing was handed over", issue: "issue 356",
unknown: "whether it was recorded is not known — the module's condition says whether the directory is " +
"still used as found", record: "a record"})
}
// askWords are what a signed ask's failures say of it.
type askWords struct{ what, nothing, issue, unknown, record string }
// askSigned sends one signed ask on subject and reads the engine's answer.
func askSigned(ctx context.Context, conn *nats.Conn, subject string, body []byte, node string, timeout time.Duration,
w askWords) (HandOverAnswer, error) {
asking, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
refused, stop := refusalsOf(conn, subject)
defer stop()
type replied struct {
msg *nats.Msg
err error
}
done := make(chan replied, 1)
go func() {
msg, err := conn.RequestWithContext(asking, subject, body)
done <- replied{msg, err}
}()
var reply *nats.Msg
var err error
select {
case r := <-done:
reply, err = r.msg, r.err
case why := <-refused:
cancel()
return HandOverAnswer{}, fmt.Errorf("the bus refused the controller asking %s for %s: %v", node, w.what, why)
}
switch {
case errors.Is(err, nats.ErrNoResponders):
return HandOverAnswer{}, fmt.Errorf("nothing on %s answers %s: its node-engine is not running, is not "+
"on the bus, or is older than this ask (novox/hq %s); %s", node, w.what, w.issue, w.nothing)
case errors.Is(err, context.DeadlineExceeded), errors.Is(err, nats.ErrTimeout):
return HandOverAnswer{}, fmt.Errorf("%s did not answer %s within %s; %s", node, w.what, timeout, w.unknown)
case err != nil:
return HandOverAnswer{}, err
}
var answer HandOverAnswer
if err := json.Unmarshal(reply.Data, &answer); err != nil {
return HandOverAnswer{}, fmt.Errorf("%s answered %s with something unreadable: %w", node, w.what, err)
}
if answer.Said == "" && answer.Refused == "" {
return HandOverAnswer{}, fmt.Errorf("%s answered %s with neither %s nor a refusal", node, w.what, w.record)
}
return answer, nil
}
// SetuidSearchContext is prefixed to a setuid search ask's bytes before signing (novox/hq issue 361): never a
// hand-over's signature, nor a declaration's. The engine holds the same words.
const SetuidSearchContext = "novox-mesh setuid-search v1\n"
// AskSetuidSearch asks one machine's node-engine to throw its last search for setuid programs away and start a
// full one (novox/hq issue 361): the ask a hand-over is, naming no path, signed under SetuidSearchContext.
func AskSetuidSearch(ctx context.Context, conn *nats.Conn, signer Signer, node, by string,
timeout time.Duration) (HandOverAnswer, error) {
if conn == nil {
return HandOverAnswer{}, errors.New("this controller is not on the bus")
}
body, err := signAsk(ctx, signer, SetuidSearchContext, HandOverAsk{Node: node, By: by})
if err != nil {
return HandOverAnswer{}, err
}
return askSigned(ctx, conn, broker.AskSetuidSearchSubject(node), body, node, timeout, askWords{
what: "a setuid search", nothing: "no search was started", issue: "issue 361",
unknown: "whether it started is not known — the controller's root-free verb says whether a search runs " +
"there", record: "a start"})
}
// SignHandOver fills the ask's expiry and nonce and signs it with the mesh's key, over HandOverContext and the
// ask's bytes exactly as they travel.
func SignHandOver(ctx context.Context, signer Signer, ask HandOverAsk) ([]byte, error) {
return signAsk(ctx, signer, HandOverContext, ask)
}
// signAsk fills an ask's expiry and nonce and signs it over prefix (its signing context) and its bytes.
func signAsk(ctx context.Context, signer Signer, prefix string, ask HandOverAsk) ([]byte, error) {
if signer == nil {
return nil, errors.New("no signing key, so nothing can be asked of an engine")
}
nonce := make([]byte, 16)
if _, err := rand.Read(nonce); err != nil {
return nil, err
}
ask.Nonce = hex.EncodeToString(nonce)
ask.Expires = time.Now().UTC().Add(HandOverGood)
raw, err := json.Marshal(ask)
if err != nil {
return nil, err
}
signature, err := signer.Sign(ctx, append([]byte(prefix), raw...))
if err != nil {
return nil, fmt.Errorf("cannot sign the ask: %w", err)
}
return json.Marshal(SignedHandOver{Ask: raw, Signature: signature})
}