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).
437 lines
19 KiB
Go
437 lines
19 KiB
Go
package link
|
|
|
|
import (
|
|
"crypto/ed25519"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// A hand-over asked over the bus (novox/hq issue 356, issue 339).
|
|
//
|
|
// A directory used as found is handed to the mesh by its operator, at the controller's terminal:
|
|
// `nox node hand-over <node> <path>` on the control-node (novox/hq ADR 0272). The controller asks this
|
|
// node's engine on AskHandOverSubject, a request on core NATS, and the engine answers once, on the reply
|
|
// the request carried.
|
|
//
|
|
// **The subject proves nothing; the signature does** (review of issue 356). Only the controller is granted
|
|
// a publish on the subject, but the bus lets any principal allowed to answer reply to a message it received,
|
|
// on whatever reply subject that message named — a tool server asked on its own subject with this reply
|
|
// would deliver a body of its choosing here. So the controller signs the ask with the mesh's key, over
|
|
// HandOverContext and the ask's bytes, and the engine verifies it with the key it verifies declarations with
|
|
// before it reads anything out of it; then it holds the ask to this node, to its expiry, and to a nonce it
|
|
// takes once. Then it judges every value and records the hand-over, or refuses and records nothing; the next
|
|
// apply gives the directory its declared owner and mode. The controller holds the same shapes in its own link
|
|
// code, with a test on each side holding the field names.
|
|
|
|
// AskHandOverSubject is where the controller asks this node's engine to take a directory used as found as
|
|
// the mesh's.
|
|
func AskHandOverSubject(node string) string { return "mesh.node." + node + ".ask.hand-over" }
|
|
|
|
// HandOverAsk is one hand-over asked: the node it is for, the directory's absolute path as the engine states
|
|
// it, who asked in the controller's words, when it stops being good, and a nonce taken 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 mesh's signature.
|
|
type SignedHandOver struct {
|
|
Ask []byte `json:"ask"`
|
|
Signature []byte `json:"signature"`
|
|
}
|
|
|
|
// HandOverContext is prefixed to an ask's bytes before signing: a hand-over's signature is never a
|
|
// declaration's, which the same key makes over a declaration's bytes alone.
|
|
const HandOverContext = "novox-mesh hand-over v1\n"
|
|
|
|
// HandOverGood is how long the controller makes an ask good for; HandOverSkew is how far the machines' clocks
|
|
// may differ. An ask whose expiry is further ahead than both together is refused: it would be good too long.
|
|
const (
|
|
HandOverGood = time.Minute
|
|
HandOverSkew = time.Minute
|
|
)
|
|
|
|
// HandOverAnswer is the engine's answer: what it recorded, or why it refused — never both, never neither.
|
|
type HandOverAnswer struct {
|
|
Said string `json:"said,omitempty"`
|
|
Refused string `json:"refused,omitempty"`
|
|
}
|
|
|
|
// HandOverFunc judges and records one hand-over: the words to answer with, or the refusal.
|
|
type HandOverFunc func(path, by string) (string, error)
|
|
|
|
// MaxHandOverAsk bounds a request: a path, a name, a time, a nonce and a signature are hundreds of bytes.
|
|
const MaxHandOverAsk = 8 << 10
|
|
|
|
// MaxHandOverBy bounds who asked.
|
|
const MaxHandOverBy = 200
|
|
|
|
// HandOverCheck is what an ask is held to: the mesh's key, this node's name, the clock, and the nonces taken.
|
|
type HandOverCheck struct {
|
|
Signer ed25519.PublicKey
|
|
Node string
|
|
Now func() time.Time
|
|
Nonces *Nonces
|
|
}
|
|
|
|
// Nonces are the nonces of asks taken, each kept until its ask expires: an ask is taken once. Kept in memory —
|
|
// an ask outlives no restart by more than its minute, and taking one again records the same hand-over again.
|
|
type Nonces struct {
|
|
mu sync.Mutex
|
|
seen map[string]time.Time
|
|
}
|
|
|
|
// take records a nonce until its expiry, and says false when it was already taken.
|
|
func (n *Nonces) take(nonce string, expires, now time.Time) bool {
|
|
n.mu.Lock()
|
|
defer n.mu.Unlock()
|
|
if n.seen == nil {
|
|
n.seen = map[string]time.Time{}
|
|
}
|
|
for k, until := range n.seen {
|
|
if now.After(until) {
|
|
delete(n.seen, k)
|
|
}
|
|
}
|
|
if _, taken := n.seen[nonce]; taken {
|
|
return false
|
|
}
|
|
n.seen[nonce] = expires
|
|
return true
|
|
}
|
|
|
|
// HandOverNonces are this process's: every link it opens takes from the same set, so a reconnect is no replay.
|
|
var HandOverNonces = &Nonces{}
|
|
|
|
func refusedAsk(why string) HandOverAnswer {
|
|
return HandOverAnswer{Refused: why + "; nothing was handed over"}
|
|
}
|
|
|
|
// AnswerHandOver is the engine's judgement of one ask's bytes: the signature first, then this node, the expiry
|
|
// and the nonce, then every value — and only then the handler, given only what passed. A refusal records nothing.
|
|
func AnswerHandOver(check HandOverCheck, handle HandOverFunc, body []byte) HandOverAnswer {
|
|
if handle == nil {
|
|
return refusedAsk("this engine takes no hand-over")
|
|
}
|
|
ask, refused := believed(check, HandOverContext, true, body)
|
|
if refused != "" {
|
|
return refusedAsk(refused)
|
|
}
|
|
said, err := handle(ask.Path, ask.By)
|
|
if err != nil {
|
|
return HandOverAnswer{Refused: err.Error()}
|
|
}
|
|
return HandOverAnswer{Said: said}
|
|
}
|
|
|
|
// believed is an ask's bytes held to what every signed ask is: signed by the mesh over context and its bytes,
|
|
// for this node, unexpired and good no longer than an ask may be, with a nonce of its own taken once here, and
|
|
// one short line naming who asked, and a path when withPath and none otherwise. The nonce is taken last, after
|
|
// every other check passed. It answers the ask with who asked trimmed, or why it is refused.
|
|
func believed(check HandOverCheck, context string, withPath bool, body []byte) (HandOverAsk, string) {
|
|
ask, _, refused := believedBytes(check, context, withPath, body)
|
|
return ask, refused
|
|
}
|
|
|
|
// believedBytes is believed with the ask's bytes as signed, for an ask that carries more than a hand-over's
|
|
// fields: read out of them only once they are believed.
|
|
func believedBytes(check HandOverCheck, context string, withPath bool, body []byte) (HandOverAsk, []byte, string) {
|
|
return believedBytesWithin(check, context, withPath, body, MaxHandOverAsk)
|
|
}
|
|
|
|
// believedBytesWithin is believedBytes for an ask bounded at max bytes: a move carries a sealed value, more than
|
|
// a hand-over's few hundred bytes (movesecret.go).
|
|
func believedBytesWithin(check HandOverCheck, context string, withPath bool, body []byte, max int) (HandOverAsk,
|
|
[]byte, string) {
|
|
if len(body) > max {
|
|
return HandOverAsk{}, nil, fmt.Sprintf("the ask is %d bytes, more than an ask", len(body))
|
|
}
|
|
var signed SignedHandOver
|
|
if err := json.Unmarshal(body, &signed); err != nil {
|
|
return HandOverAsk{}, nil, "the ask is not a signed ask: " + err.Error()
|
|
}
|
|
if len(check.Signer) != ed25519.PublicKeySize ||
|
|
!ed25519.Verify(check.Signer, append([]byte(context), signed.Ask...), signed.Signature) {
|
|
return HandOverAsk{}, nil, "the ask is not signed by the mesh"
|
|
}
|
|
var ask HandOverAsk
|
|
if err := json.Unmarshal(signed.Ask, &ask); err != nil {
|
|
return HandOverAsk{}, nil, "the signed ask is not an ask: " + err.Error()
|
|
}
|
|
now := time.Now()
|
|
if check.Now != nil {
|
|
now = check.Now()
|
|
}
|
|
nonces := check.Nonces
|
|
if nonces == nil {
|
|
nonces = HandOverNonces
|
|
}
|
|
ask.By = strings.TrimSpace(ask.By)
|
|
nonce, nonceErr := hex.DecodeString(ask.Nonce)
|
|
switch {
|
|
case check.Node == "" || ask.Node != check.Node:
|
|
return HandOverAsk{}, nil, fmt.Sprintf("the ask is for %q, and this is %q", ask.Node, check.Node)
|
|
case ask.Expires.IsZero() || !now.Before(ask.Expires):
|
|
return HandOverAsk{}, nil, "the ask has expired"
|
|
case ask.Expires.After(now.Add(HandOverGood + HandOverSkew)):
|
|
return HandOverAsk{}, nil, "the ask is good for longer than an ask may be"
|
|
case nonceErr != nil || len(nonce) < 16:
|
|
return HandOverAsk{}, nil, "the ask carries no nonce of its own"
|
|
case ask.By == "":
|
|
return HandOverAsk{}, nil, "the ask names nobody as asking"
|
|
case len(ask.By) > MaxHandOverBy || strings.ContainsAny(ask.By, "\r\n\x00"):
|
|
return HandOverAsk{}, nil, "who asked is not one short line"
|
|
case withPath && ask.Path == "":
|
|
return HandOverAsk{}, nil, "the ask names no directory"
|
|
case !withPath && ask.Path != "":
|
|
return HandOverAsk{}, nil, "the ask names a path, and this ask takes none"
|
|
case !nonces.take(ask.Nonce, ask.Expires, now):
|
|
return HandOverAsk{}, nil, "the ask was taken already"
|
|
}
|
|
return ask, signed.Ask, ""
|
|
}
|
|
|
|
// HandOverReply is what a hand-over request is answered with, and whether to answer: a request with no reply
|
|
// subject is nobody's question, so nothing is judged, recorded or said.
|
|
func HandOverReply(check HandOverCheck, handle HandOverFunc, reply string, body []byte) ([]byte, bool) {
|
|
if reply == "" {
|
|
return nil, false
|
|
}
|
|
answer, err := json.Marshal(AnswerHandOver(check, handle, body))
|
|
if err != nil {
|
|
return nil, false
|
|
}
|
|
return answer, true
|
|
}
|
|
|
|
// HandOvers is a link that hears the controller asking this node to hand a directory over. Optional, as
|
|
// Asked is.
|
|
type HandOvers interface {
|
|
HearHandOvers(HandOverFunc)
|
|
}
|
|
|
|
// A search for setuid programs asked over the bus (novox/hq issue 361).
|
|
//
|
|
// The operator who changed by hand what the last search found — removed a setuid-root copy no package owns —
|
|
// asks for a fresh one at the controller's terminal: `nox node setuid-search <node>` on the control-node. The
|
|
// controller asks this node's engine on AskSetuidSearchSubject, signed as a hand-over is and held to the same
|
|
// checks (believed), under a signing context of its own so neither ask's signature is the other's. The engine
|
|
// throws away the last search and starts a full one; it never names a path in its answer.
|
|
//
|
|
// **At the terminal alone, never a verb** (decided in issue 361): a search can never make a machine free
|
|
// wrongly — it judges only once complete — but one asked again and again would keep the machine unjudged and
|
|
// its disks busy, and whoever may call a verb includes agents.
|
|
|
|
// AskSetuidSearchSubject is where the controller asks this node's engine for a fresh search.
|
|
func AskSetuidSearchSubject(node string) string { return "mesh.node." + node + ".ask.setuid-search" }
|
|
|
|
// SetuidSearchContext is prefixed to a search ask's bytes before signing: never a hand-over's, nor a
|
|
// declaration's.
|
|
const SetuidSearchContext = "novox-mesh setuid-search v1\n"
|
|
|
|
// SetuidSearchFunc throws away the last search and starts a full one, asked by by: the words to answer with.
|
|
type SetuidSearchFunc func(by string) (string, error)
|
|
|
|
func refusedSearch(why string) HandOverAnswer {
|
|
return HandOverAnswer{Refused: why + "; no search was started"}
|
|
}
|
|
|
|
// AnswerSetuidSearch is the engine's judgement of one search ask's bytes: believed as a hand-over is, a path
|
|
// refused, and only then the handler.
|
|
func AnswerSetuidSearch(check HandOverCheck, handle SetuidSearchFunc, body []byte) HandOverAnswer {
|
|
if handle == nil {
|
|
return refusedSearch("this engine searches for setuid programs on no ask")
|
|
}
|
|
ask, refused := believed(check, SetuidSearchContext, false, body)
|
|
if refused != "" {
|
|
return refusedSearch(refused)
|
|
}
|
|
said, err := handle(ask.By)
|
|
if err != nil {
|
|
return refusedSearch(err.Error())
|
|
}
|
|
return HandOverAnswer{Said: said}
|
|
}
|
|
|
|
// SetuidSearchReply is what a search request is answered with, and whether to answer, as HandOverReply.
|
|
func SetuidSearchReply(check HandOverCheck, handle SetuidSearchFunc, reply string, body []byte) ([]byte, bool) {
|
|
if reply == "" {
|
|
return nil, false
|
|
}
|
|
answer, err := json.Marshal(AnswerSetuidSearch(check, handle, body))
|
|
if err != nil {
|
|
return nil, false
|
|
}
|
|
return answer, true
|
|
}
|
|
|
|
// SetuidSearches is a link that hears the controller asking this node for a fresh search. Optional.
|
|
type SetuidSearches interface {
|
|
HearSetuidSearches(SetuidSearchFunc)
|
|
}
|
|
|
|
// A judgement of this node's directories asked over the bus (novox/hq issue 358).
|
|
//
|
|
// Before the node-engine gives a mesh directory its mode and owner only through a walk that follows no link
|
|
// another account could make, the operator is told which directories that walk would refuse on each node. The
|
|
// controller asks this node's engine on AskJudgeDirectoriesSubject, signed as a hand-over is and held to the
|
|
// same checks (believed), under a signing context of its own. The engine judges every directory of the
|
|
// declaration it keeps, read-only — nothing is made, chmodded or chowned — and answers one line per directory.
|
|
|
|
// AskJudgeDirectoriesSubject is where the controller asks this node's engine to judge its directories.
|
|
func AskJudgeDirectoriesSubject(node string) string {
|
|
return "mesh.node." + node + ".ask.judge-directories"
|
|
}
|
|
|
|
// JudgeDirectoriesContext is prefixed to a judgement ask's bytes before signing: never another ask's, nor a
|
|
// declaration's.
|
|
const JudgeDirectoriesContext = "novox-mesh judge-directories v1\n"
|
|
|
|
// JudgeDirectoriesFunc judges this node's declared directories, read-only, asked by by: the words to answer with.
|
|
type JudgeDirectoriesFunc func(by string) (string, error)
|
|
|
|
func refusedJudgement(why string) HandOverAnswer {
|
|
return HandOverAnswer{Refused: why + "; nothing was judged"}
|
|
}
|
|
|
|
// AnswerJudgeDirectories is the engine's judgement of one ask's bytes: believed as a hand-over is, a path refused,
|
|
// and only then the handler.
|
|
func AnswerJudgeDirectories(check HandOverCheck, handle JudgeDirectoriesFunc, body []byte) HandOverAnswer {
|
|
if handle == nil {
|
|
return refusedJudgement("this engine judges no directory on an ask")
|
|
}
|
|
ask, refused := believed(check, JudgeDirectoriesContext, false, body)
|
|
if refused != "" {
|
|
return refusedJudgement(refused)
|
|
}
|
|
said, err := handle(ask.By)
|
|
if err != nil {
|
|
return refusedJudgement(err.Error())
|
|
}
|
|
return HandOverAnswer{Said: said}
|
|
}
|
|
|
|
// JudgeDirectoriesReply is what a judgement request is answered with, and whether to answer, as HandOverReply.
|
|
func JudgeDirectoriesReply(check HandOverCheck, handle JudgeDirectoriesFunc, reply string, body []byte) ([]byte, bool) {
|
|
if reply == "" {
|
|
return nil, false
|
|
}
|
|
answer, err := json.Marshal(AnswerJudgeDirectories(check, handle, body))
|
|
if err != nil {
|
|
return nil, false
|
|
}
|
|
return answer, true
|
|
}
|
|
|
|
// DirectoryJudgements is a link that hears the controller asking this node to judge its directories. Optional.
|
|
type DirectoryJudgements interface {
|
|
HearDirectoryJudgements(JudgeDirectoriesFunc)
|
|
}
|
|
|
|
// The take act on a library, asked over the bus (novox/hq ADR 0328 §7, issue 567).
|
|
//
|
|
// A library whose root already holds data is kept only on the operator's word: it waits (§8), the controller asks
|
|
// the operator once for that library (ADR 0259), and on the approved warrant asks the library's home engine on
|
|
// AskAccessActSubject, signed as a hand-over is and held to the same checks (believed), under a signing context of
|
|
// its own, naming the act, the access `library:<name>`, its root as the engine states it, and the warrant. The engine
|
|
// judges every value against what it was last declared and what is on disk, answers at once that the act started or
|
|
// why it is refused, sets the library's group, group write and setgid on everything under the root (following no
|
|
// link, entering no other filesystem), and states the act on the library's verdict (Act).
|
|
|
|
// AskAccessActSubject is where the controller asks this node's engine to run an act on one access's folder.
|
|
func AskAccessActSubject(node string) string { return "mesh.node." + node + ".ask.access-act" }
|
|
|
|
// AccessActContext is prefixed to an act ask's bytes before signing: never another ask's, nor a declaration's.
|
|
const AccessActContext = "novox-mesh access-act v2\n"
|
|
|
|
// ActTake is the one act asked: the library's group, group write and setgid set on everything under its root, on
|
|
// a warrant (ADR 0328 §7).
|
|
const ActTake = "take"
|
|
|
|
// AccessActAsk is an act's own fields, beside a hand-over's (node, path, by, expires, nonce): which act, the
|
|
// access by its declared resource id exactly as the declaration names it (`/` allowed), and the warrant the
|
|
// operator approved.
|
|
type AccessActAsk struct {
|
|
Act string `json:"act"`
|
|
Access string `json:"access"`
|
|
Warrant string `json:"warrant"`
|
|
}
|
|
|
|
// AccessAct is one act asked and believed, as the handler is given it.
|
|
type AccessAct struct {
|
|
Act, Access, Path, Warrant, By string
|
|
}
|
|
|
|
// MaxActField bounds an access id and a warrant id.
|
|
const MaxActField = 200
|
|
|
|
// AccessActFunc judges and runs one act: the words to answer with, or the refusal.
|
|
type AccessActFunc func(AccessAct) (string, error)
|
|
|
|
func refusedAct(why string) HandOverAnswer {
|
|
return HandOverAnswer{Refused: why + "; nothing was changed"}
|
|
}
|
|
|
|
// AnswerAccessAct is the engine's judgement of one act ask's bytes: believed as a hand-over is, then the act, the
|
|
// access and the warrant — **nothing is done without a warrant** — and only then the handler.
|
|
func AnswerAccessAct(check HandOverCheck, handle AccessActFunc, body []byte) HandOverAnswer {
|
|
if handle == nil {
|
|
return refusedAct("this engine runs no act on an accessed folder")
|
|
}
|
|
ask, signed, refused := believedBytes(check, AccessActContext, true, body)
|
|
if refused != "" {
|
|
return refusedAct(refused)
|
|
}
|
|
var act AccessActAsk
|
|
if err := json.Unmarshal(signed, &act); err != nil {
|
|
return refusedAct("the signed ask is not an act's ask: " + err.Error())
|
|
}
|
|
act.Access, act.Warrant = strings.TrimSpace(act.Access), strings.TrimSpace(act.Warrant)
|
|
switch {
|
|
case act.Warrant == "":
|
|
return refusedAct("the ask names no warrant, and an act on an accessed folder runs only on one")
|
|
case act.Access == "":
|
|
return refusedAct("the ask names no access")
|
|
case act.Act != ActTake:
|
|
return refusedAct(fmt.Sprintf("the ask names the act %q, and the one act asked is %q", act.Act, ActTake))
|
|
}
|
|
// The access is the declared resource's id exactly as the declaration names it — which may hold a `/`
|
|
// (radarr.access-services/media/movies) — and the engine matches it against what it was declared, refusing
|
|
// anything else; the warrant is one short name.
|
|
if len(act.Access) > MaxActField || strings.ContainsAny(act.Access, "\r\n\x00") {
|
|
return refusedAct("the ask's access is not one short line")
|
|
}
|
|
if len(act.Warrant) > MaxActField || strings.ContainsAny(act.Warrant, "\r\n\x00/") {
|
|
return refusedAct("the ask's warrant is not one short name")
|
|
}
|
|
said, err := handle(AccessAct{Act: act.Act, Access: act.Access, Path: ask.Path, Warrant: act.Warrant, By: ask.By})
|
|
if err != nil {
|
|
return refusedAct(err.Error())
|
|
}
|
|
return HandOverAnswer{Said: said}
|
|
}
|
|
|
|
// AccessActReply is what an act request is answered with, and whether to answer, as HandOverReply.
|
|
func AccessActReply(check HandOverCheck, handle AccessActFunc, reply string, body []byte) ([]byte, bool) {
|
|
if reply == "" {
|
|
return nil, false
|
|
}
|
|
answer, err := json.Marshal(AnswerAccessAct(check, handle, body))
|
|
if err != nil {
|
|
return nil, false
|
|
}
|
|
return answer, true
|
|
}
|
|
|
|
// AccessActs is a link that hears the controller asking this node to run an act on an accessed folder. Optional.
|
|
type AccessActs interface {
|
|
HearAccessActs(AccessActFunc)
|
|
}
|