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.
141 lines
5.6 KiB
Go
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})
|
|
}
|