Files
mesh-controller/internal/link/take.go
T

115 lines
5.3 KiB
Go

package link
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/nats-io/nats.go"
"github.com/novox/mesh-controller/internal/broker"
)
// The take act: a shared folder that already holds data is taken by the mesh once, on the operator's word
// (novox/hq ADR 0325 §8).
//
// The controller asks the operator once for that folder on that machine (ADR 0259, at the level approve). On the
// warrant it asks the machine's node-engine, on the engine's own subject (broker.AskAccessActSubject), signed with
// the mesh's key as a hand-over is (SignedHandOver) under a signing context of its own (AccessActContext), to take
// the folder: the engine adds the entries of the users it was declared for that folder to everything under it —
// following no link, entering no other filesystem — answers at once that it started or why it refused, and states
// the walk on the access's verdict. **Nothing is taken without a warrant**: the ask names the warrant the operator
// gave, and the controller sends it only from the warrant (cmd/mesh-controller decideTake).
//
// The engine's own shape (mesh-host internal/link AccessActAsk, AnswerAccessAct): a hand-over's fields and
// {act: "take", access: <the access's resource id in the declaration>, warrant}.
// AccessActContext is prefixed to an act ask's bytes before signing: never another ask's signature, nor a
// declaration's. The engine holds the same words.
const AccessActContext = "novox-mesh access-act v2\n"
// ActTake is the one act the controller asks.
const ActTake = "take"
// AccessActAsk is what the controller asks: a hand-over's fields — the node it is for, the folder's absolute path as
// the declaration places it, who asked, when the ask stops being good and a nonce the engine takes once — and the
// act's own: which act, the access by its resource id, and the warrant the operator gave.
type AccessActAsk struct {
Node string `json:"node"`
Path string `json:"path"`
By string `json:"by"`
Expires time.Time `json:"expires"`
Nonce string `json:"nonce"`
Act string `json:"act"`
Access string `json:"access"`
Warrant string `json:"warrant"`
}
// MaxActField bounds an access id and a warrant, as the engine does.
const MaxActField = 200
// TakeWithin is how long the controller waits for the engine's answer: it starts the walk and answers, and the
// walk's end is stated on the access's verdict.
const TakeWithin = 30 * time.Second
// SignTake fills a take ask's act, expiry and nonce and signs it with the mesh's key over AccessActContext. It refuses
// an ask with no warrant, no path or no access — and one the engine would refuse for its shape: nothing is ever asked
// of an engine that it would have to refuse.
func SignTake(ctx context.Context, signer Signer, ask AccessActAsk) ([]byte, error) {
ask.Access, ask.Warrant = strings.TrimSpace(ask.Access), strings.TrimSpace(ask.Warrant)
switch {
case signer == nil:
return nil, errors.New("no signing key, so nothing can be asked of an engine")
case ask.Warrant == "":
return nil, errors.New("a take is asked only on the operator's warrant, and this one names none")
case ask.Node == "" || !strings.HasPrefix(ask.Path, "/"):
return nil, errors.New("a take names its machine and the folder's absolute path")
case ask.Access == "":
return nil, errors.New("a take names the access whose folder it takes")
}
// The warrant is one short name; the access is the resource id the declaration gave it, which may hold a path's
// slashes (catalogue.AccessID) — the engine judges it against what it was declared.
if len(ask.Warrant) > MaxActField || strings.ContainsAny(ask.Warrant, "\r\n\x00/") {
return nil, fmt.Errorf("the take's warrant %q is not one short name", ask.Warrant)
}
if len(ask.Access) > MaxActField || strings.ContainsAny(ask.Access, "\r\n\x00") {
return nil, fmt.Errorf("the take's access %q is not one line", ask.Access)
}
nonce := make([]byte, 16)
if _, err := rand.Read(nonce); err != nil {
return nil, err
}
ask.Act, ask.Nonce, ask.Expires = ActTake, hex.EncodeToString(nonce), time.Now().UTC().Add(HandOverGood)
raw, err := json.Marshal(ask)
if err != nil {
return nil, err
}
signature, err := signer.Sign(ctx, append([]byte(AccessActContext), raw...))
if err != nil {
return nil, fmt.Errorf("cannot sign the ask: %w", err)
}
return json.Marshal(SignedHandOver{Ask: raw, Signature: signature})
}
// AskTake asks one machine's node-engine to take a shared folder on the operator's warrant, and reads its answer. An
// error is the ask not reaching an engine, or an answer that is not one; a refusal is the engine's and comes back in
// the answer.
func AskTake(ctx context.Context, conn *nats.Conn, signer Signer, ask AccessActAsk, timeout time.Duration) (HandOverAnswer, error) {
if conn == nil {
return HandOverAnswer{}, errors.New("this controller is not on the bus")
}
body, err := SignTake(ctx, signer, ask)
if err != nil {
return HandOverAnswer{}, err
}
return askSigned(ctx, conn, broker.AskAccessActSubject(ask.Node), body, ask.Node, timeout, askWords{
what: "the take of a shared folder", nothing: "nothing was taken", issue: "ADR 0325",
unknown: "whether it started is not known — the access's verdict on the machine says whether it is kept",
record: "a start"})
}