ADR 0328 supersedes ADR 0325: shared data is a library with one home, kept usable by its group rather than by ACL entries. The engine now makes each library's group and each module's user in its libraries' groups; keeps a library's root and requested sub-paths in its group with group write and setgid on its home, making missing sub-paths as the root is; waits for the operator's word before touching a root that holds data, and on the take act walks it by descriptor (no link, same filesystem, bounded and resumed, files with other names left); holds the containers of a library whose disk or mount is not there; and puts the tool runner's account in the group. The ACL keeping, mask repair and watch are gone; an access is again as before ADR 0325.
430 lines
18 KiB
Go
430 lines
18 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) {
|
|
if len(body) > MaxHandOverAsk {
|
|
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)
|
|
}
|