A module's containers ran as whatever their image chose and shared folders were made writable by putting accounts in the folder's group (ADR 0312), which ADR 0325 supersedes. The engine now makes each declared module identity as a system account with the controller's numbers, runs a container declaring `identity` as it, and keeps each placed shared folder usable through one POSIX ACL entry per user, set through the kernel's xattrs on descriptors it opened itself. A folder holding data is kept only after the operator's warrant (the take act); a missing folder is made where its data lives and waits where its disk is not; root's data is never kept. A watch and a bounded sweep put back what an app narrows. The group grant is removed, and what it put an account in is taken back. Reuses the descriptor walks, the create act, the missing-path wait and the warrant- backed act message from the paused issue 567 branch.
431 lines
18 KiB
Go
431 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 an accessed folder, asked over the bus (novox/hq ADR 0325 §8, issue 567).
|
|
//
|
|
// A folder that already holds data is kept only on the operator's word: the access waits (§7), the controller asks
|
|
// the operator once for that folder on that machine (ADR 0259), and on the approved warrant asks this node's 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, its folder 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, adds the users' entries to everything under the folder (following no link, entering no other
|
|
// filesystem), and states the act on the access's verdict (Act). Adding or dropping a user of a folder already
|
|
// taken asks nobody: the engine does it on the apply that declares it.
|
|
|
|
// 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"
|
|
|
|
// The walks under an accessed folder. Only ActTake is asked; ActAdd and ActDrop are what an apply starts.
|
|
const (
|
|
// ActTake adds the users' entries to the folder and everything under it, on a warrant (ADR 0325 §8).
|
|
ActTake = "take"
|
|
// ActAdd adds the entries of a user that joined a taken folder; ActDrop removes exactly a dropped user's.
|
|
ActAdd = "add"
|
|
ActDrop = "drop"
|
|
)
|
|
|
|
// AccessActAsk is an act's own fields, beside a hand-over's (node, path, by, expires, nonce): which act, the
|
|
// access by its resource id, 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))
|
|
}
|
|
for _, v := range []string{act.Access, act.Warrant} {
|
|
if len(v) > MaxActField || strings.ContainsAny(v, "\r\n\x00/") {
|
|
return refusedAct("the ask's access or 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)
|
|
}
|