Files
mesh-controller/internal/link/handover.go
T
jochen b55a38ca9f Sign the hand-over ask, and fail the line on the engine's refusal (hq issue 356, review)
The subject proved nothing: the bus lets any principal allowed to answer reply to a message it received on the reply subject that message named, so a tool server — the operator's account, every agent — could deliver a hand-over to an engine. The controller now signs the ask with the mesh's key over a fixed context (node, path, who asked, a minute's expiry, a fresh nonce), as declarations are signed, and the engine verifies it. The writers table gains the row for mesh.node.*.ask.hand-over; the subject's comment no longer claims who the engine hears. The line's known-node check and the refusal branch are tested; every check was removed in turn and a test failed.
2026-10-09 19:44:55 +02:00

141 lines
5.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
}
asking, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
subject := broker.AskHandOverSubject(node)
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
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 a hand-over: %v", node, why)
}
switch {
case errors.Is(err, nats.ErrNoResponders):
return HandOverAnswer{}, fmt.Errorf("nothing on %s answers a hand-over: its node-engine is not running, is not "+
"on the bus, or is older than this ask (novox/hq issue 356); nothing was handed over", node)
case errors.Is(err, context.DeadlineExceeded), errors.Is(err, nats.ErrTimeout):
return HandOverAnswer{}, fmt.Errorf("%s did not answer the hand-over within %s; whether it was recorded is not "+
"known — the module's condition says whether the directory is still used as found", node, timeout)
case err != nil:
return HandOverAnswer{}, err
}
var answer HandOverAnswer
if err := json.Unmarshal(reply.Data, &answer); err != nil {
return HandOverAnswer{}, fmt.Errorf("%s answered the hand-over with something unreadable: %w", node, err)
}
if answer.Said == "" && answer.Refused == "" {
return HandOverAnswer{}, fmt.Errorf("%s answered the hand-over with neither a record nor a refusal", node)
}
return answer, nil
}
// 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) {
if signer == nil {
return nil, errors.New("no signing key, so no hand-over can be asked")
}
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(HandOverContext), raw...))
if err != nil {
return nil, fmt.Errorf("cannot sign the hand-over: %w", err)
}
return json.Marshal(SignedHandOver{Ask: raw, Signature: signature})
}