From 70f13a3775b62fc7fcd0100c33e9d6d89550bf55 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 2 Nov 2026 01:03:04 +0100 Subject: [PATCH] mesh-enrolment: place before the vault is asked, read the grants pass whole, name the expired enrolment's issued event, carry the first tunnel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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-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 --- .../cmd/mesh-delivery-assigner/seat_tools.go | 8 ++- .../mesh-delivery-assigner/seat_tools_test.go | 1 + .../internal/watch/findings_test.go | 12 ++-- .../internal/watch/nodehealth.go | 22 ++++++- .../cmd/mesh-enrolment/confirm_test.go | 2 +- .../mesh-enrolment/cmd/mesh-enrolment/end.go | 28 ++++----- .../cmd/mesh-enrolment/end_test.go | 16 +++-- .../cmd/mesh-enrolment/enrolment_test.go | 61 +++++++++++++++++++ .../cmd/mesh-enrolment/holder.go | 43 ++++++++++++- .../cmd/mesh-enrolment/integration_test.go | 29 +++++++-- .../cmd/mesh-enrolment/issue.go | 17 +++++- .../cmd/mesh-enrolment/issue_test.go | 18 +++++- .../cmd/mesh-enrolment/kit_test.go | 19 ++++-- .../mesh-enrolment/cmd/mesh-enrolment/main.go | 28 +++++---- .../cmd/mesh-enrolment/redeem.go | 9 +-- .../cmd/mesh-enrolment/redeem_test.go | 9 ++- .../mesh-enrolment/testdata/grants-pass.json | 53 ++++++++++++++++ .../cmd/mesh-enrolment/tokens_test.go | 4 +- .../cmd/mesh-enrolment/vault.go | 7 ++- 19 files changed, 317 insertions(+), 69 deletions(-) create mode 100644 modules/mesh-enrolment/cmd/mesh-enrolment/enrolment_test.go create mode 100644 modules/mesh-enrolment/cmd/mesh-enrolment/testdata/grants-pass.json diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/seat_tools.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/seat_tools.go index 2ef4889..8f944dd 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/seat_tools.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/seat_tools.go @@ -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 `.`. +// 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 `.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 } diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/seat_tools_test.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/seat_tools_test.go index 82142d1..f4f80f4 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/seat_tools_test.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/seat_tools_test.go @@ -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"}, diff --git a/modules/mesh-delivery-watchman/internal/watch/findings_test.go b/modules/mesh-delivery-watchman/internal/watch/findings_test.go index d25eec9..e1f83c2 100644 --- a/modules/mesh-delivery-watchman/internal/watch/findings_test.go +++ b/modules/mesh-delivery-watchman/internal/watch/findings_test.go @@ -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]) } diff --git a/modules/mesh-delivery-watchman/internal/watch/nodehealth.go b/modules/mesh-delivery-watchman/internal/watch/nodehealth.go index d3b6dd3..eda9df4 100644 --- a/modules/mesh-delivery-watchman/internal/watch/nodehealth.go +++ b/modules/mesh-delivery-watchman/internal/watch/nodehealth.go @@ -21,7 +21,9 @@ import ( // said once there, never while its own network or its silence says it; // - machine-units `machine..units`: failed units no module places; // - relogin-needed `module...relogin-needed` and directory-used-as-found -// `module...directory-used-as-found`. +// `module...directory-used-as-found`; +// - node-account-fallback `machine..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 "_" diff --git a/modules/mesh-enrolment/cmd/mesh-enrolment/confirm_test.go b/modules/mesh-enrolment/cmd/mesh-enrolment/confirm_test.go index b88928c..89a00b2 100644 --- a/modules/mesh-enrolment/cmd/mesh-enrolment/confirm_test.go +++ b/modules/mesh-enrolment/cmd/mesh-enrolment/confirm_test.go @@ -4,7 +4,7 @@ import "testing" // 774/S57/AC1: a confirm from any caller but node. is refused; from 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."}, {"another node", "node.g14", "ace", "its own enrolment only"}, diff --git a/modules/mesh-enrolment/cmd/mesh-enrolment/end.go b/modules/mesh-enrolment/cmd/mesh-enrolment/end.go index 79e75c2..545f788 100644 --- a/modules/mesh-enrolment/cmd/mesh-enrolment/end.go +++ b/modules/mesh-enrolment/cmd/mesh-enrolment/end.go @@ -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. diff --git a/modules/mesh-enrolment/cmd/mesh-enrolment/end_test.go b/modules/mesh-enrolment/cmd/mesh-enrolment/end_test.go index 9bde32a..f913ed3 100644 --- a/modules/mesh-enrolment/cmd/mesh-enrolment/end_test.go +++ b/modules/mesh-enrolment/cmd/mesh-enrolment/end_test.go @@ -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)) diff --git a/modules/mesh-enrolment/cmd/mesh-enrolment/enrolment_test.go b/modules/mesh-enrolment/cmd/mesh-enrolment/enrolment_test.go new file mode 100644 index 0000000..bb157f8 --- /dev/null +++ b/modules/mesh-enrolment/cmd/mesh-enrolment/enrolment_test.go @@ -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") + } +} diff --git a/modules/mesh-enrolment/cmd/mesh-enrolment/holder.go b/modules/mesh-enrolment/cmd/mesh-enrolment/holder.go index f6dbdde..8e7ef81 100644 --- a/modules/mesh-enrolment/cmd/mesh-enrolment/holder.go +++ b/modules/mesh-enrolment/cmd/mesh-enrolment/holder.go @@ -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") diff --git a/modules/mesh-enrolment/cmd/mesh-enrolment/integration_test.go b/modules/mesh-enrolment/cmd/mesh-enrolment/integration_test.go index 6bf05ad..7ae78d2 100644 --- a/modules/mesh-enrolment/cmd/mesh-enrolment/integration_test.go +++ b/modules/mesh-enrolment/cmd/mesh-enrolment/integration_test.go @@ -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) diff --git a/modules/mesh-enrolment/cmd/mesh-enrolment/issue.go b/modules/mesh-enrolment/cmd/mesh-enrolment/issue.go index 86b414b..969a89d 100644 --- a/modules/mesh-enrolment/cmd/mesh-enrolment/issue.go +++ b/modules/mesh-enrolment/cmd/mesh-enrolment/issue.go @@ -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 diff --git a/modules/mesh-enrolment/cmd/mesh-enrolment/issue_test.go b/modules/mesh-enrolment/cmd/mesh-enrolment/issue_test.go index f94331a..5fcaacb 100644 --- a/modules/mesh-enrolment/cmd/mesh-enrolment/issue_test.go +++ b/modules/mesh-enrolment/cmd/mesh-enrolment/issue_test.go @@ -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 { diff --git a/modules/mesh-enrolment/cmd/mesh-enrolment/kit_test.go b/modules/mesh-enrolment/cmd/mesh-enrolment/kit_test.go index 3ace80b..74be06e 100644 --- a/modules/mesh-enrolment/cmd/mesh-enrolment/kit_test.go +++ b/modules/mesh-enrolment/cmd/mesh-enrolment/kit_test.go @@ -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":[]}` diff --git a/modules/mesh-enrolment/cmd/mesh-enrolment/main.go b/modules/mesh-enrolment/cmd/mesh-enrolment/main.go index a9ae270..5016954 100644 --- a/modules/mesh-enrolment/cmd/mesh-enrolment/main.go +++ b/modules/mesh-enrolment/cmd/mesh-enrolment/main.go @@ -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) } diff --git a/modules/mesh-enrolment/cmd/mesh-enrolment/redeem.go b/modules/mesh-enrolment/cmd/mesh-enrolment/redeem.go index 38f38af..df8760b 100644 --- a/modules/mesh-enrolment/cmd/mesh-enrolment/redeem.go +++ b/modules/mesh-enrolment/cmd/mesh-enrolment/redeem.go @@ -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., 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., 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 { diff --git a/modules/mesh-enrolment/cmd/mesh-enrolment/redeem_test.go b/modules/mesh-enrolment/cmd/mesh-enrolment/redeem_test.go index 9dad26f..81599ff 100644 --- a/modules/mesh-enrolment/cmd/mesh-enrolment/redeem_test.go +++ b/modules/mesh-enrolment/cmd/mesh-enrolment/redeem_test.go @@ -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) diff --git a/modules/mesh-enrolment/cmd/mesh-enrolment/testdata/grants-pass.json b/modules/mesh-enrolment/cmd/mesh-enrolment/testdata/grants-pass.json new file mode 100644 index 0000000..1b04918 --- /dev/null +++ b/modules/mesh-enrolment/cmd/mesh-enrolment/testdata/grants-pass.json @@ -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": "" +} \ No newline at end of file diff --git a/modules/mesh-enrolment/cmd/mesh-enrolment/tokens_test.go b/modules/mesh-enrolment/cmd/mesh-enrolment/tokens_test.go index ab4b890..75782fd 100644 --- a/modules/mesh-enrolment/cmd/mesh-enrolment/tokens_test.go +++ b/modules/mesh-enrolment/cmd/mesh-enrolment/tokens_test.go @@ -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) diff --git a/modules/mesh-enrolment/cmd/mesh-enrolment/vault.go b/modules/mesh-enrolment/cmd/mesh-enrolment/vault.go index 7ff7953..8ef9ff9 100644 --- a/modules/mesh-enrolment/cmd/mesh-enrolment/vault.go +++ b/modules/mesh-enrolment/cmd/mesh-enrolment/vault.go @@ -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) {