mesh/merge-gate head 5a7ec3775b98 merged into trunk 53fd32618916 beside mesh-controller#346@c711e622 — pass: builds mesh-host → ace, g14, novox, shanks; no…
mesh/repo-check head 5a7ec3775b98 merged into trunk 53fd32618916 beside mesh-controller#346@c711e622 — pass: [release feat/57-config-and-provisioning, judg…
mesh/delivery stopped: its walk plan-1792114347281764422 was stopped by a person (mesh-controller): mesh-host#114 merged into the release branch feat/57-…
mesh/delivery-group group 57-config-and-provisioning stopped: a member was stopped
The node-engine renders the feature's templates, collections and moved providers' files, on the SDK the feature's modules build on (feature 57, config and provisioning).
210 lines
9.3 KiB
Go
210 lines
9.3 KiB
Go
package link
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// The move of a secret into the vault, asked over the bus (design 57's V3, issue 676; the operator's decision of
|
|
// 2026-10-14 on that issue).
|
|
//
|
|
// **Only the machine a value was sealed to can open it**, so the controller, which cannot, asks each machine's
|
|
// engine to move what was sealed to it: one row of its store at a time, on AskMoveSecretSubject, signed as a
|
|
// hand-over is and held to the same checks (believed), under a signing context of its own, naming the row — a
|
|
// module's own secret on this machine, or a pair credential with this machine at one end — its origin, the
|
|
// controller's generation, when it was made, and the value as the store holds it, sealed to this machine. The
|
|
// engine opens it with its sealing key, hands it to the vault's `move-in` in the vault's two steps, and answers
|
|
// what the vault kept only once the vault's read-back opens to the same value. Nothing is rotated; the engine
|
|
// writes nothing on this machine. The controller holds the same shapes in its own link code, with a test on each
|
|
// side holding the field names.
|
|
|
|
// AskMoveSecretSubject is where the controller asks this node's engine to move one value sealed to it into the vault.
|
|
func AskMoveSecretSubject(node string) string { return "mesh.node." + node + ".ask.move-secret" }
|
|
|
|
// MoveSecretContext is prefixed to a move ask's bytes before signing: never another ask's, nor a declaration's.
|
|
const MoveSecretContext = "novox-mesh move-secret v1\n"
|
|
|
|
// MoveSecretAsk is a move's own fields, beside a hand-over's (node, by, expires, nonce; no path): the row — module
|
|
// and name for a module's own secret on the ask's node; provider, provision, consumer, consumer module and local
|
|
// name for a pair credential — where its value came from, the controller's generation, when it was made, the value
|
|
// sealed to this machine as the store holds it, and the sealing key the store says it was sealed to.
|
|
type MoveSecretAsk struct {
|
|
Module string `json:"module,omitempty"`
|
|
Name string `json:"name,omitempty"`
|
|
Provider string `json:"provider,omitempty"`
|
|
Provision string `json:"provision,omitempty"`
|
|
Consumer string `json:"consumer,omitempty"`
|
|
ConsumerModule string `json:"consumer_module,omitempty"`
|
|
Local string `json:"local,omitempty"`
|
|
Origin string `json:"origin"`
|
|
Generation string `json:"generation,omitempty"`
|
|
MadeAt time.Time `json:"made_at"`
|
|
Sealed string `json:"sealed"`
|
|
SealedTo string `json:"sealed_to,omitempty"`
|
|
}
|
|
|
|
// MoveSecretAnswer is the engine's answer: what the vault kept, or why nothing was moved — never both.
|
|
type MoveSecretAnswer struct {
|
|
Said string `json:"said,omitempty"`
|
|
Refused string `json:"refused,omitempty"`
|
|
Secret string `json:"secret,omitempty"`
|
|
Generation int `json:"generation,omitempty"`
|
|
Unchanged bool `json:"unchanged,omitempty"`
|
|
}
|
|
|
|
// MoveSecret is one move asked and believed, as the handler is given it: the row, this machine, and who asked.
|
|
type MoveSecret struct {
|
|
MoveSecretAsk
|
|
Node, By string
|
|
}
|
|
|
|
// Own says the row is a module's own secret rather than a pair credential.
|
|
func (m MoveSecret) Own() bool { return m.Module != "" || m.Name != "" }
|
|
|
|
// MovedSecret is what the vault kept: its name, its generation, and whether it held the value already.
|
|
type MovedSecret struct {
|
|
Secret string
|
|
Generation int
|
|
Unchanged bool
|
|
}
|
|
|
|
// MoveSecretFunc moves one value into the vault: what it kept, or why not.
|
|
type MoveSecretFunc func(MoveSecret) (MovedSecret, error)
|
|
|
|
// MaxMoveSecretAsk bounds a move's request: a row's names, a time, a value sealed and a signature.
|
|
const MaxMoveSecretAsk = 64 << 10
|
|
|
|
// movedOrigins are the origins the controller's store holds.
|
|
var movedOrigins = map[string]bool{"made": true, "accepted": true, "issued": true}
|
|
|
|
func refusedMove(why string) MoveSecretAnswer {
|
|
return MoveSecretAnswer{Refused: why + "; nothing was moved"}
|
|
}
|
|
|
|
// oneName is a short name of one line.
|
|
func oneName(s string) bool {
|
|
return s != "" && len(s) <= MaxActField && !strings.ContainsAny(s, "\r\n\x00/ ")
|
|
}
|
|
|
|
// AnswerMoveSecret is the engine's judgement of one move ask's bytes: believed as a hand-over is, then the row —
|
|
// one shape, this machine's, a value to open and an origin — and only then the handler.
|
|
func AnswerMoveSecret(check HandOverCheck, handle MoveSecretFunc, body []byte) MoveSecretAnswer {
|
|
if handle == nil {
|
|
return refusedMove("this engine moves no secret into the vault")
|
|
}
|
|
ask, signed, refused := believedBytesWithin(check, MoveSecretContext, false, body, MaxMoveSecretAsk)
|
|
if refused != "" {
|
|
return refusedMove(refused)
|
|
}
|
|
var move MoveSecretAsk
|
|
if err := json.Unmarshal(signed, &move); err != nil {
|
|
return refusedMove("the signed ask is not a move's ask: " + err.Error())
|
|
}
|
|
own := move.Module != "" || move.Name != ""
|
|
pair := move.Provider != "" || move.Provision != "" || move.Consumer != "" || move.ConsumerModule != "" ||
|
|
move.Local != ""
|
|
switch {
|
|
case own == pair:
|
|
return refusedMove("the ask names neither one module's own secret nor one pair credential")
|
|
case own && (!oneName(move.Module) || !oneName(move.Name)):
|
|
return refusedMove("the ask's module and name are not short names")
|
|
case pair && (!oneName(move.Provider) || !oneName(move.Provision) || !oneName(move.Consumer) ||
|
|
!oneName(move.ConsumerModule) || (move.Local != "" && !oneName(move.Local))):
|
|
return refusedMove("the ask's provider, provision, consumer, consuming module and local name are not short names")
|
|
case pair && move.Provider != ask.Node && move.Consumer != ask.Node:
|
|
return refusedMove(fmt.Sprintf("the pair is between %s and %s, and this is %s", move.Provider, move.Consumer,
|
|
ask.Node))
|
|
case !movedOrigins[move.Origin]:
|
|
return refusedMove(fmt.Sprintf("the origin %q is not made, accepted or issued", move.Origin))
|
|
case strings.TrimSpace(move.Sealed) == "":
|
|
return refusedMove("the ask carries no value sealed to this machine")
|
|
case len(move.Generation) > MaxActField || strings.ContainsAny(move.Generation, "\r\n\x00"):
|
|
return refusedMove("the ask's generation is not one short line")
|
|
}
|
|
moved, err := handle(MoveSecret{MoveSecretAsk: move, Node: ask.Node, By: ask.By})
|
|
if err != nil {
|
|
return refusedMove(err.Error())
|
|
}
|
|
said := fmt.Sprintf("%s is in the vault at generation %d, read back the same", moved.Secret, moved.Generation)
|
|
if moved.Unchanged {
|
|
said = fmt.Sprintf("the vault held %s already, the same value (generation %d)", moved.Secret, moved.Generation)
|
|
}
|
|
return MoveSecretAnswer{Said: said, Secret: moved.Secret, Generation: moved.Generation, Unchanged: moved.Unchanged}
|
|
}
|
|
|
|
// MoveSecretReply is what a move request is answered with, and whether to answer, as HandOverReply.
|
|
func MoveSecretReply(check HandOverCheck, handle MoveSecretFunc, reply string, body []byte) ([]byte, bool) {
|
|
if reply == "" {
|
|
return nil, false
|
|
}
|
|
answer, err := json.Marshal(AnswerMoveSecret(check, handle, body))
|
|
if err != nil {
|
|
return nil, false
|
|
}
|
|
return answer, true
|
|
}
|
|
|
|
// MoveSecrets is a link that hears the controller asking this node to move a value into the vault. Optional.
|
|
type MoveSecrets interface {
|
|
HearMoveSecrets(MoveSecretFunc)
|
|
}
|
|
|
|
// ---- asking the vault ------------------------------------------------------------------------------------------
|
|
|
|
// VaultSeat is the vault's seat; its verbs are asked on `mesh.seat.<seat>.call.<verb>.<caller>`, the subject that
|
|
// names its caller (novox/hq issue 365), which the bus lets only that user publish.
|
|
const VaultSeat = "mesh-vault"
|
|
|
|
// NodeEngineUser is the bus user of a machine's node-engine, as the controller's broker names it.
|
|
func NodeEngineUser(node string) string { return "node." + node }
|
|
|
|
// CalledVaultSubject is where this node's engine asks one of the vault's verbs in its own name: each dot of its
|
|
// user's name written `~`, as the controller's broker writes a caller's token.
|
|
func CalledVaultSubject(verb, node string) string {
|
|
return "mesh.seat." + VaultSeat + ".call." + verb + "." + strings.ReplaceAll(NodeEngineUser(node), ".", "~")
|
|
}
|
|
|
|
// VaultWithin is how long the engine gives the vault to answer one step.
|
|
const VaultWithin = 15 * time.Second
|
|
|
|
// AskVault asks one of the vault's verbs in this node's engine's own name over the link open now, and answers its
|
|
// result, or its refusal as an error.
|
|
func (q *Queue) AskVault(ctx context.Context, verb string, args any) (json.RawMessage, error) {
|
|
q.init()
|
|
q.mu.Lock()
|
|
bus := q.bus
|
|
q.mu.Unlock()
|
|
if bus == nil {
|
|
return nil, fmt.Errorf("%w: this machine has no link to the bus open now", ErrNotAsked)
|
|
}
|
|
asker, ok := bus.(ToolBus)
|
|
if !ok {
|
|
return nil, fmt.Errorf("%w: the link to the bus open now cannot ask the vault", ErrNotAsked)
|
|
}
|
|
body, err := json.Marshal(args)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
asking, cancel := context.WithTimeout(ctx, VaultWithin)
|
|
defer cancel()
|
|
reply, err := asker.Ask(asking, CalledVaultSubject(verb, q.Membership.Node), body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("the vault's %s could not be asked: %w", verb, err)
|
|
}
|
|
var env struct {
|
|
Result json.RawMessage `json:"result"`
|
|
Error string `json:"error"`
|
|
}
|
|
if err := json.Unmarshal(reply, &env); err != nil {
|
|
return nil, fmt.Errorf("the vault answered %s with something that is not a tool's answer", verb)
|
|
}
|
|
if env.Error != "" {
|
|
return nil, errors.New(env.Error)
|
|
}
|
|
return env.Result, nil
|
|
}
|