Add the module mesh-enrolment: the seat's skeleton, its bucket of open enrolments and the tokens verb

The holder of the seat mesh-enrolment (ADR 0587 §1, §2, §8) runs as a bundle on its own account, keeps the bucket
enrolments with a time to live per key, and answers tokens: every open enrolment or one node's with stage, expiry,
ask and revision, the bucket's fill, whether issuing is on and why, and for one node at live the token sealed by
the vault and relayed, kept nowhere. Tests added: two (one table-style test of tokens, the manifest test); the
repository is over its budget, paid by merging tests in the assigner's seat tests in 774/S59's commit.

(issue 774, 774/S54)

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
This commit is contained in:
2026-11-02 01:44:46 +01:00
parent 9125fcc712
commit 5d5ee69775
11 changed files with 1268 additions and 0 deletions
@@ -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.<node> and address.<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 <seat>.<event>.
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.<node> (ADR 0587 §4).
enrolPrefix = "enrol."
nodePrefix = "node."
)
// Enrolment is the value of the key node.<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.<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, `<module>.<state>`.
type Reader interface {
Keys(state string) ([]string, error)
Get(state, key string) ([]byte, bool, error)
}
@@ -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.<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
}
@@ -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, `<module>.<state>` 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)
}
@@ -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.<seat>.event.<name>.
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)
}
}
@@ -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
}
@@ -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)
}
}
@@ -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"))
}},
}
}
@@ -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
}
}
+16
View File
@@ -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
)
+26
View File
@@ -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=
+88
View File
@@ -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"
]
}
]
}
}