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 ` 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..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}) }