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: , 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"}) }