diff --git a/modules/mesh-enrolment/cmd/mesh-enrolment/enrolment.go b/modules/mesh-enrolment/cmd/mesh-enrolment/enrolment.go new file mode 100644 index 0000000..f23eaae --- /dev/null +++ b/modules/mesh-enrolment/cmd/mesh-enrolment/enrolment.go @@ -0,0 +1,144 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "regexp" + "time" +) + +// mesh-enrolment: the holder of the seat mesh-enrolment (novox/hq ADR 0587 §1), held once for the mesh. A Go bundle served +// by a runtime of its own, on its own bus account (`runs-as`). It mints and keeps no secret and holds no signing key: the +// vault mints the token and the node's account and signs (ADR 0587 §3). What it keeps is one key per open enrolment in its +// state `enrolments` (§2), and what it says is its five events. + +// Module and Seat are the module's name and the seat it holds. +const ( + Module = "mesh-enrolment" + Seat = "mesh-enrolment" + // State is the holder's bucket of open enrolments; its keys are node. and address.
(ADR 0587 §2). + State = "enrolments" +) + +// The seat's six verbs and five events (ADR 0587 §1). +var Verbs = []string{"issue", "redeem", "confirm", "revoke", "tokens", "again"} + +const ( + EventIssued = "enrolment-issued" + EventEnrolled = "node-enrolled" + EventRefused = "enrolment-refused" + EventRevoked = "enrolment-revoked" + EventExpired = "enrolment-expired" +) + +// EndEvents are the events that end an enrolment; the holder's durable consumer hears each (ADR 0587 §5) and asks the vault +// to drop the token. A seat's event is heard as .. +var EndEvents = []string{EventEnrolled, EventRefused, EventRevoked, EventExpired} + +// Stage is how far an open enrolment has come (ADR 0587 §2). +type Stage string + +const ( + // StageAsked: the operator's Approve is asked, no token exists. + StageAsked Stage = "asked" + // StageLive: the Approve is given, the token exists and expires on the bus with the key. + StageLive Stage = "live" + // StageRedeeming: the token is spent; the key has no expiry and ends only by confirm or revoke. + StageRedeeming Stage = "redeeming" +) + +const ( + // DefaultExpiry and MaxExpiry bound a token's life (ADR 0587 §4). + DefaultExpiry = time.Hour + MaxExpiry = 24 * time.Hour + // EnrolUser is the bus user a joining machine dials as: enrol. (ADR 0587 §4). + enrolPrefix = "enrol." + nodePrefix = "node." +) + +// Enrolment is the value of the key node. (ADR 0587 §2): no secret, nothing derived from the token. +type Enrolment struct { + V int `json:"v"` + Node string `json:"node"` + Address string `json:"address"` + Why string `json:"why"` + Caller string `json:"caller"` + Ask string `json:"ask,omitempty"` + Stage Stage `json:"stage"` + Issued time.Time `json:"issued"` + Expires time.Time `json:"expires"` + // KeysDigest is the digest of the public keys the first redeem presented (ADR 0587 §5), empty before it. + KeysDigest string `json:"keys-digest,omitempty"` +} + +// Reservation is the value of the key address.
: the node's name alone (ADR 0587 §2). +type Reservation struct { + V int `json:"v"` + Node string `json:"node"` +} + +// NodeKey and AddressKey are the two keys of an enrolment. +func NodeKey(node string) string { return "node." + node } +func AddressKey(address string) string { return "address." + address } + +// EnrolUser and NodeUser are the two bus users of a node: the one it joins with, and its own. +func EnrolUser(node string) string { return enrolPrefix + node } +func NodeUser(node string) string { return nodePrefix + node } + +var nodeName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,62}$`) + +// CheckNode says node is a node name a bus user and a key can carry. +func CheckNode(node string) error { + if !nodeName.MatchString(node) { + return fmt.Errorf("%q is not a valid node name: lowercase letters, digits and hyphens, not starting with a hyphen", node) + } + return nil +} + +func decodeEnrolment(raw []byte) (Enrolment, error) { + var e Enrolment + if err := json.Unmarshal(raw, &e); err != nil { + return Enrolment{}, fmt.Errorf("an enrolment key does not read: %w", err) + } + if e.Node == "" || e.Address == "" { + return Enrolment{}, errors.New("an enrolment key names no node or no address") + } + switch e.Stage { + case StageAsked, StageLive, StageRedeeming: + default: + return Enrolment{}, fmt.Errorf("the enrolment of %s is in no stage %q", e.Node, e.Stage) + } + return e, nil +} + +// Row is a key as the store holds it. +type Row struct { + Value []byte + Revision uint64 +} + +// ErrExists is a create refused because the key has a value; ErrChanged a write refused because the key moved. +var ( + ErrExists = errors.New("the key has a value already") + ErrChanged = errors.New("the key was written by another writer first") +) + +// Store is the holder's own state: stdio.State in the process, a map in a test. A ttl of zero is no expiry of the key's own. +type Store interface { + Get(key string) (Row, bool, error) + // Create writes a key only when it has none (ErrExists otherwise). + Create(key string, v any, ttl time.Duration) error + // Update writes a key only when it is still at revision (ErrChanged otherwise), with no expiry of its own. + Update(key string, v any, revision uint64) (uint64, error) + // Put writes a key whole, with the expiry ttl when it is not zero. + Put(key string, v any, ttl time.Duration) (uint64, error) + Delete(key string) error + Keys() ([]string, error) +} + +// Reader reads another module's state, `.`. +type Reader interface { + Keys(state string) ([]string, error) + Get(state, key string) ([]byte, bool, error) +} diff --git a/modules/mesh-enrolment/cmd/mesh-enrolment/holder.go b/modules/mesh-enrolment/cmd/mesh-enrolment/holder.go new file mode 100644 index 0000000..b2490d0 --- /dev/null +++ b/modules/mesh-enrolment/cmd/mesh-enrolment/holder.go @@ -0,0 +1,263 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "net/netip" + "sort" + "strings" + "sync" + "time" + + "git.novox.be/novox/mesh-sdk/go/asks" + "git.novox.be/novox/mesh-sdk/go/buscompose" + "git.novox.be/novox/mesh-sdk/go/delivery" +) + +// Vault is the vault's side of enrolment (ADR 0587 §3, §5): the token and the node's account, and the signatures. The +// holder keeps nothing of what it answers. +type Vault interface { + // Token asks the vault to make the token and its hash for node, expiring at expires, and answers the public fields + // the joining machine must trust. No token is in the answer. + Token(node string, expires time.Time) (json.RawMessage, error) + // Sealed asks the vault for node's token sealed to the operator key, as the vault sealed it. + Sealed(node string) (json.RawMessage, error) + // Drop asks the vault to drop node's token and its hash; it answers only once both are gone. + Drop(node string) error + // Account asks for node's account sealed to the sealing key; the same node and keys again answer the same account. + Account(node, sealingKey, keysDigest string) (json.RawMessage, error) + // Retire asks the vault to retire the account it minted for node. + Retire(node string) error + // SignCertificate and SignReply sign through the vault's `sign`. + SignCertificate(node, servingKey string) (json.RawMessage, error) + SignReply(node string, reply []byte) (json.RawMessage, error) +} + +// Asker is the operator's channel as the holder asks it (asker.Client is one). +type Asker interface { + Ask(asks.Ask) error + Cancel(id string) error +} + +// Holder is the seat's holder: the state it keeps, and every other part it reaches through a port. +type Holder struct { + Store Store + Read Reader + Vault Vault + Asker Asker + // Say publishes a seat event under a message id the bus keeps one of. + Say func(event string, body any, id string) error + // AddNode asks mesh-placement to add the node (ADR 0587 §7). + AddNode func(AddNode) error + // Warrant and Act record in the hand-act log, through the seat mesh-hand-acts: the operator's warrant for an ask, and an + // act done by hand (a revoke). + Warrant func(ask string) error + Act func(what, why, cause string) error + // Loaded says whether the bus node loaded the bus user (ADR 0587 §3: redeem answers once node. is in its list). + Loaded func(user string) (bool, error) + Now func() time.Time + Logf func(string, ...any) + // AskLasts is how long the operator has to answer an issue's ask. + AskLasts time.Duration + + mu sync.Mutex + // seen is each node key's last content the watch handed over, so an expiry, which hands over no value, is said with it. + seen map[string]Enrolment + // ended is the end events this process heard, newest last: the history `tokens` answers (limit: EVENTS is not readable + // from a bundle, so a restart forgets them). + ended []json.RawMessage +} + +func (h *Holder) now() time.Time { + if h.Now != nil { + return h.Now() + } + return time.Now() +} + +func (h *Holder) logf(f string, a ...any) { + if h.Logf != nil { + h.Logf(f, a...) + } +} + +func (h *Holder) say(event string, body any, id string) error { + if h.Say == nil { + return errors.New("the seat's events cannot be said: no publisher") + } + return h.Say(event, body, id) +} + +// AddNode is what the holder asks mesh-placement to add (ADR 0587 §7): the node, its address, the public halves of its +// keys, and the issue's warrant, which names this node and this address. +type AddNode struct { + Node string `json:"node"` + Address string `json:"address"` + Why string `json:"why"` + Keys NodeKeys `json:"keys"` + Warrant Warrant `json:"warrant"` +} + +// NodeKeys are the public halves of a joining node's keys: identity, private network, sealing and serving. +type NodeKeys struct { + Public []byte `json:"public_key"` + Overlay string `json:"overlay_key,omitempty"` + Sealing string `json:"sealing_key,omitempty"` + Serving string `json:"serving_key,omitempty"` +} + +// Warrant is the issue's warrant as the proposal carries it: the ask the operator answered, the node and the address it +// named. +type Warrant struct { + Ask string `json:"ask"` + Node string `json:"node"` + Address string `json:"address"` +} + +// Digest is the digest of the keys a node presented, the one hash an event carries (ADR 0587 §1). +func (k NodeKeys) Digest() string { return delivery.DigestOf(k.bytes()) } + +func (k NodeKeys) bytes() []byte { + return []byte("novox-mesh-enrol-keys\x00" + string(k.Public) + "\x00" + k.Overlay + "\x00" + k.Sealing + "\x00" + k.Serving) +} + +// issuing says whether issue is on: only while the controller's lease is handed to a holder that is no controller (ADR 0587 +// §4), and why either way. +func (h *Holder) issuing() (bool, string) { + raw, found, err := h.Read.Get("mesh-controller.lease", buscompose.ControllerLeaseKey) + return buscompose.LeaseHandedAway(raw, found, err) +} + +// openEnrolments reads every node key of the bucket. +func (h *Holder) openEnrolments() (map[string]Row, map[string]Enrolment, error) { + keys, err := h.Store.Keys() + if err != nil { + return nil, nil, fmt.Errorf("the enrolments cannot be listed: %w", err) + } + sort.Strings(keys) + rows, out := map[string]Row{}, map[string]Enrolment{} + for _, k := range keys { + node, ok := strings.CutPrefix(k, "node.") + if !ok { + continue + } + row, found, err := h.Store.Get(k) + if err != nil { + return nil, nil, fmt.Errorf("the enrolment of %s cannot be read: %w", node, err) + } + if !found { + continue + } + e, err := decodeEnrolment(row.Value) + if err != nil { + return nil, nil, fmt.Errorf("%s: %w", k, err) + } + rows[node], out[node] = row, e + } + return rows, out, nil +} + +// placements reads every node's placement, assembled from its keys. +func (h *Holder) placements() (map[string]delivery.Placement, error) { + keys, err := h.Read.Keys("mesh-delivery-assigner.placement") + if err != nil { + return nil, fmt.Errorf("placement cannot be listed: %w", err) + } + held := map[string][]byte{} + for _, k := range keys { + if raw, found, err := h.Read.Get("mesh-delivery-assigner.placement", k); err != nil { + return nil, fmt.Errorf("placement key %s cannot be read: %w", k, err) + } else if found { + held[k] = raw + } + } + p, err := delivery.PlacementsOf(held) + if len(p) == 0 && err != nil { + return nil, err + } + return p, nil +} + +// hasRecord says the node already writes a record (a node of the mesh, ADR 0587 §4: "a node record ... holds it"). +func (h *Holder) hasRecord(node string) bool { + keys, err := h.Read.Keys("node-engine.have") + if err != nil { + return false + } + for _, k := range keys { + if n, _, isNode, err := delivery.ParseHaveKey(k); err == nil && isNode && n == node { + return true + } + } + return false +} + +// nextAddress is the lowest address of the private network that no placement key and no reservation holds (ADR 0587 §4), +// after skip. The network is the hub's range; the hub's own address and the tunnel's are never given. +func nextAddress(placements map[string]delivery.Placement, reserved map[string]bool, skip map[string]bool) (string, error) { + var prefix netip.Prefix + taken := map[string]bool{} + for _, p := range placements { + if a := p.Decided.Address; a != "" { + taken[a] = true + } + if t := p.Decided.Tunnel; t != nil { + if t.Address != "" { + if a, err := netip.ParsePrefix(t.Address); err == nil { + taken[a.Addr().String()] = true + } else { + taken[t.Address] = true + } + } + if t.Range != "" && p.Decided.Hub && !prefix.IsValid() { + if r, err := netip.ParsePrefix(t.Range); err == nil { + prefix = r.Masked() + } + } + } + } + if !prefix.IsValid() { + for _, p := range placements { + if a, err := netip.ParseAddr(p.Decided.Address); err == nil && p.Decided.Hub && a.Is4() { + prefix = netip.PrefixFrom(a, 24).Masked() + } + } + } + if !prefix.IsValid() { + return "", errors.New("no private network is known: the hub's placement names no range or address") + } + for a := prefix.Addr().Next(); prefix.Contains(a); a = a.Next() { + s := a.String() + if prefix.Addr().Is4() && !prefix.Contains(a.Next()) { + break // the broadcast address + } + if taken[s] || reserved[s] || skip[s] { + continue + } + return s, nil + } + return "", fmt.Errorf("the private network %s has no free address", prefix) +} + +func (h *Holder) reservedAddresses() (map[string]bool, error) { + keys, err := h.Store.Keys() + if err != nil { + return nil, fmt.Errorf("the enrolments cannot be listed: %w", err) + } + out := map[string]bool{} + for _, k := range keys { + if a, ok := strings.CutPrefix(k, "address."); ok { + out[a] = true + } + } + return out, nil +} + +// lifetimeOf is the time a key lives on the bus for an enrolment that expires at expires. +func (h *Holder) lifetimeOf(expires time.Time) time.Duration { + if d := expires.Sub(h.now()); d > time.Second { + return d + } + return time.Second +} diff --git a/modules/mesh-enrolment/cmd/mesh-enrolment/kit_test.go b/modules/mesh-enrolment/cmd/mesh-enrolment/kit_test.go new file mode 100644 index 0000000..e907b6b --- /dev/null +++ b/modules/mesh-enrolment/cmd/mesh-enrolment/kit_test.go @@ -0,0 +1,240 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "slices" + "sort" + "strings" + "sync" + "testing" + "time" + + "git.novox.be/novox/mesh-sdk/go/asks" +) + +// memStore is the holder's bucket in memory: revisions, and the expiry each key was written with. +type memStore struct { + mu sync.Mutex + rows map[string]Row + ttl map[string]time.Duration + rev uint64 + // failDelete names keys whose delete fails once. + failDelete map[string]bool +} + +func newMem() *memStore { + return &memStore{rows: map[string]Row{}, ttl: map[string]time.Duration{}, failDelete: map[string]bool{}} +} + +func (m *memStore) write(key string, v any, ttl time.Duration) uint64 { + raw, _ := json.Marshal(v) + m.rev++ + m.rows[key] = Row{Value: raw, Revision: m.rev} + m.ttl[key] = ttl + return m.rev +} + +func (m *memStore) Get(key string) (Row, bool, error) { + m.mu.Lock() + defer m.mu.Unlock() + r, ok := m.rows[key] + return r, ok, nil +} + +func (m *memStore) Create(key string, v any, ttl time.Duration) error { + m.mu.Lock() + defer m.mu.Unlock() + if _, ok := m.rows[key]; ok { + return ErrExists + } + m.write(key, v, ttl) + return nil +} + +func (m *memStore) Update(key string, v any, revision uint64) (uint64, error) { + m.mu.Lock() + defer m.mu.Unlock() + if r, ok := m.rows[key]; !ok || r.Revision != revision { + return 0, ErrChanged + } + return m.write(key, v, 0), nil +} + +func (m *memStore) Put(key string, v any, ttl time.Duration) (uint64, error) { + m.mu.Lock() + defer m.mu.Unlock() + return m.write(key, v, ttl), nil +} + +func (m *memStore) Delete(key string) error { + m.mu.Lock() + defer m.mu.Unlock() + if m.failDelete[key] { + delete(m.failDelete, key) + return errors.New("the bus did not answer") + } + delete(m.rows, key) + delete(m.ttl, key) + return nil +} + +func (m *memStore) Keys() ([]string, error) { + m.mu.Lock() + defer m.mu.Unlock() + var out []string + for k := range m.rows { + out = append(out, k) + } + sort.Strings(out) + return out, nil +} + +// memRead is other modules' states, `.` then key. +type memRead map[string]map[string]string + +func (r memRead) Keys(state string) ([]string, error) { + var out []string + for k := range r[state] { + out = append(out, k) + } + sort.Strings(out) + return out, nil +} + +func (r memRead) Get(state, key string) ([]byte, bool, error) { + v, ok := r[state][key] + return []byte(v), ok, nil +} + +// fakeVault records its calls in order and fails on the verbs named in fail, once each. +type fakeVault struct { + calls []string + fail map[string]bool +} + +func (v *fakeVault) do(what string) error { + v.calls = append(v.calls, what) + if v.fail[what] { + delete(v.fail, what) + return errors.New("the vault did not answer " + what) + } + return nil +} + +func (v *fakeVault) Token(node string, expires time.Time) (json.RawMessage, error) { + return json.RawMessage(`{"broker":"b","fingerprint":"f","signer":"s"}`), v.do("token " + node) +} +func (v *fakeVault) Sealed(node string) (json.RawMessage, error) { + return json.RawMessage(`"sealed-for-` + node + `"`), v.do("sealed " + node) +} +func (v *fakeVault) Drop(node string) error { return v.do("drop " + node) } +func (v *fakeVault) Account(node, sealing, digest string) (json.RawMessage, error) { + return json.RawMessage(`{"account":"` + node + `"}`), v.do("account " + node) +} +func (v *fakeVault) Retire(node string) error { return v.do("retire " + node) } +func (v *fakeVault) SignCertificate(node, serving string) (json.RawMessage, error) { + return json.RawMessage(`{"certificate":"c"}`), v.do("certificate " + node) +} +func (v *fakeVault) SignReply(node string, reply []byte) (json.RawMessage, error) { + return json.RawMessage(`{"signature":"sig","signer":"fp"}`), v.do("sign " + node) +} + +type fakeAsker struct { + asked, cancelled []string + err error +} + +func (a *fakeAsker) Ask(k asks.Ask) error { + if a.err != nil { + return a.err + } + a.asked = append(a.asked, k.ID) + return nil +} +func (a *fakeAsker) Cancel(id string) error { a.cancelled = append(a.cancelled, id); return nil } + +type said struct { + Event, ID string + Body json.RawMessage +} + +// rig is a holder over fakes, with the lease handed to mesh-grants, one hub in placement and a clock the test moves. +type rig struct { + t *testing.T + h *Holder + st *memStore + vault *fakeVault + asker *fakeAsker + rd memRead + now time.Time + said []said + added []AddNode + acts []string + warr []string + loaded bool + placeErr error +} + +const hubPlacement = `{"v":1,"node":"hub","decided":{"hub":true,"address":"10.9.0.1","tunnel":{"interface":"wg0","address":"10.9.0.1/24","range":"10.9.0.0/24"}},"modules":[]}` + +func newRig(t *testing.T) *rig { + r := &rig{t: t, st: newMem(), vault: &fakeVault{fail: map[string]bool{}}, asker: &fakeAsker{}, now: time.Date(2026, 11, 2, 10, 0, 0, 0, time.UTC), loaded: true} + r.rd = memRead{ + "mesh-controller.lease": {"holder": `{"handed-to":"mesh-grants","handed-by":"operator"}`}, + "mesh-delivery-assigner.placement": {"hub.node": hubPlacement}, + "node-engine.have": {}, + } + r.h = &Holder{Store: r.st, Read: r.rd, Vault: r.vault, Asker: r.asker, AskLasts: time.Hour, + Say: func(event string, body any, id string) error { + raw, _ := json.Marshal(body) + r.said = append(r.said, said{event, id, raw}) + return nil + }, + AddNode: func(a AddNode) error { r.added = append(r.added, a); return r.placeErr }, + Warrant: func(ask string) error { r.warr = append(r.warr, ask); return nil }, + Act: func(what, why, cause string) error { r.acts = append(r.acts, what); return nil }, + Loaded: func(string) (bool, error) { return r.loaded, nil }, + Now: func() time.Time { return r.now }, + Logf: func(f string, a ...any) { t.Logf(f, a...) }} + return r +} + +func (r *rig) events() []string { + var out []string + for _, s := range r.said { + out = append(out, s.Event) + } + return out +} + +func (r *rig) enrolment(node string) (Enrolment, bool) { + row, ok, _ := r.st.Get(NodeKey(node)) + if !ok { + return Enrolment{}, false + } + e, err := decodeEnrolment(row.Value) + if err != nil { + r.t.Fatal(err) + } + return e, true +} + +func (r *rig) keys() []string { k, _ := r.st.Keys(); return k } + +func (r *rig) has(key string) bool { return slices.Contains(r.keys(), key) } + +func mustContain(t *testing.T, got, want string) { + t.Helper() + if !strings.Contains(got, want) { + t.Fatalf("%q does not contain %q", got, want) + } +} + +func errText(err error) string { + if err == nil { + return "" + } + return fmt.Sprint(err) +} diff --git a/modules/mesh-enrolment/cmd/mesh-enrolment/main.go b/modules/mesh-enrolment/cmd/mesh-enrolment/main.go new file mode 100644 index 0000000..189f172 --- /dev/null +++ b/modules/mesh-enrolment/cmd/mesh-enrolment/main.go @@ -0,0 +1,146 @@ +// mesh-enrolment: the holder of the seat mesh-enrolment (novox/hq ADR 0587). A Go bundle served by a runtime of its own, +// on its own bus account (`runs-as`), so that its account alone writes its bucket `enrolments` with per-key expiry. It +// asks the operator on their channel, the vault to mint and sign, mesh-placement to add the node and mesh-hand-acts to +// record the acts. stdout is the MCP channel; what this module says, it says on stderr. +package main + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "time" + + stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-sdk/go/asker" + "git.novox.be/novox/mesh-sdk/go/handacts" +) + +func logf(format string, a ...any) { fmt.Fprintf(os.Stderr, format+"\n", a...) } + +// runtimeStore is the holder's bucket, through the runtime that launched this bundle. +type runtimeStore struct{} + +func (runtimeStore) Get(key string) (Row, bool, error) { + e, err := stdio.State(State).Get(key) + if err != nil || e == nil { + return Row{}, false, err + } + return Row{Value: e.Value, Revision: e.Revision}, true, nil +} + +func (runtimeStore) Create(key string, v any, ttl time.Duration) error { + var err error + if ttl > 0 { + _, err = stdio.State(State).CreateTTL(key, v, ttl) + } else { + _, err = stdio.State(State).Create(key, v) + } + if errors.Is(err, stdio.ErrChanged) { + return ErrExists + } + return err +} + +func (runtimeStore) Update(key string, v any, revision uint64) (uint64, error) { + rev, err := stdio.State(State).Update(key, v, revision) + if errors.Is(err, stdio.ErrChanged) { + return 0, ErrChanged + } + return rev, err +} + +func (runtimeStore) Put(key string, v any, ttl time.Duration) (uint64, error) { + if ttl > 0 { + return stdio.State(State).PutTTL(key, v, ttl) + } + return stdio.State(State).Put(key, v) +} + +func (runtimeStore) Delete(key string) error { return stdio.State(State).Delete(key) } +func (runtimeStore) Keys() ([]string, error) { return stdio.State(State).Keys() } + +// runtimeReader reads another module's state through the runtime. +type runtimeReader struct{} + +func (runtimeReader) Keys(state string) ([]string, error) { return stdio.State(state).Keys() } + +func (runtimeReader) Get(state, key string) ([]byte, bool, error) { + e, err := stdio.State(state).Get(key) + if err != nil || e == nil { + return nil, false, err + } + return e.Value, true, nil +} + +// SeatEventSubject is where the seat says an event: mesh.seat..event.. +func SeatEventSubject(event string) string { return "mesh.seat." + Seat + ".event." + event } + +// newHolder is the holder over the runtime. +func newHolder() *Holder { + call := Call(stdio.Ask) + rec := handacts.Recorder{Ask: stdio.Ask} + rd := runtimeReader{} + return &Holder{ + Store: runtimeStore{}, Read: rd, Vault: vaultOver{call: call}, Asker: asker.New("asked"), + Say: func(event string, body any, id string) error { + return stdio.SeatPublish(SeatEventSubject(event), body, id) + }, + AddNode: addNodeOver(call), + Warrant: func(ask string) error { _, err := rec.RecordWarrant(Module, ask); return err }, + Act: func(what, why, cause string) error { + _, err := rec.Record(handacts.Request{What: what, Why: why, Cause: cause}) + return err + }, + Loaded: loadedVia(rd), + Now: time.Now, + Logf: func(f string, a ...any) { logf("["+Module+"] "+f, a...) }, + AskLasts: 55 * time.Minute, + } +} + +// loadedVia says a bus user is loaded when the grants part's last pass lists it among the composed users and the bus node's +// loaded list holds it (its credentials row says loaded). +func loadedVia(rd Reader) func(string) (bool, error) { + return func(user string) (bool, error) { + raw, found, err := rd.Get("mesh-grants.user-list", "pass") + if err != nil { + return false, err + } + if !found { + return false, errors.New("the grants part has composed no list yet") + } + var st struct { + Compare struct { + Unread string `json:"unread"` + } `json:"compare"` + Credentials struct { + Users []struct { + User string `json:"user"` + InShadow bool `json:"in_shadow"` + Loaded bool `json:"loaded"` + } `json:"users"` + } `json:"credentials"` + } + if err := json.Unmarshal(raw, &st); err != nil { + return false, fmt.Errorf("the grants part's list state does not read: %w", err) + } + if st.Compare.Unread != "" { + return false, errors.New("the bus node's loaded list cannot be read: " + st.Compare.Unread) + } + for _, u := range st.Credentials.Users { + if u.User == user { + return u.InShadow && u.Loaded, nil + } + } + return false, nil + } +} + +func main() { + h := newHolder() + if err := stdio.Serve("", Tools(h)); err != nil { + logf("%v", err) + os.Exit(1) + } +} diff --git a/modules/mesh-enrolment/cmd/mesh-enrolment/tokens.go b/modules/mesh-enrolment/cmd/mesh-enrolment/tokens.go new file mode 100644 index 0000000..df23f1e --- /dev/null +++ b/modules/mesh-enrolment/cmd/mesh-enrolment/tokens.go @@ -0,0 +1,118 @@ +package main + +import ( + "encoding/json" + "fmt" + "sort" + "time" +) + +// TokenRow is one open enrolment as `tokens` answers it (ADR 0587 §8): its stage, expiry, ask and revision, and for a node +// at stage live the token sealed to the operator key as the vault sealed it (the holder never reads it and keeps it nowhere). +type TokenRow struct { + Node string `json:"node"` + Address string `json:"address"` + Why string `json:"why"` + Caller string `json:"caller"` + Stage Stage `json:"stage"` + Ask string `json:"ask,omitempty"` + Issued time.Time `json:"issued"` + Expires time.Time `json:"expires"` + Age string `json:"age"` + Revision uint64 `json:"revision"` + Keys string `json:"keys-digest,omitempty"` + Sealed json.RawMessage `json:"sealed,omitempty"` + // Unsealed says why no sealed token is given for a node that should have one. + Unsealed string `json:"unsealed,omitempty"` +} + +// Issuing is whether issue is on, and why. +type Issuing struct { + On bool `json:"on"` + Why string `json:"why"` +} + +// Fill is how full the bucket is: its keys and the bytes of their values (the state's cap is the bus's, which no bundle +// can read, so the fill is stated without it). +type Fill struct { + Keys int `json:"keys"` + Bytes int `json:"bytes"` +} + +// TokensAnswer is what `tokens` answers. +type TokensAnswer struct { + Open []TokenRow `json:"open"` + Issuing Issuing `json:"issuing"` + Fill Fill `json:"fill"` + History []json.RawMessage `json:"history,omitempty"` + // HistoryFrom says where the history comes from, when one was asked for. + HistoryFrom string `json:"history-from,omitempty"` +} + +// Tokens answers one open enrolment or all, with the bucket's fill, whether issuing is on and why, and with history the +// ended enrolments the holder's own events say (ADR 0587 §8). +func (h *Holder) Tokens(node string, history bool) (TokensAnswer, error) { + rows, open, err := h.openEnrolments() + if err != nil { + return TokensAnswer{}, err + } + var out TokensAnswer + on, why := h.issuing() + out.Issuing = Issuing{On: on, Why: why} + if keys, err := h.Store.Keys(); err == nil { + out.Fill.Keys = len(keys) + for _, k := range keys { + if r, found, err := h.Store.Get(k); err == nil && found { + out.Fill.Bytes += len(r.Value) + } + } + } + if node != "" { + if _, ok := open[node]; !ok && !history { + return TokensAnswer{}, fmt.Errorf("no enrolment of %s is open: tokens without a node lists those that are", node) + } + } + names := make([]string, 0, len(open)) + for n := range open { + if node == "" || n == node { + names = append(names, n) + } + } + sort.Strings(names) + out.Open = []TokenRow{} + for _, n := range names { + e := open[n] + row := TokenRow{Node: e.Node, Address: e.Address, Why: e.Why, Caller: e.Caller, Stage: e.Stage, Ask: e.Ask, + Issued: e.Issued, Expires: e.Expires, Age: h.now().Sub(e.Issued).Round(time.Second).String(), + Revision: rows[n].Revision, Keys: e.KeysDigest} + if e.Stage == StageLive && node != "" { + // Asked of the vault at this moment and relayed as the vault sealed it: kept nowhere here (ADR 0587 §8). + sealed, err := h.Vault.Sealed(n) + if err != nil { + row.Unsealed = "the vault did not give the token: " + err.Error() + } else { + row.Sealed = sealed + } + } + out.Open = append(out.Open, row) + } + if history { + h.mu.Lock() + for _, e := range h.ended { + if node == "" || endedNode(e) == node { + out.History = append(out.History, e) + } + } + h.mu.Unlock() + out.HistoryFrom = "the end events this holder heard since it started; the bus keeps them 7 days, and a bundle cannot read that stream" + } + return out, nil +} + +func endedNode(raw json.RawMessage) string { + var e struct { + Node string `json:"node"` + } + _ = json.Unmarshal(raw, &e) + return e.Node +} diff --git a/modules/mesh-enrolment/cmd/mesh-enrolment/tokens_test.go b/modules/mesh-enrolment/cmd/mesh-enrolment/tokens_test.go new file mode 100644 index 0000000..a200701 --- /dev/null +++ b/modules/mesh-enrolment/cmd/mesh-enrolment/tokens_test.go @@ -0,0 +1,106 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "slices" + "testing" + "time" +) + +// seed writes an open enrolment of node at stage, with its address key, as the holder would have. +func (r *rig) seed(node, address string, stage Stage) { + e := Enrolment{V: 1, Node: node, Address: address, Why: "a new machine", Caller: "person.operator", Ask: "ask-" + node, Stage: stage, + Issued: r.now.Add(-time.Minute), Expires: r.now.Add(time.Hour)} + if stage == StageRedeeming { + e.KeysDigest = "sha256:keys" + } + _, _ = r.st.Put(NodeKey(node), e, 0) + _, _ = r.st.Put(AddressKey(address), Reservation{V: 1, Node: node}, 0) +} + +// 774/S54/AC1 and AC2: tokens answers each stage with its expiry, ask and revision, the fill and whether issuing is on; +// for one node at live it relays the vault's sealed token and keeps nothing; and the manifest says what the code does. +func TestTokensAnswersEachStageAndRelaysTheSealedTokenOfOneNode(t *testing.T) { + r := newRig(t) + r.seed("ace", "10.9.0.2", StageAsked) + r.seed("g14", "10.9.0.3", StageLive) + r.seed("novox", "10.9.0.4", StageRedeeming) + all, err := r.h.Tokens("", false) + if err != nil { + t.Fatal(err) + } + var stages []string + for _, row := range all.Open { + stages = append(stages, row.Node+":"+string(row.Stage)) + if row.Revision == 0 || row.Expires.IsZero() || row.Ask == "" || row.Sealed != nil { + t.Errorf("row %+v lacks revision, expiry or ask, or relays a token for a list of all", row) + } + } + if !slices.Equal(stages, []string{"ace:asked", "g14:live", "novox:redeeming"}) || all.Fill.Keys != 6 || !all.Issuing.On { + t.Errorf("tokens %v, fill %+v, issuing %+v", stages, all.Fill, all.Issuing) + } + one, err := r.h.Tokens("g14", false) + if err != nil || len(one.Open) != 1 || string(one.Open[0].Sealed) != `"sealed-for-g14"` { + t.Fatalf("one node: %+v, %v", one, err) + } + for k, row := range r.st.rows { + if slices.Contains(r.vault.calls, "sealed g14") && string(row.Value) != "" { + var any map[string]json.RawMessage + _ = json.Unmarshal(row.Value, &any) + if _, has := any["sealed"]; has { + t.Errorf("the key %s holds a sealed token", k) + } + } + } + // Before the lease write issuing is off, and says why. + r.rd["mesh-controller.lease"]["holder"] = `{"instance":"novox.mesh-controller"}` + off, _ := r.h.Tokens("", false) + mustContain(t, off.Issuing.Why, "controller holds the lease") + if off.Issuing.On { + t.Error("issuing is on while the controller holds the lease") + } +} + +func TestTheManifestSaysWhatTheCodeDoes(t *testing.T) { + raw, err := os.ReadFile(filepath.Join("..", "..", "module.json")) + if err != nil { + t.Fatal(err) + } + var m struct { + Module string `json:"module"` + Runs string `json:"runs-as"` + Consumes []string `json:"consumes"` + Claims []struct { + Name string `json:"name"` + Scope string `json:"scope"` + Serves []string `json:"serves"` + } `json:"claims"` + Emits []string `json:"emits"` + State []json.RawMessage `json:"state"` + } + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + if m.Module != Module || m.Runs != Module || len(m.Claims) != 1 || m.Claims[0].Name != Seat || m.Claims[0].Scope != "mesh" || + !slices.Equal(m.Claims[0].Serves, Verbs) || len(m.Emits) != 0 { + t.Errorf("module %q runs as %q, claims %+v, emits %v", m.Module, m.Runs, m.Claims, m.Emits) + } + var heard []string + for _, e := range EndEvents { + heard = append(heard, Seat+"."+e) + } + if !slices.Equal(m.Consumes, heard) { + t.Errorf("consumes %v, the end events are %v", m.Consumes, heard) + } + if len(m.State) != 2 || string(m.State[0]) == "" || !slices.ContainsFunc(m.State, func(s json.RawMessage) bool { + var d struct { + Name string `json:"name"` + TTL bool `json:"key-ttl"` + } + return json.Unmarshal(s, &d) == nil && d.Name == State && d.TTL + }) { + t.Errorf("state %s: enrolments must allow a time to live per key", m.State) + } +} diff --git a/modules/mesh-enrolment/cmd/mesh-enrolment/tools.go b/modules/mesh-enrolment/cmd/mesh-enrolment/tools.go new file mode 100644 index 0000000..652bbc4 --- /dev/null +++ b/modules/mesh-enrolment/cmd/mesh-enrolment/tools.go @@ -0,0 +1,51 @@ +package main + +import ( + "strings" + "time" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +// Tool names: the seat's verbs, as the manifest claims them (ADR 0587 §1, §8). +const ( + TokensTool = Seat + ".tokens" +) + +func obj(props map[string]any, required ...string) map[string]any { + m := map[string]any{"type": "object", "properties": props} + if len(required) > 0 { + m["required"] = required + } + return m +} + +func str(d string) map[string]any { return map[string]any{"type": "string", "description": d} } + +func arg(a map[string]any, k string) string { + s, _ := a[k].(string) + return strings.TrimSpace(s) +} + +func flag(a map[string]any, k string) bool { + return a[k] == true || strings.EqualFold(arg(a, k), "true") +} + +var _ = time.Second + +// Tools are what the runtime serves. +func Tools(h *Holder) []stdio.Tool { + return []stdio.Tool{ + {Name: TokensTool, + Description: "What the seat holds (novox/hq ADR 0587 §8): every open enrolment, or one node's, with its stage (asked, live or " + + "redeeming), address, expiry, ask, revision and age; whether issuing is on and why (it is on only from the controller's " + + "lease write); the bucket's fill. With a node at stage live, the token sealed to the operator key, asked of the vault at " + + "this moment and relayed as the vault sealed it: the one route by which the operator gets a token, which this seat never " + + "reads and keeps nowhere. With history=true the ended enrolments its events say. A machine stuck at redeeming shows here " + + "with its age; revoke ends it. Read-only.", + Input: obj(map[string]any{"node": str("one node"), "history": str("\"true\": the ended enrolments")}), + Run: func(a map[string]any) (any, error) { + return h.Tokens(arg(a, "node"), flag(a, "history")) + }}, + } +} diff --git a/modules/mesh-enrolment/cmd/mesh-enrolment/vault.go b/modules/mesh-enrolment/cmd/mesh-enrolment/vault.go new file mode 100644 index 0000000..b96269f --- /dev/null +++ b/modules/mesh-enrolment/cmd/mesh-enrolment/vault.go @@ -0,0 +1,70 @@ +package main + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "time" + + "git.novox.be/novox/mesh-sdk/go/delivery" +) + +// Seats the holder calls (ADR 0587 §3, §5, §7). The vault's two verbs are the ones its manifest names this seat the only +// caller of; its `sign` names this seat for a node's certificate and a redeem answer. +const ( + vaultToken = "seat:mesh-vault.enrolment-token" + vaultAccount = "seat:mesh-vault.node-account" + vaultSign = "seat:mesh-vault.sign" + placementAdd = "seat:mesh-placement.propose" +) + +// Call asks a seat verb by its address (stdio.Ask through the runtime, or a test's). +type Call func(key string, body any) (json.RawMessage, error) + +// vaultOver is the vault as the holder reaches it: the seat's verbs over a Call. +type vaultOver struct{ call Call } + +func (v vaultOver) Token(node string, expires time.Time) (json.RawMessage, error) { + return v.call(vaultToken, map[string]any{"node": node, "expires": expires.UTC().Format(time.RFC3339)}) +} + +func (v vaultOver) Sealed(node string) (json.RawMessage, error) { + return v.call(vaultToken, map[string]any{"node": node, "sealed": true}) +} + +func (v vaultOver) Drop(node string) error { + _, err := v.call(vaultToken, map[string]any{"node": node, "drop": true}) + return err +} + +func (v vaultOver) Account(node, sealingKey, keysDigest string) (json.RawMessage, error) { + return v.call(vaultAccount, map[string]any{"node": node, "sealing_key": sealingKey, "keys_digest": keysDigest}) +} + +func (v vaultOver) Retire(node string) error { + _, err := v.call(vaultAccount, map[string]any{"node": node, "retire": true}) + return err +} + +func (v vaultOver) SignCertificate(node, servingKey string) (json.RawMessage, error) { + return v.call(vaultSign, map[string]any{"kind": delivery.KindNodeCertificate, "node": node, "serving_key": servingKey}) +} + +func (v vaultOver) SignReply(node string, reply []byte) (json.RawMessage, error) { + return v.call(vaultSign, map[string]any{"kind": delivery.KindEnrolment, "node": node, + "reply": base64.StdEncoding.EncodeToString(reply)}) +} + +// addNodeOver asks mesh-placement to add the node with the issue's warrant (ADR 0587 §7). +func addNodeOver(call Call) func(AddNode) error { + return func(a AddNode) error { + _, err := call(placementAdd, map[string]any{ + "why": a.Why, + "changes": []any{map[string]any{"op": "add-node", "node": a.Node, "address": a.Address, "keys": a.Keys, "warrant": a.Warrant}}, + }) + if err != nil { + return fmt.Errorf("mesh-placement did not take the node %s: %w", a.Node, err) + } + return nil + } +} diff --git a/modules/mesh-enrolment/go.mod b/modules/mesh-enrolment/go.mod new file mode 100644 index 0000000..03fe8f2 --- /dev/null +++ b/modules/mesh-enrolment/go.mod @@ -0,0 +1,16 @@ +module mesh-enrolment + +go 1.26.0 + +require ( + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101151945-b2a27ed5cec4 + github.com/nats-io/nats.go v1.54.0 +) + +require ( + github.com/klauspost/compress v1.20.0 // indirect + github.com/nats-io/nkeys v0.4.16 // indirect + github.com/nats-io/nuid v1.0.1 // indirect + golang.org/x/crypto v0.57.0 // indirect + golang.org/x/sys v0.48.0 // indirect +) diff --git a/modules/mesh-enrolment/go.sum b/modules/mesh-enrolment/go.sum new file mode 100644 index 0000000..1c91827 --- /dev/null +++ b/modules/mesh-enrolment/go.sum @@ -0,0 +1,26 @@ +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101151945-b2a27ed5cec4 h1:xeuMBwOHNKrVUdGVg7Bdv5tkdueBvngwJFyk6n10rp0= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101151945-b2a27ed5cec4/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= +github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= +github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= +github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= +github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA= +github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= +github.com/minio/highwayhash v1.0.4 h1:asJizugGgchQod2ja9NJlGOWq4s7KsAWr5XUc9Clgl4= +github.com/minio/highwayhash v1.0.4/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ= +github.com/nats-io/jwt/v2 v2.8.1 h1:V0xpGuD/N8Mi+fQNDynXohVvp7ZztevW5io8CUWlPmU= +github.com/nats-io/jwt/v2 v2.8.1/go.mod h1:nWnOEEiVMiKHQpnAy4eXlizVEtSfzacZ1Q43LIRavZg= +github.com/nats-io/nats-server/v2 v2.11.17 h1:GKEghcFK6A+aFx11Yf1LjgLC3txAwvyhnYzhBIQZA8I= +github.com/nats-io/nats-server/v2 v2.11.17/go.mod h1:B1sFVz4StNosQ903ak4N1G01Fl/9f8e06mXpFIE2K24= +github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA= +github.com/nats-io/nats.go v1.54.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k= +github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg= +github.com/nats-io/nkeys v0.4.16/go.mod h1:llLgWoI0o4z/Q57q2R1kHfmocyhGV6VG/U18Glg1Afs= +github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= +github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= +golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= +golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= diff --git a/modules/mesh-enrolment/module.json b/modules/mesh-enrolment/module.json new file mode 100644 index 0000000..4504a55 --- /dev/null +++ b/modules/mesh-enrolment/module.json @@ -0,0 +1,88 @@ +{ + "module": "mesh-enrolment", + "version": "1", + "runs-as": "mesh-enrolment", + "claims": [ + { + "name": "mesh-enrolment", + "scope": "mesh", + "serves": [ + "issue", + "redeem", + "confirm", + "revoke", + "tokens", + "again" + ] + } + ], + "consumes": [ + "mesh-enrolment.node-enrolled", + "mesh-enrolment.enrolment-refused", + "mesh-enrolment.enrolment-revoked", + "mesh-enrolment.enrolment-expired" + ], + "uses": [ + "operator-channel" + ], + "invokes": [ + "seat:mesh-hand-acts.hand-act", + "seat:mesh-hand-acts.warranted", + "seat:mesh-placement.propose", + "seat:mesh-vault.enrolment-token", + "seat:mesh-vault.node-account", + "seat:mesh-vault.sign" + ], + "reads": [ + "mesh-controller.lease", + "mesh-delivery-assigner.placement", + "mesh-grants.user-list", + "node-engine.have" + ], + "state": [ + { + "name": "enrolments", + "key-ttl": true + }, + { + "name": "asked", + "history": 5 + } + ], + "own-secrets": { + "broker": "${dir:state}/broker" + }, + "secrets-owner": "mesh-enrolment", + "resources": [ + { + "id": "account", + "type": "user", + "name": "mesh-enrolment", + "shell": "/usr/bin/nologin", + "home": "/var/lib/mesh-enrolment" + }, + { + "id": "state", + "type": "directory", + "mode": "0700", + "owner": "mesh-enrolment", + "place": ".", + "unknown": "program" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/mesh-enrolment", + "binary": "mesh-enrolment", + "loads": [ + "mesh-enrolment" + ] + } + ] + } +}