diff --git a/modules/mesh-enrolment/cmd/mesh-enrolment/end.go b/modules/mesh-enrolment/cmd/mesh-enrolment/end.go new file mode 100644 index 0000000..b4d2f2b --- /dev/null +++ b/modules/mesh-enrolment/cmd/mesh-enrolment/end.go @@ -0,0 +1,119 @@ +package main + +import ( + "encoding/json" + "fmt" + "strings" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +// Revoke ends an enrolment at any stage (ADR 0587 §5), by a person's call: at asked it withdraws the ask; at redeeming it also +// undoes what redeem had not finished, the vault retiring a node account it already minted (a node already written into +// placement stays there and is removed, if wanted, as any node is removed). It only takes access away, so it needs no Approve; +// it is recorded in the hand-act log. enrolment-revoked carries the key's content and the why; the token is then dropped by +// the durable consumer of that event, never before. A revoke that stopped is finished by calling it again. +func (h *Holder) Revoke(caller, node, why string) (map[string]any, error) { + if caller == "" || caller == stdio.Unproven { + return nil, fmt.Errorf("the caller is not proven by the runtime, and a revoke is recorded with who made it") + } + if strings.TrimSpace(why) == "" { + return nil, fmt.Errorf("a revoke says why: it is recorded as an act done by hand") + } + row, found, err := h.Store.Get(NodeKey(node)) + if err != nil { + return nil, fmt.Errorf("the enrolment of %s cannot be read: %w", node, err) + } + if !found { + return nil, fmt.Errorf("no enrolment of %s is open: mesh-enrolment.tokens lists those that are", node) + } + e, err := decodeEnrolment(row.Value) + if err != nil { + return nil, err + } + if e.Stage == StageAsked && e.Ask != "" && h.Asker != nil { + if err := h.Asker.Cancel(e.Ask); err != nil { + h.logf("the ask %s of %s was not taken back at the router: %v", e.Ask, node, err) + } + } + if e.Stage == StageRedeeming { + if err := h.Vault.Retire(node); err != nil { + return nil, fmt.Errorf("the vault did not retire the account minted for %s, so nothing is ended (call revoke again): %w", node, err) + } + } + out := map[string]any{"node": node, "was": e.Stage} + if h.Act != nil { + if err := h.Act("mesh-enrolment revoke of "+node+" at "+string(e.Stage), why+" (by "+caller+")", "enrolment-revoke"); err != nil { + out["recorded"] = "NOT recorded in the hand-act log: " + err.Error() + h.logf("the revoke of %s is not recorded in the hand-act log: %v", node, err) + } + } + if err := h.sayEvent(EventRevoked, e, why); err != nil { + return nil, fmt.Errorf("enrolment-revoked of %s cannot be said, so its keys stay (call revoke again): %w", node, err) + } + if err := h.release(e); err != nil { + return nil, fmt.Errorf("the enrolment of %s is revoked and its keys are not all deleted (call revoke again): %w", node, err) + } + out["says"] = "the enrolment is ended; the vault is asked to drop the token and, for a redeem under way, the account was retired" + return out, nil +} + +// Heard is the holder's watch of its own bucket (stdio.State.Follow): it keeps each node key's last content, and the bus's +// expiry marker of a key at stage live says enrolment-expired, naming the node and the enrolment-issued event that carried its +// content (ADR 0587 §5). redeem checks the expiry itself, so a marker heard late admits no machine. +func (h *Holder) Heard(op, key string, value []byte) error { + node, ok := strings.CutPrefix(key, "node.") + if !ok { + return nil + } + h.mu.Lock() + defer h.mu.Unlock() + if h.seen == nil { + h.seen = map[string]Enrolment{} + } + switch op { + case stdio.OpPut: + if e, err := decodeEnrolment(value); err == nil { + h.seen[node] = e + } + case stdio.OpDelete: + delete(h.seen, node) + case stdio.OpExpire: + e, known := h.seen[node] + delete(h.seen, node) + body := Event{Node: node, Enrolment: e} + id := "enrolment-expired." + node + if known { + body.Issued = eventID(EventIssued, e) + id = eventID(EventExpired, e) + } + body.Reason = "the token's expiry passed unredeemed" + if err := h.say(EventExpired, body, id); err != nil { + return fmt.Errorf("enrolment-expired of %s cannot be said (offered again): %w", node, err) + } + } + return nil +} + +// 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. +func (h *Holder) Ended(event string, body []byte) error { + var e struct { + Node string `json:"node"` + } + if err := json.Unmarshal(body, &e); err != nil || CheckNode(e.Node) != nil { + h.logf("the event %s names no node and is not acted on: %v", event, err) + return nil + } + if err := h.Vault.Drop(e.Node); err != nil { + return fmt.Errorf("the vault did not drop the token of %s (the bus delivers %s again): %w", e.Node, event, err) + } + h.mu.Lock() + h.ended = append(h.ended, json.RawMessage(`{"event":`+fmt.Sprintf("%q", event)+`,"content":`+string(body)+`}`)) + if len(h.ended) > 200 { + h.ended = h.ended[len(h.ended)-200:] + } + h.mu.Unlock() + return nil +} diff --git a/modules/mesh-enrolment/cmd/mesh-enrolment/end_test.go b/modules/mesh-enrolment/cmd/mesh-enrolment/end_test.go new file mode 100644 index 0000000..92cd90f --- /dev/null +++ b/modules/mesh-enrolment/cmd/mesh-enrolment/end_test.go @@ -0,0 +1,91 @@ +package main + +import ( + "encoding/json" + "slices" + "strings" + "testing" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +// 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) { + r := newRig(t) + r.seed("ace", "10.9.0.2", StageRedeeming) + r.vault.fail["retire ace"] = true + if _, err := r.h.Revoke("person.operator", "ace", "never confirmed"); err == nil || len(r.keys()) != 2 || len(r.said) != 0 { + t.Fatalf("a failed retire ended it: %v, keys %v, events %v", err, r.keys(), r.events()) + } + if _, err := r.h.Revoke("person.operator", "ace", "never confirmed"); err != nil { + t.Fatal(err) + } + if len(r.keys()) != 0 || !slices.Equal(r.events(), []string{EventRevoked}) || len(r.acts) != 1 || !slices.Contains(r.vault.calls, "retire ace") { + t.Errorf("keys %v events %v acts %v vault %v", r.keys(), r.events(), r.acts, r.vault.calls) + } + mustContain(t, string(r.said[0].Body), "never confirmed") + // At asked it withdraws the ask and the vault is not asked to retire anything. + r.seed("g14", "10.9.0.3", StageAsked) + if _, err := r.h.Revoke("person.operator", "g14", "changed our mind"); err != nil || !slices.Equal(r.asker.cancelled, []string{"ask-g14"}) || slices.Contains(r.vault.calls, "retire g14") { + t.Errorf("%v, cancelled %v, vault %v", err, r.asker.cancelled, r.vault.calls) + } + if _, err := r.h.Revoke(stdio.Unproven, "ace", "x"); err == nil { + t.Error("revoked for no one") + } + // The bus's marker of a key at live: the content the watch handed over is said with the issued event. + r.seed("novox", "10.9.0.4", StageLive) + row, _, _ := r.st.Get(NodeKey("novox")) + _ = r.h.Heard(stdio.OpPut, NodeKey("novox"), row.Value) + if err := r.h.Heard(stdio.OpExpire, NodeKey("novox"), nil); err != nil { + t.Fatal(err) + } + last := r.said[len(r.said)-1] + mustContain(t, string(last.Body), `"issued-event":"enrolment-issued.novox.`) + if last.Event != EventExpired { + t.Errorf("said %s", last.Event) + } +} + +// 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) { + r := newRig(t) + a, _ := r.h.Issue("person.operator", "ace", "a new machine", 0) + _ = r.h.Warranted(yes, askWarrant(a.Ask)) + if _, err := r.h.Tokens("ace", false); err != nil { // relays the sealed token + t.Fatal(err) + } + if slices.ContainsFunc(r.vault.calls, func(c string) bool { return strings.HasPrefix(c, "drop") }) { + t.Fatalf("the vault was asked to drop a token before an end: %v", r.vault.calls) + } + if _, err := r.h.Revoke("person.operator", "ace", "lost the machine"); err != nil { + t.Fatal(err) + } + end := r.said[len(r.said)-1] + r.vault.fail["drop ace"] = true + if err := r.h.Ended(end.Event, end.Body); err == nil { + t.Fatal("an event was acknowledged though the vault did not answer") + } + if err := r.h.Ended(end.Event, end.Body); err != nil { + t.Fatal(err) + } + if n := strings.Count(strings.Join(r.vault.calls, ","), "drop ace"); n != 2 { + t.Errorf("the drop was asked %d times", n) + } + for _, s := range r.said { + if strings.Contains(string(s.Body), "sealed") || strings.Contains(string(s.Body), "token") { + t.Errorf("the event %s holds a token: %s", s.Event, s.Body) + } + } + for k, row := range r.st.rows { + if strings.Contains(string(row.Value), "sealed") { + t.Errorf("the key %s holds a token", k) + } + } + tk, _ := r.h.Tokens("", true) + if raw, _ := json.Marshal(tk); !strings.Contains(string(raw), "enrolment-revoked") { + t.Errorf("history %s", raw) + } +} diff --git a/modules/mesh-enrolment/cmd/mesh-enrolment/kit_test.go b/modules/mesh-enrolment/cmd/mesh-enrolment/kit_test.go index 40bd2cc..d42413b 100644 --- a/modules/mesh-enrolment/cmd/mesh-enrolment/kit_test.go +++ b/modules/mesh-enrolment/cmd/mesh-enrolment/kit_test.go @@ -255,3 +255,5 @@ func errText(err error) string { } return fmt.Sprint(err) } + +func askWarrant(ask string) asks.Warrant { return asks.Warrant{Ask: ask} } diff --git a/modules/mesh-enrolment/cmd/mesh-enrolment/main.go b/modules/mesh-enrolment/cmd/mesh-enrolment/main.go index 189f172..3ff7ccd 100644 --- a/modules/mesh-enrolment/cmd/mesh-enrolment/main.go +++ b/modules/mesh-enrolment/cmd/mesh-enrolment/main.go @@ -139,6 +139,8 @@ func loadedVia(rd Reader) func(string) (bool, error) { func main() { h := newHolder() + client := h.Asker.(*asker.Client) + h.Run(func() error { return client.Listen(h.Warranted, h.Said) }) if err := stdio.Serve("", Tools(h)); err != nil { logf("%v", err) os.Exit(1) diff --git a/modules/mesh-enrolment/cmd/mesh-enrolment/run.go b/modules/mesh-enrolment/cmd/mesh-enrolment/run.go new file mode 100644 index 0000000..07cd094 --- /dev/null +++ b/modules/mesh-enrolment/cmd/mesh-enrolment/run.go @@ -0,0 +1,39 @@ +package main + +import ( + "time" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +// Run starts what the holder does beside its tools, none of it on a timer: the watch of its own bucket (the bus's expiry +// markers), the durable consumer of its own ending events (the drop), and the operator's answers. Each is asked of the +// runtime as soon as it serves, and again with a growing wait until the runtime takes it. +func (h *Holder) Run(listen func() error) { + go stdio.State(State).Follow("", func(c stdio.StateChange) error { return h.Heard(c.Op, c.Key, c.Value) }) + for _, event := range EndEvents { + go func(key string) { + <-stdio.Served() + retry("hear "+key, h.logf, func() error { + return stdio.Subscribe(key, func(e stdio.Envelope) error { return h.Ended(key, e.Body) }) + }) + }(Seat + "." + event) + } + if listen != nil { + go func() { + <-stdio.Served() + retry("hear the operator's answers", h.logf, listen) + }() + } +} + +func retry(what string, logf func(string, ...any), do func() error) { + for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) { + err := do() + if err == nil { + return + } + logf("does not %s yet (%v); asking again in %s", what, err, wait) + time.Sleep(wait) + } +} diff --git a/modules/mesh-enrolment/cmd/mesh-enrolment/tokens_test.go b/modules/mesh-enrolment/cmd/mesh-enrolment/tokens_test.go index a200701..0ca284c 100644 --- a/modules/mesh-enrolment/cmd/mesh-enrolment/tokens_test.go +++ b/modules/mesh-enrolment/cmd/mesh-enrolment/tokens_test.go @@ -94,6 +94,15 @@ func TestTheManifestSaysWhatTheCodeDoes(t *testing.T) { if !slices.Equal(m.Consumes, heard) { t.Errorf("consumes %v, the end events are %v", m.Consumes, heard) } + served := map[string]bool{} + for _, tool := range Tools(&Holder{}) { + served[tool.Name] = true + } + for _, v := range Verbs { + if !served[Seat+"."+v] { + t.Errorf("the verb %s has no tool", v) + } + } 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"` diff --git a/modules/mesh-enrolment/cmd/mesh-enrolment/tools.go b/modules/mesh-enrolment/cmd/mesh-enrolment/tools.go index e6f1ac3..1c1ca47 100644 --- a/modules/mesh-enrolment/cmd/mesh-enrolment/tools.go +++ b/modules/mesh-enrolment/cmd/mesh-enrolment/tools.go @@ -15,6 +15,7 @@ const ( AgainTool = Seat + ".again" RedeemTool = Seat + ".redeem" ConfirmTool = Seat + ".confirm" + RevokeTool = Seat + ".revoke" TokensTool = Seat + ".tokens" ) @@ -94,6 +95,16 @@ func Tools(h *Holder) []stdio.Tool { "the enrolment; the vault is then asked to drop the token.", Input: obj(map[string]any{"node": str("the machine's own name (optional)")}), Run: func(a map[string]any) (any, error) { return h.Confirm(stdio.CallerOf(a), arg(a, "node")) }}, + {Name: RevokeTool, + Description: "End an enrolment at any stage (novox/hq ADR 0587 §5): at asked it withdraws the ask; at live or redeeming it deletes " + + "the key and its address key, and at redeeming the vault retires an account it already minted (a node already written into " + + "placement stays there). It only takes access away, so it needs no Approve; it is recorded as an act done by hand. Says " + + "enrolment-revoked; the vault then drops the token. A machine stuck at redeeming is ended by this. A revoke that stopped is " + + "finished by calling it again.", + Input: obj(map[string]any{"node": str("the machine"), "why": str("why, kept with the act")}, "node", "why"), + Run: func(a map[string]any) (any, error) { + return h.Revoke(stdio.CallerOf(a), arg(a, "node"), arg(a, "why")) + }}, {Name: AgainTool, Description: "Ask the operator again about an enrolment still at stage asked whose ask the router no longer holds open " + "(novox/hq ADR 0587 §8): a new ask for the same node, address and expiry. Nothing more: a redeem stopped midway is finished " +