mesh/merge-gate pass: builds mesh-host → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
mesh/delivery-group group fix/358-judge-directories-read-only delivered: every member is delivered
Once a mesh directory's mode and owner go only through a walk that follows no link another account could make, a directory behind such a link is refused and its module gated: on the control-node that could stop the controller. The operator is to be told which directories that walk would refuse on every node before it lands. So the engine judges the declaration it keeps with the same walk, making and changing nothing: on a signed ask from the controller (mesh.node.<node>.ask.judge-directories, held to the hand-over's checks) and as `nox-mesh-host judge-directories` at the machine.
323 lines
13 KiB
Go
323 lines
13 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) {
|
|
if len(body) > MaxHandOverAsk {
|
|
return HandOverAsk{}, 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{}, "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{}, "the ask is not signed by the mesh"
|
|
}
|
|
var ask HandOverAsk
|
|
if err := json.Unmarshal(signed.Ask, &ask); err != nil {
|
|
return HandOverAsk{}, "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{}, 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{}, "the ask has expired"
|
|
case ask.Expires.After(now.Add(HandOverGood + HandOverSkew)):
|
|
return HandOverAsk{}, "the ask is good for longer than an ask may be"
|
|
case nonceErr != nil || len(nonce) < 16:
|
|
return HandOverAsk{}, "the ask carries no nonce of its own"
|
|
case ask.By == "":
|
|
return HandOverAsk{}, "the ask names nobody as asking"
|
|
case len(ask.By) > MaxHandOverBy || strings.ContainsAny(ask.By, "\r\n\x00"):
|
|
return HandOverAsk{}, "who asked is not one short line"
|
|
case withPath && ask.Path == "":
|
|
return HandOverAsk{}, "the ask names no directory"
|
|
case !withPath && ask.Path != "":
|
|
return HandOverAsk{}, "the ask names a path, and this ask takes none"
|
|
case !nonces.take(ask.Nonce, ask.Expires, now):
|
|
return HandOverAsk{}, "the ask was taken already"
|
|
}
|
|
return 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)
|
|
}
|