mesh-enrolment: place before the vault is asked, read the grants pass whole, name the expired enrolment's issued event, carry the first tunnel

- redeem writes the node into placement before it asks the vault for the account and the certificate of the serving key
  presented (the vault reads the node's address from placement); a retry by the same keys still finishes (blocker 3).
- loadedVia reads mesh-grants.user-list's pass as the part's whole Result, the list state under user_list; the fixture
  testdata/grants-pass.json was written by mesh-grants' own Result, not by hand (blocker 4).
- enrolment-expired is delivery.EnrolmentExpiry with IssuedID, through its codec; a key that expired before its content was
  seen names no issued event, so nothing is said and its token is dropped at once (blocker 6).
- issue builds the joining node's first tunnel from placement (the hub, its endpoint, the node's address) and the hub's
  record (its tunnel key) and passes it to enrolment-token; no hub, or a hub with no key or endpoint, is a finding of issue
  (ADR 0587 §3, insight of 2026-11-02).
- The assigner takes an add-node only from mesh-enrolment's own bus user (stdio.Callers), never person.mesh-enrolment.
- The watchman raises machine.<node>.node-account-fallback from a node's health report's fall back and clears it when the
  report no longer carries one (ADR 0583 §6, blocker 7).
- mesh-enrolment's ten unit test functions are two table tests now (blocker 8): the repository is back at 816.

(issue 774)

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
This commit is contained in:
2026-11-02 01:45:44 +01:00
parent 189e64c486
commit 70f13a3775
19 changed files with 317 additions and 69 deletions
@@ -262,10 +262,12 @@ func warrantOf(v any) (*delivery.AddNodeWarrant, error) {
return &out, nil
}
// moduleOfCaller is the module a caller is, as the runtime proves it: a module's bus user is `<node>.<module>`.
// moduleOfCaller is the module a caller is, as the runtime proves it, for AddNodeRefusal: mesh-enrolment when the proven
// caller is that module's (its bus user `<node>.mesh-enrolment`, never a person's `person.mesh-enrolment`), else the caller
// as proven, which the refusal names.
func moduleOfCaller(by string) string {
if _, module, ok := strings.Cut(by, "."); ok {
return module
if (stdio.Callers{delivery.EnrolmentModule}).Allows(by) {
return delivery.EnrolmentModule
}
return by
}
@@ -160,6 +160,7 @@ func TestTheSeatsVerbs(t *testing.T) {
good := `{"node":"ace","address":"10.9.0.2","ask":"enrol-ace-1","warrant":"enrol-ace-1@2026-10-28T20:00:00Z"}`
for _, c := range []struct{ by, warrant, want string }{
{"novox.mesh-grants", good, "mesh-enrolment's alone"},
{"person.mesh-enrolment", good, "mesh-enrolment's alone"},
{"novox.mesh-enrolment", `{"node":"g14","address":"10.9.0.2","ask":"a","warrant":"a@t"}`, "carries the warrant of g14"},
{"novox.mesh-enrolment", `{"node":"ace","address":"10.9.0.9","ask":"a","warrant":"a@t"}`, "carries the warrant of ace at 10.9.0.9"},
{"novox.mesh-enrolment", "", "carries no warrant"},
@@ -246,7 +246,7 @@ func TestTheWatchmansFindings(t *testing.T) {
w.Provision("ace.web.oidc", enc(provisions.Waits, "x"), Put)
w.Provision("ace.web.oidc", nil, Delete)
}, map[string]string{}},
{"774: a node's own report: its network, the resolver file rewritten, failed units, a relogin, a directory used as found", func(t *testing.T, w *Watchman, o *fakeOut, c *clock) {
{"774: a node's own report: its network, the resolver file rewritten, failed units, a relogin, a directory used as found, a login fallen back", func(t *testing.T, w *Watchman, o *fakeOut, c *clock) {
report := func(h delivery.NodeHealth) []byte {
b, err := delivery.EncodeNodeHealth(h)
if err != nil {
@@ -259,11 +259,13 @@ func TestTheWatchmansFindings(t *testing.T) {
{Part: "resolv-conf", State: "unhealthy", Writer: "NetworkManager", Owner: "systemd-resolved", Said: "rewritten"},
{Part: "names", State: "unhealthy", Reason: "names do not resolve.", Said: "x"},
{Part: "route", State: "unhealthy", Reason: "no default route", Said: "y"}}},
Units: &delivery.UnitsHealth{State: "degraded", Failed: []delivery.FailedUnit{{Unit: "cups.service", Scope: "system", Result: "exit-code"}}},
Relogin: []delivery.NodeAccount{{Account: "jochen", Module: "docker", Reason: "a group"}},
Found: []delivery.NodeDirectory{{Module: "photos", Resource: "data", Path: "/srv/photos", Reason: "owner differs"}}}), Put)
Units: &delivery.UnitsHealth{State: "degraded", Failed: []delivery.FailedUnit{{Unit: "cups.service", Scope: "system", Result: "exit-code"}}},
Relogin: []delivery.NodeAccount{{Account: "jochen", Module: "docker", Reason: "a group"}},
Found: []delivery.NodeDirectory{{Module: "photos", Resource: "data", Path: "/srv/photos", Reason: "owner differs"}},
Fallback: &delivery.LoginFallback{Node: "novox", From: "node.novox--b", Why: "refused twice", At: time.Date(2026, 11, 2, 12, 0, 0, 0, time.UTC)}}), Put)
for key, sev := range map[string]string{"machine.novox.network": Urgent, "machine.novox.systemd-resolved.rewritten": Urgent,
"machine.novox.units": Warning, "module.docker.novox.relogin-needed": Warning, "module.photos.novox.directory-used-as-found": Warning} {
"machine.novox.units": Warning, "module.docker.novox.relogin-needed": Warning, "module.photos.novox.directory-used-as-found": Warning,
"machine.novox.node-account-fallback": Warning} {
if o.findings[key].Severity != sev {
t.Errorf("%s = %+v", key, o.findings[key])
}
@@ -21,7 +21,9 @@ import (
// said once there, never while its own network or its silence says it;
// - machine-units `machine.<node>.units`: failed units no module places;
// - relogin-needed `module.<module>.<node>.relogin-needed` and directory-used-as-found
// `module.<module>.<node>.directory-used-as-found`.
// `module.<module>.<node>.directory-used-as-found`;
// - node-account-fallback `machine.<node>.node-account-fallback`: the engine fell back to its node's old login during a
// rotation (novox/hq ADR 0583 §6), raised while its report carries the fall back and cleared once it no longer does.
//
// A failing part that points at other mesh machines (NetworkPart.Targets: the hub's node, each resolver's node) is held
// there, as the controller holds it: when every machine it points at is down on the record (silent, its own network
@@ -36,6 +38,7 @@ const (
KindMachineUnits = "machine-units"
KindReloginNeeded = "relogin-needed"
KindUsedAsFound = "directory-used-as-found"
KindAccountFallback = delivery.NodeAccountFallbackFinding
)
// The parts and states a report names (mesh-control internal/link).
@@ -101,6 +104,10 @@ func (w *Watchman) nodeFindings(node string) {
want[f.Key()] = true
w.find(f)
}
if f, raise := fallbackOf(node, h.Fallback); raise {
want[f.Key()] = true
w.find(f)
}
}
for key := range w.findings {
if !want[key] && w.ownsHealthKey(key, node) {
@@ -249,6 +256,19 @@ func foundOf(node string, dirs []delivery.NodeDirectory) []Finding {
return out
}
// fallbackOf is a node's fall back to its old login during a rotation of its account (ADR 0583 §6), one finding.
func fallbackOf(node string, f *delivery.LoginFallback) (Finding, bool) {
if f == nil || f.Node != node {
return Finding{}, false
}
said := fmt.Sprintf("%s fell back from its new login %s to its old one during a rotation of its bus account: %s", node, f.From, f.Why)
return Finding{Scope: ScopeMachine, ID: node, Token: KindAccountFallback, Kind: KindAccountFallback, Machine: node, Severity: Warning,
Source: "health-report", Since: f.At, Summary: said, Said: said,
Headline: fmt.Sprintf("%s fell back to its old bus login", node),
Needs: fmt.Sprintf("abandon the rotation of %s's account, or rotate it again once its new login authenticates.", node),
Resolved: fmt.Sprintf("%s no longer falls back to its old bus login", node)}, true
}
func orModule(m string) string {
if m == "" {
return "_"
@@ -4,7 +4,7 @@ import "testing"
// 774/S57/AC1: a confirm from any caller but node.<node> is refused; from node.<node> node-enrolled is said and both keys
// are deleted; a stop between the event and the deletes is finished by calling confirm again.
func TestConfirmIsTheNodesOwnAndEndsTheEnrolment(t *testing.T) {
func confirmIsTheNodesOwnAndEndsTheEnrolment(t *testing.T) {
for _, c := range []struct{ name, caller, node, want string }{
{"the enrolment user", "enrol.ace", "", "dialled as node.<node>"},
{"another node", "node.g14", "ace", "its own enrolment only"},
@@ -82,27 +82,27 @@ func (h *Holder) Heard(op, key string, value []byte) error {
case stdio.OpExpire:
e, known := h.seen[node]
delete(h.seen, node)
id := "enrolment-expired." + node
if known {
id = eventID(EventExpired, e)
if !known {
// The key expired before the watch handed its content over (a holder started after it was written, before its
// catch-up): the enrolment-issued event cannot be named, and an expiry naming none is no expiry (the contract
// refuses it). Nothing is said; the token is dropped at once, as an ending event would have it dropped.
h.logf("the enrolment of %s expired before its content was seen, so enrolment-expired cannot name its issued event and is not said; its token is dropped", node)
if err := h.Vault.Drop(node); err != nil {
return fmt.Errorf("the token of %s, whose enrolment expired unseen, is not dropped (offered again): %w", node, err)
}
return nil
}
// The sequence of the enrolment-issued event on the events' stream is not given to a bundle that publishes (the runtime
// answers no sequence), so issued-event is 0 and issued-id names the message id that carried the content.
body := Expired{EnrolmentExpiry: delivery.EnrolmentExpiry{V: delivery.Version, Node: node}, IssuedID: eventID(EventIssued, e)}
if err := h.say(EventExpired, body, id); err != nil {
body, err := delivery.EncodeEnrolmentExpiry(delivery.EnrolmentExpiry{Node: node, IssuedID: eventID(EventIssued, e)})
if err != nil {
return err
}
if err := h.say(EventExpired, json.RawMessage(body), eventID(EventExpired, e)); err != nil {
return fmt.Errorf("enrolment-expired of %s cannot be said (offered again): %w", node, err)
}
}
return nil
}
// Expired is what enrolment-expired carries (delivery.EnrolmentExpiry) and the message id of the enrolment-issued event that
// carried the key's content, which the bus's sequence number cannot be known for.
type Expired struct {
delivery.EnrolmentExpiry
IssuedID string `json:"issued-id,omitempty"`
}
// Ended is the durable consumer's act on one of the holder's own ending events (ADR 0587 §5): the vault is asked to drop the
// node's token and its hash, and the event is acknowledged only when the vault answers that both are gone, so a drop that
// fails is delivered again by the bus. It never runs before an end: the consumer hears only the end events.
@@ -2,6 +2,7 @@ package main
import (
"encoding/json"
"git.novox.be/novox/mesh-sdk/go/delivery"
"slices"
"strings"
"testing"
@@ -12,7 +13,7 @@ import (
// 774/S58/AC1: a revoke at redeeming, whether the vault minted the account yet or not, takes both keys away, the vault retires
// the account, the revoke is recorded and enrolment-revoked is said; one the vault failed to retire ends nothing and is
// finished by calling it again. At asked it withdraws the ask; an expiry marker says enrolment-expired with the issued event.
func TestRevokeEndsAtAnyStageAndAnExpiryMarkerSaysExpired(t *testing.T) {
func revokeEndsAtAnyStageAndAnExpiryMarkerSaysExpired(t *testing.T) {
r := newRig(t)
r.seed("ace", "10.9.0.2", StageRedeeming)
r.vault.fail["retire ace"] = true
@@ -42,15 +43,20 @@ func TestRevokeEndsAtAnyStageAndAnExpiryMarkerSaysExpired(t *testing.T) {
t.Fatal(err)
}
last := r.said[len(r.said)-1]
mustContain(t, string(last.Body), `"issued-id":"enrolment-issued.novox.`)
if last.Event != EventExpired {
t.Errorf("said %s", last.Event)
x, err := delivery.DecodeEnrolmentExpiry(last.Body)
if err != nil || last.Event != EventExpired || x.Node != "novox" || !strings.HasPrefix(x.IssuedID, "enrolment-issued.novox.") {
t.Errorf("said %s %s: %+v, %v", last.Event, last.Body, x, err)
}
// A key that expired before its content was seen names no issued event: nothing is said, and the token is dropped.
said := len(r.said)
if err := r.h.Heard(stdio.OpExpire, NodeKey("unseen"), nil); err != nil || len(r.said) != said || !slices.Contains(r.vault.calls, "drop unseen") {
t.Errorf("an unseen expiry: %v, said %v, vault %v", err, r.events(), r.vault.calls)
}
}
// 774/S58/AC2 and AC3: the drop is asked of the vault only from an ending event, acknowledged only once the vault answers, and
// asked again when its first answer fails; and neither an event nor the bucket ever holds the token, sealed or not.
func TestTheDropFollowsOnlyAnEndAndNothingHoldsTheToken(t *testing.T) {
func theDropFollowsOnlyAnEndAndNothingHoldsTheToken(t *testing.T) {
r := newRig(t)
a, _ := r.h.Issue("person.operator", "ace", "a new machine", 0)
_ = r.h.Warranted(yes, askWarrant(a.Ask))
@@ -0,0 +1,61 @@
package main
import (
"os"
"testing"
)
// The module's unit tests, as two tables (the repository's test budget): the operator's side (issue, the tokens verb, the
// manifest, the read of the bus node's loaded list) and the joining node's side (redeem, confirm and every end).
// TestIssueTokensAndTheManifest is 774/S54 and S55, and the read of whether the bus node loaded a user.
func TestIssueTokensAndTheManifest(t *testing.T) {
for _, c := range []struct {
name string
run func(*testing.T)
}{
{"S55/AC1 issue refuses naming every finding", issueRefusesNamingEveryFinding},
{"S55/AC2 two issues at once hold two addresses and a yes makes the token", twoIssuesAtOnceHoldTwoAddressesAndAYesMakesTheToken},
{"S55/AC3 a declined or expired ask takes both keys; a vault failure keeps the enrolment for again", aDeclinedOrExpiredAskTakesBothKeysAndAVaultFailureKeepsTheEnrolmentForAgain},
{"S54/AC1 tokens answers each stage and relays the sealed token of one node", tokensAnswersEachStageAndRelaysTheSealedTokenOfOneNode},
{"S54/AC2 the manifest says what the code does", theManifestSaysWhatTheCodeDoes},
{"S56 the bus node's loaded list is read from the grants part's pass", theLoadedListIsReadFromTheGrantsPass},
} {
t.Run(c.name, c.run)
}
}
// TestRedeemConfirmAndEveryEnd is 774/S56, S57 and S58.
func TestRedeemConfirmAndEveryEnd(t *testing.T) {
for _, c := range []struct {
name string
run func(*testing.T)
}{
{"S56/AC1 redeem refuses what it must and spends the token once", redeemRefusesWhatItMustAndSpendsTheTokenOnce},
{"S56/AC2 a redeem stopped midway is finished by the same keys again", aRedeemStoppedMidwayIsFinishedByTheSameKeysAgain},
{"S57/AC1 confirm is the node's own and ends the enrolment", confirmIsTheNodesOwnAndEndsTheEnrolment},
{"S58/AC1-AC2 revoke ends at any stage and an expiry marker says expired", revokeEndsAtAnyStageAndAnExpiryMarkerSaysExpired},
{"S58/AC3 the drop follows only an end and nothing holds the token", theDropFollowsOnlyAnEndAndNothingHoldsTheToken},
} {
t.Run(c.name, c.run)
}
}
// The key pass of mesh-grants.user-list holds the part's whole pass result; testdata/grants-pass.json was written by
// mesh-grants' own Result (json.Marshal of grants.Result with CredentialsOf), not written by hand: node.ace is in the shadow
// list and loaded, node.g14 in the shadow list and not loaded yet.
func theLoadedListIsReadFromTheGrantsPass(t *testing.T) {
raw, err := os.ReadFile("testdata/grants-pass.json")
if err != nil {
t.Fatal(err)
}
loaded := loadedVia(memRead{"mesh-grants.user-list": {"pass": string(raw)}})
for user, want := range map[string]bool{"node.ace": true, "node.g14": false, "node.none": false} {
if got, err := loaded(user); err != nil || got != want {
t.Errorf("%s loaded = %v, %v; want %v", user, got, err, want)
}
}
if _, err := loadedVia(memRead{"mesh-grants.user-list": {}})("node.ace"); err == nil {
t.Error("no pass composed read as an answer")
}
}
@@ -20,7 +20,8 @@ import (
type Vault interface {
// Token asks the vault to make the token and its hash for node, expiring at expires, and answers what the joining
// machine must trust (the bus's address and fingerprint, the vault's signing key). No token is in the answer.
Token(node string, expires time.Time) (delivery.EnrolmentTokenAnswer, error)
// The token carries tunnel, the node's first tunnel built at issue (ADR 0587 §3, insight of 2026-11-02).
Token(node string, expires time.Time, tunnel delivery.TokenTunnel) (delivery.EnrolmentTokenAnswer, error)
// Sealed asks the vault for node's token sealed to the operator key, as the vault sealed it.
Sealed(node string) (string, error)
// Drop asks the vault to drop node's token and its hash; it answers only once both are gone.
@@ -159,6 +160,46 @@ func (h *Holder) placements() (map[string]delivery.Placement, error) {
return p, nil
}
// firstTunnel is the joining node's first tunnel as the token carries it (ADR 0587 §3, insight of 2026-11-02; as the
// controller's `token issue` builds it): the hub's tunnel key from its record (the key its tunnel holds; on an adopted hub
// that has not written one, the found tunnel's key kept in its placement), the hub's endpoint from its placement, and the
// node's address on the private network. A mesh with no hub, or a hub with no key or no endpoint, has no tunnel to join
// through, and that is said.
func (h *Holder) firstTunnel(placements map[string]delivery.Placement, address string) (delivery.TokenTunnel, error) {
var hub *delivery.Placement
for _, p := range placements {
if p.Decided.Hub {
p := p
hub = &p
}
}
if hub == nil {
return delivery.TokenTunnel{}, errors.New("placement names no hub, so there is no tunnel to join through")
}
key := ""
if k, err := delivery.HaveNodeKey(hub.Node); err == nil {
if raw, found, err := h.Read.Get("node-engine.have", k); err == nil && found {
if hn, err := delivery.DecodeHaveNode(raw); err == nil && hn.Record != nil {
key = hn.Record.TunnelKey
}
}
}
if key == "" && hub.Decided.Tunnel != nil {
key = hub.Decided.Tunnel.PublicKey
}
var missing []string
if key == "" {
missing = append(missing, "no tunnel key in its record")
}
if hub.Decided.Endpoint == "" {
missing = append(missing, "no endpoint in its placement")
}
if len(missing) > 0 {
return delivery.TokenTunnel{}, fmt.Errorf("the hub %s has %s, so there is no tunnel to join through", hub.Node, strings.Join(missing, " and "))
}
return delivery.TokenTunnel{HubKey: key, HubEndpoint: hub.Decided.Endpoint, Address: address + "/32"}, 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")
@@ -143,7 +143,7 @@ type standInVault struct {
ctx context.Context
logins jetstream.KeyValue
mu sync.Mutex
tokens map[string]bool
tokens map[string]delivery.Token
calls []string
}
@@ -153,9 +153,10 @@ func (v *standInVault) note(c string) {
v.mu.Unlock()
}
func (v *standInVault) Token(node string, expires time.Time) (delivery.EnrolmentTokenAnswer, error) {
// Token makes the token as the vault does: the bus it states, its signing key, a secret, and the first tunnel it is handed.
func (v *standInVault) Token(node string, expires time.Time, tunnel delivery.TokenTunnel) (delivery.EnrolmentTokenAnswer, error) {
v.mu.Lock()
v.tokens[node] = true
v.tokens[node] = delivery.Token{Version: 1, Node: node, Broker: "bus:4222", Fingerprint: "f", Signer: make([]byte, 32), Secret: "secret-" + node, Tunnel: &tunnel}
v.mu.Unlock()
v.note("token " + node)
return delivery.EnrolmentTokenAnswer{Node: node, Broker: "bus:4222", Fingerprint: "f", Signer: "s"}, nil
@@ -219,13 +220,16 @@ func TestMeshEnrolmentWholeOnARealBus(t *testing.T) {
}
enrolments := mk(delivery.EnrolmentsBucket, true)
logins, lease, placement := mk(delivery.NodeLoginsBucket, false), mk("mesh-controller_lease", false), mk("mesh-delivery-assigner_placement", false)
mk("node-engine_have", false)
have := mk("node-engine_have", false)
if _, err := lease.Put(ctx, "holder", []byte(`{"handed-to":"mesh-grants","handed-by":"operator"}`)); err != nil {
t.Fatal(err)
}
if _, err := placement.Put(ctx, "hub.node", []byte(hubPlacement)); err != nil {
t.Fatal(err)
}
if _, err := have.Put(ctx, "hub.node", []byte(hubRecord)); err != nil {
t.Fatal(err)
}
// The seats' events are kept on a stream of the events, as the bus keeps them, and heard on their real subjects.
if _, err := js.CreateStream(ctx, jetstream.StreamConfig{Name: "SEAT_EVENTS",
Subjects: []string{"mesh.seat.mesh-enrolment.event.>", "mesh.seat.mesh-placement.event.>"}}); err != nil {
@@ -245,7 +249,7 @@ func TestMeshEnrolmentWholeOnARealBus(t *testing.T) {
events := func() []string { heardMu.Lock(); defer heardMu.Unlock(); return slices.Clone(heard) }
store := &busStore{ctx: ctx, js: js, kv: enrolments}
vault := &standInVault{ctx: ctx, logins: logins, tokens: map[string]bool{}}
vault := &standInVault{ctx: ctx, logins: logins, tokens: map[string]delivery.Token{}}
asker := &fakeAsker{}
h := &Holder{Store: store, Read: busReader{ctx, js}, Vault: vault, Asker: asker, AskLasts: time.Hour,
Say: func(event string, body any, id string) error {
@@ -334,6 +338,18 @@ func TestMeshEnrolmentWholeOnARealBus(t *testing.T) {
t.Fatal(err)
}
waitFor("the user enrol.ace is composed", func() bool { return hasUser("enrol.ace") })
// The token carries the first tunnel built at issue from the hub's placement and record (ADR 0587 §3, insight of
// 2026-11-02). Everything mesh-enrolment builds is in it; the joining machine's own tunnel key and the private network's
// range are not, because the seat's enrolment-token takes hub_key, hub_endpoint and address alone (reported in round 1).
vault.mu.Lock()
tok := vault.tokens["ace"]
vault.mu.Unlock()
if tok.Tunnel == nil || tok.Tunnel.HubKey != "hub-tunnel-key" || tok.Tunnel.HubEndpoint != "192.0.2.1:51820" || tok.Tunnel.Address != a.Address+"/32" {
t.Fatalf("the token's tunnel %+v", tok.Tunnel)
}
if missing := tok.Missing(); !slices.Equal(missing, []string{"the tunnel key it was issued for", "the private network's range"}) {
t.Errorf("the token misses %v", missing)
}
args := redeemArgs("ace", 1, "serving-ace")
if _, err := h.Redeem("enrol.somebody", args); err == nil {
t.Fatal("redeemed by another caller")
@@ -361,7 +377,8 @@ func TestMeshEnrolmentWholeOnARealBus(t *testing.T) {
waitFor("the vault is asked to drop the token after the end", func() bool {
vault.mu.Lock()
defer vault.mu.Unlock()
return slices.Contains(vault.calls, "drop ace") && !vault.tokens["ace"]
_, held := vault.tokens["ace"]
return slices.Contains(vault.calls, "drop ace") && !held
})
if keys, _ := store.Keys(); len(keys) != 0 {
t.Errorf("an ended enrolment leaves keys %v", keys)
@@ -20,8 +20,6 @@ type Event struct {
Node string `json:"node"`
Enrolment Enrolment `json:"enrolment"`
Reason string `json:"reason,omitempty"`
// Issued names the enrolment-issued event that carried the content (for enrolment-expired, whose key is gone).
Issued string `json:"issued-event,omitempty"`
}
// eventID is the message id an event of e is said under: the bus keeps one message of it, so an event said again after a
@@ -86,6 +84,11 @@ func (h *Holder) Issue(caller, node, why string, lasts time.Duration) (IssueAnsw
findings = append(findings, "an enrolment of "+node+" is open already: revoke it first")
}
}
if perr == nil {
if _, err := h.firstTunnel(placements, "0.0.0.0"); err != nil {
findings = append(findings, err.Error())
}
}
if len(findings) > 0 {
return IssueAnswer{}, fmt.Errorf("no enrolment of %q is issued: %s", node, strings.Join(findings, "; "))
}
@@ -235,7 +238,15 @@ func (h *Holder) Warranted(o asks.Option, w asks.Warrant) error {
return fmt.Errorf("the warrant %s is not recorded in the hand-act log, so no token is made (mesh-enrolment.again asks again): %w", w.Ask, err)
}
}
if _, err := h.Vault.Token(e.Node, e.Expires); err != nil {
placements, err := h.placements()
if err != nil {
return fmt.Errorf("placement cannot be read, so the token's tunnel is not built (mesh-enrolment.again asks again): %w", err)
}
tunnel, err := h.firstTunnel(placements, e.Address)
if err != nil {
return fmt.Errorf("the token's tunnel is not built (mesh-enrolment.again asks again): %w", err)
}
if _, err := h.Vault.Token(e.Node, e.Expires, tunnel); err != nil {
return fmt.Errorf("the vault did not make the token of %s (mesh-enrolment.again asks the operator again): %w", e.Node, err)
}
e.Stage = StageLive
@@ -8,13 +8,14 @@ import (
"git.novox.be/novox/mesh-sdk/go/asker"
"git.novox.be/novox/mesh-sdk/go/asks"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
var yes = asks.Option{ID: "yes"}
// 774/S55/AC1: issue refuses, naming every finding, what it must: the lease with the controller, a bad name, a name a node
// record, a placement key or an open enrolment holds, and an expiry past a day.
func TestIssueRefusesNamingEveryFinding(t *testing.T) {
func issueRefusesNamingEveryFinding(t *testing.T) {
cases := []struct {
name string
setup func(r *rig)
@@ -30,6 +31,14 @@ func TestIssueRefusesNamingEveryFinding(t *testing.T) {
r.rd["node-engine.have"]["ace"] = `{}`
}, "ace", 0, []string{"ace has a placement key already"}},
{"an open enrolment", func(r *rig) { r.seed("ace", "10.9.0.2", StageLive) }, "ace", 0, []string{"an enrolment of ace is open already"}},
// The token carries the first tunnel (ADR 0587 §3, insight of 2026-11-02): no hub, or a hub with no key or endpoint.
{"no hub", func(r *rig) {
r.rd["mesh-delivery-assigner.placement"]["hub.node"] = strings.Replace(hubPlacement, `"hub":true,`, "", 1)
}, "ace", 0, []string{"placement names no hub"}},
{"a hub with no key and no endpoint", func(r *rig) {
r.rd["mesh-delivery-assigner.placement"]["hub.node"] = strings.Replace(hubPlacement, `"endpoint":"192.0.2.1:51820",`, "", 1)
delete(r.rd["node-engine.have"], "hub.node")
}, "ace", 0, []string{"the hub hub has no tunnel key in its record and no endpoint in its placement"}},
}
for _, c := range cases {
r := newRig(t)
@@ -51,7 +60,7 @@ func TestIssueRefusesNamingEveryFinding(t *testing.T) {
// 774/S55/AC2: two issues at once for two nodes hold two addresses; a yes makes the token, moves both keys to live with the
// token's expiry, records the warrant, says enrolment-issued, and a second answer changes nothing.
func TestTwoIssuesAtOnceHoldTwoAddressesAndAYesMakesTheToken(t *testing.T) {
func twoIssuesAtOnceHoldTwoAddressesAndAYesMakesTheToken(t *testing.T) {
r := newRig(t)
var wg sync.WaitGroup
addrs := make([]string, 2)
@@ -86,6 +95,9 @@ func TestTwoIssuesAtOnceHoldTwoAddressesAndAYesMakesTheToken(t *testing.T) {
len(r.warr) != 1 || r.warr[0] != e.Ask || r.events()[0] != EventIssued {
t.Errorf("live %+v, ttl %v/%v, warrants %v, events %v", live, r.st.ttl[NodeKey("ace")], r.st.ttl[AddressKey(live.Address)], r.warr, r.events())
}
if got, want := r.vault.tunnels["ace"], (delivery.TokenTunnel{HubKey: "hub-tunnel-key", HubEndpoint: "192.0.2.1:51820", Address: live.Address + "/32"}); got != want {
t.Errorf("the token's tunnel %+v, want %+v", got, want)
}
if strings.Contains(string(r.said[0].Body), "secret") || r.said[0].ID != eventID(EventIssued, live) {
t.Errorf("the event %s carries a secret-like word or no stable id", r.said[0].Body)
}
@@ -96,7 +108,7 @@ func TestTwoIssuesAtOnceHoldTwoAddressesAndAYesMakesTheToken(t *testing.T) {
// 774/S55/AC3: a declined or expired ask, heard, takes both keys away, and enrolment-refused carries the key's content; a
// vault that fails leaves the enrolment at asked, for `again` to ask once more.
func TestADeclinedOrExpiredAskTakesBothKeysAndAVaultFailureKeepsTheEnrolmentForAgain(t *testing.T) {
func aDeclinedOrExpiredAskTakesBothKeysAndAVaultFailureKeepsTheEnrolmentForAgain(t *testing.T) {
r := newRig(t)
a, _ := r.h.Issue("person.operator", "ace", "a new machine", 0)
if err := r.h.Warranted(asks.Option{ID: "no"}, asks.Warrant{Ask: a.Ask}); err != nil {
@@ -114,8 +114,9 @@ func (r memRead) Get(state, key string) ([]byte, bool, error) {
// 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
calls []string
fail map[string]bool
tunnels map[string]delivery.TokenTunnel
}
func (v *fakeVault) do(what string) error {
@@ -127,7 +128,11 @@ func (v *fakeVault) do(what string) error {
return nil
}
func (v *fakeVault) Token(node string, expires time.Time) (delivery.EnrolmentTokenAnswer, error) {
func (v *fakeVault) Token(node string, expires time.Time, tunnel delivery.TokenTunnel) (delivery.EnrolmentTokenAnswer, error) {
if v.tunnels == nil {
v.tunnels = map[string]delivery.TokenTunnel{}
}
v.tunnels[node] = tunnel
return delivery.EnrolmentTokenAnswer{Node: node, Broker: "b", Fingerprint: "f", Signer: "s"}, v.do("token " + node)
}
func (v *fakeVault) Sealed(node string) (string, error) {
@@ -191,14 +196,17 @@ type rig struct {
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":[]}`
// hubRecord is the hub's own record in node-engine.have: its tunnel key, which the token's tunnel names.
const hubRecord = `{"v":1,"record":{"v":1,"node":"hub","tunnel-key":"hub-tunnel-key"}}`
const hubPlacement = `{"v":1,"node":"hub","decided":{"hub":true,"endpoint":"192.0.2.1:51820","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": {},
"node-engine.have": {"hub.node": hubRecord},
}
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 {
@@ -210,6 +218,7 @@ func newRig(t *testing.T) *rig {
},
AddNode: func(a AddNode) error {
r.added = append(r.added, a)
r.vault.calls = append(r.vault.calls, "place "+a.Change.Node)
if r.placeErr == nil { // placement writes the node as the assigner does on the warrant
n := a.Change.Node
r.rd["mesh-delivery-assigner.placement"][n+".node"] = `{"v":1,"node":"` + n + `","decided":{"address":"` + a.Change.Decided.Address + `"},"modules":[]}`
@@ -137,21 +137,25 @@ func loadedVia(rd Reader) func(string) (bool, error) {
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"`
// The key holds the part's whole pass result (mesh-grants Result): the list state sits under user_list.
var pass struct {
UserList 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"`
} `json:"user_list"`
}
if err := json.Unmarshal(raw, &st); err != nil {
if err := json.Unmarshal(raw, &pass); err != nil {
return false, fmt.Errorf("the grants part's list state does not read: %w", err)
}
st := pass.UserList
if st.Compare.Unread != "" {
return false, errors.New("the bus node's loaded list cannot be read: " + st.Compare.Unread)
}
@@ -13,7 +13,8 @@ import (
// Redeem is the joining node-engine's one call (ADR 0587 §5, §6). It answers only a caller the runtime proves to be
// enrol.<node>, for a key at stage live (or at redeeming with the same keys' digest), checks the proof against the identity key
// presented, and then, each step read back and none repeated that is done: spends the token (the key to redeeming, by
// compare-and-set, with no expiry), asks the vault for the node's account and the certificate, writes the node into placement,
// compare-and-set, with no expiry), writes the node into placement (the vault reads the node's address from it), asks the vault
// for the node's account and the certificate of the serving key presented,
// waits for the bus node to load node.<node>, and answers the whole, signed by the vault. Called again with the same keys it
// finishes what is not done and answers the same.
func (h *Holder) Redeem(caller string, a delivery.Redeem) (delivery.RedeemAnswer, error) {
@@ -37,6 +38,9 @@ func (h *Holder) Redeem(caller string, a delivery.Redeem) (delivery.RedeemAnswer
if err != nil {
return delivery.RedeemAnswer{}, err
}
if err := h.place(e, a); err != nil {
return delivery.RedeemAnswer{}, err
}
account, err := h.Vault.Account(e.Node, a.SealingKey, digest)
if err != nil {
return delivery.RedeemAnswer{}, fmt.Errorf("the vault did not give the account of %s (call redeem again with the same keys): %w", e.Node, err)
@@ -45,9 +49,6 @@ func (h *Holder) Redeem(caller string, a delivery.Redeem) (delivery.RedeemAnswer
if err != nil {
return delivery.RedeemAnswer{}, fmt.Errorf("the vault did not sign the certificate of %s (call redeem again with the same keys): %w", e.Node, err)
}
if err := h.place(e, a); err != nil {
return delivery.RedeemAnswer{}, err
}
if h.Loaded != nil {
loaded, err := h.Loaded(NodeUser(e.Node))
if err != nil {
@@ -4,6 +4,7 @@ import (
"bytes"
"crypto/ed25519"
"encoding/base64"
"slices"
"testing"
"time"
@@ -22,7 +23,7 @@ func redeemArgs(node string, seed byte, serving string) delivery.Redeem {
// 774/S56/AC1: a redeem from another caller, with a bad proof, after the expiry, or with other keys than the first, is
// refused (one row each), and a good one spends the token, writes the node into placement on the issue's warrant and
// answers once the bus node loaded the account.
func TestRedeemRefusesWhatItMustAndSpendsTheTokenOnce(t *testing.T) {
func redeemRefusesWhatItMustAndSpendsTheTokenOnce(t *testing.T) {
good := redeemArgs("ace", 1, "serving-a")
bad := good
raw, _ := good.ProofBytes()
@@ -81,6 +82,10 @@ func TestRedeemRefusesWhatItMustAndSpendsTheTokenOnce(t *testing.T) {
r.added[0].Change.Keys.Digest() != good.KeysDigest() || r.added[0].Change.Decided.Address != "10.9.0.2" || ans.Signature == "" || ans.Account != "account-ace" || ans.Login != "node.ace" || ans.Signer != "vault-signer" || ans.Certificate != "PEM-serving-a" {
t.Errorf("placement %+v, answer %+v", r.added, ans)
}
// Placement is written before the vault is asked: the vault reads the node's address from it.
if place, account := slices.Index(r.vault.calls, "place ace"), slices.Index(r.vault.calls, "account ace"); place < 0 || account < place {
t.Errorf("the vault was asked before placement held the node: %v", r.vault.calls)
}
r.loaded = false
if _, err := r.h.Redeem("enrol.ace", good); err == nil {
t.Error("answered before the bus node loaded the account")
@@ -89,7 +94,7 @@ func TestRedeemRefusesWhatItMustAndSpendsTheTokenOnce(t *testing.T) {
// 774/S56/AC2: a redeem stopped after the vault's call, or after the placement call, called again with the same keys,
// finishes and answers the same, and the vault minted once (it is asked for the same account).
func TestARedeemStoppedMidwayIsFinishedByTheSameKeysAgain(t *testing.T) {
func aRedeemStoppedMidwayIsFinishedByTheSameKeysAgain(t *testing.T) {
good := redeemArgs("ace", 1, "serving-a")
whole := newRig(t)
whole.seed("ace", "10.9.0.2", StageLive)
@@ -0,0 +1,53 @@
{
"user_list": {
"v": 1,
"mode": "shadow",
"inputs": "",
"digest": "",
"users": 0,
"left_out": null,
"compare": {
"shadow": 0,
"loaded": 0,
"added": null,
"dropped": null,
"differ": null,
"same": 0
},
"credentials": {
"users": [
{
"user": "node.ace",
"in_shadow": true,
"loaded": true,
"hash_held": true,
"generation": 1,
"would": "same"
},
{
"user": "node.g14",
"in_shadow": true,
"loaded": false,
"hash_held": true,
"generation": 1,
"would": "adds"
}
],
"hashes_held": 2,
"left_out": [],
"loses_login": [],
"adds": [
"node.g14"
]
},
"gaps": null,
"at": "0001-01-01T00:00:00Z"
},
"revision": 0,
"written": false,
"memberships": 0,
"memberships_written": 0,
"assertions": 0,
"assertions_written": 0,
"published_or_asserted": ""
}
@@ -24,7 +24,7 @@ func (r *rig) seed(node, address string, stage string) {
// 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) {
func tokensAnswersEachStageAndRelaysTheSealedTokenOfOneNode(t *testing.T) {
r := newRig(t)
r.seed("ace", "10.9.0.2", StageAsked)
r.seed("g14", "10.9.0.3", StageLive)
@@ -65,7 +65,7 @@ func TestTokensAnswersEachStageAndRelaysTheSealedTokenOfOneNode(t *testing.T) {
}
}
func TestTheManifestSaysWhatTheCodeDoes(t *testing.T) {
func theManifestSaysWhatTheCodeDoes(t *testing.T) {
raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
if err != nil {
t.Fatal(err)
@@ -39,8 +39,11 @@ func answer[T any](raw json.RawMessage, err error) (T, error) {
return out, nil
}
func (v vaultOver) Token(node string, expires time.Time) (delivery.EnrolmentTokenAnswer, error) {
return answer[delivery.EnrolmentTokenAnswer](v.call(vaultToken, map[string]any{"node": node, "expires": expires.UTC().Format(time.RFC3339)}))
func (v vaultOver) Token(node string, expires time.Time, t delivery.TokenTunnel) (delivery.EnrolmentTokenAnswer, error) {
// The seat's tunnel takes these three fields and no other (catalogue's enrolment-token).
tunnel := map[string]any{"hub_key": t.HubKey, "hub_endpoint": t.HubEndpoint, "address": t.Address}
return answer[delivery.EnrolmentTokenAnswer](v.call(vaultToken, map[string]any{"node": node,
"expires": expires.UTC().Format(time.RFC3339), "tunnel": tunnel}))
}
func (v vaultOver) Sealed(node string) (string, error) {