diff --git a/modules/mesh-enrolment/cmd/mesh-enrolment/issue.go b/modules/mesh-enrolment/cmd/mesh-enrolment/issue.go new file mode 100644 index 0000000..e6f6be2 --- /dev/null +++ b/modules/mesh-enrolment/cmd/mesh-enrolment/issue.go @@ -0,0 +1,306 @@ +package main + +import ( + "errors" + "fmt" + "strconv" + "strings" + "time" + + stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-sdk/go/asker" + "git.novox.be/novox/mesh-sdk/go/asks" +) + +// Event is what each of the seat's events carries (ADR 0587 §2): the key's whole content, which holds no secret and nothing +// derived from the token, and for an end the reason. Node is repeated at the top so a consumer reads it without the rest. +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 +// crash is not said twice. +func eventID(event string, e Enrolment) string { + return event + "." + e.Node + "." + strconv.FormatInt(e.Issued.UnixNano(), 10) +} + +func (h *Holder) sayEvent(event string, e Enrolment, reason string) error { + return h.say(event, Event{Node: e.Node, Enrolment: e, Reason: reason}, eventID(event, e)) +} + +// IssueAnswer is what `issue` answers: the ask is the operator's to answer, and nothing else happens until they do. +type IssueAnswer struct { + Node string `json:"node"` + Address string `json:"address"` + Ask string `json:"ask"` + Expires time.Time `json:"expires"` + Says string `json:"says"` +} + +// Issue opens an enrolment (ADR 0587 §4): it refuses at once, naming every finding, a name that is no node name or that a +// node record, a placement key or an open enrolment holds, and every call before the lease write; chooses the lowest free +// address and reserves it by creating its key (the bus refuses a key that exists, so two issues never get one address); +// writes the key at stage asked; and asks the operator, on a channel that proves who answered. The token is made only on +// the operator's yes (Warranted). +func (h *Holder) Issue(caller, node, why string, lasts time.Duration) (IssueAnswer, error) { + var findings []string + if caller == "" || caller == stdio.Unproven { + findings = append(findings, "the caller is not proven by the runtime, and an enrolment records who asked") + } + if err := CheckNode(node); err != nil { + findings = append(findings, err.Error()) + } + if strings.TrimSpace(why) == "" { + findings = append(findings, "no why is given: the operator is asked why this machine joins") + } + if lasts == 0 { + lasts = DefaultExpiry + } + if lasts < 0 || lasts > MaxExpiry { + findings = append(findings, fmt.Sprintf("the expiry %s is outside zero to %s", lasts, MaxExpiry)) + } + if on, reason := h.issuing(); !on { + findings = append(findings, "issuing is off until the controller's lease is written to a holder that is no controller: "+reason+ + "; until then the controller's `token issue` is the only issuer (ADR 0587 §4)") + } + placements, perr := h.placements() + if perr != nil { + findings = append(findings, "placement cannot be read: "+perr.Error()) + } + if CheckNode(node) == nil { + if _, ok := placements[node]; ok { + findings = append(findings, node+" has a placement key already") + } + if h.hasRecord(node) { + findings = append(findings, node+" has a node record already") + } + if _, found, err := h.Store.Get(NodeKey(node)); err != nil { + findings = append(findings, "the enrolments cannot be read: "+err.Error()) + } else if found { + findings = append(findings, "an enrolment of "+node+" is open already: revoke it first") + } + } + if len(findings) > 0 { + return IssueAnswer{}, fmt.Errorf("no enrolment of %q is issued: %s", node, strings.Join(findings, "; ")) + } + now := h.now() + e := Enrolment{V: 1, Node: node, Why: strings.TrimSpace(why), Caller: caller, Stage: StageAsked, Issued: now, Expires: now.Add(lasts)} + e.Ask = "enrol-" + node + "-" + now.UTC().Format("20060102t150405") + skip := map[string]bool{} + for { + reserved, err := h.reservedAddresses() + if err != nil { + return IssueAnswer{}, err + } + addr, err := nextAddress(placements, reserved, skip) + if err != nil { + return IssueAnswer{}, fmt.Errorf("no enrolment of %q is issued: %w", node, err) + } + switch err := h.Store.Create(AddressKey(addr), Reservation{V: 1, Node: node}, 0); { + case err == nil: + e.Address = addr + case errors.Is(err, ErrExists): + skip[addr] = true // another issue reserved it first: the next + continue + default: + return IssueAnswer{}, fmt.Errorf("the address %s cannot be reserved: %w", addr, err) + } + break + } + if err := h.Store.Create(NodeKey(node), e, 0); err != nil { + _ = h.Store.Delete(AddressKey(e.Address)) + if errors.Is(err, ErrExists) { + return IssueAnswer{}, fmt.Errorf("no enrolment of %q is issued: an enrolment of it was opened meanwhile", node) + } + return IssueAnswer{}, fmt.Errorf("the enrolment of %s cannot be written: %w", node, err) + } + if err := h.raise(e); err != nil { + h.release(e) + return IssueAnswer{}, err + } + return IssueAnswer{Node: node, Address: e.Address, Ask: e.Ask, Expires: e.Expires, + Says: "the operator is asked on their channel; only their yes makes the token. mesh-enrolment.tokens shows where it stands"}, nil +} + +// release takes an enrolment's two keys away, the address key last (the node's key is what names the reservation's owner). +func (h *Holder) release(e Enrolment) { + if err := h.Store.Delete(NodeKey(e.Node)); err != nil { + h.logf("the key of %s could not be deleted and stays: %v", e.Node, err) + } + if err := h.Store.Delete(AddressKey(e.Address)); err != nil { + h.logf("the address key %s could not be deleted and stays: %v", e.Address, err) + } +} + +// AskOf is the ask of an enrolment, in the words the operator reads: that a machine of this name may join until the +// expiry at this address. Its yes binds the act (name, address, expiry). +func (h *Holder) AskOf(e Enrolment) (asks.Ask, error) { + act := asks.Act{"verb": Seat + ".issue", "module": Module, "node": e.Node, "address": e.Address, + "expires": e.Expires.UTC().Format(time.RFC3339)} + bind, err := asks.ActDigest(act) + if err != nil { + return asks.Ask{}, err + } + until := e.Expires.UTC().Format("2006-01-02 15:04 MST") + closes := h.now().Add(h.AskLasts) + if closes.After(e.Expires) { + closes = e.Expires + } + return asks.Ask{ID: e.Ask, Headline: "Let the machine " + e.Node + " join the mesh?", + Explanation: "A machine named " + e.Node + " may join the mesh until " + until + ", at the private address " + e.Address + ".\n" + + "Your yes lets the vault make a one-time token for it; you open the token at your own desk and type it on the new machine.\n" + + "What the machine runs is placed later, as for any machine.", + Who: asks.Operator, + Options: []asks.Option{{ID: "yes", Label: "Yes, let it join", Does: "The vault makes a one-time token for " + e.Node + ", valid until " + until + ".", Level: asks.Approve, Binds: bind}, + {ID: "no", Label: "No", Does: "Nothing is made; the address is free again.", Level: asks.Acknowledge}}, + Expires: closes, OnExpiry: "No token is made and the address is free again.", About: "mesh-enrolment-" + e.Node, + Details: "Asked by " + e.Caller + ": " + e.Why}, nil +} + +// raise asks the operator; a question that cannot be asked is taken back at the router, since a publish that timed out may +// have reached it, so a yes cannot land on an enrolment the holder has forgotten. +func (h *Holder) raise(e Enrolment) error { + ask, err := h.AskOf(e) + if err != nil { + return err + } + if err := h.Asker.Ask(ask); err != nil { + if cerr := h.Asker.Cancel(ask.ID); cerr != nil { + h.logf("the ask %s that could not be published was not taken back at the router: %v", ask.ID, cerr) + } + return fmt.Errorf("the operator could not be asked about %s: %w", e.Node, err) + } + return nil +} + +// byAsk finds the open enrolment an ask belongs to. +func (h *Holder) byAsk(ask string) (Enrolment, Row, bool, error) { + rows, open, err := h.openEnrolments() + if err != nil { + return Enrolment{}, Row{}, false, err + } + for n, e := range open { + if e.Ask == ask { + return e, rows[n], true, nil + } + } + return Enrolment{}, Row{}, false, nil +} + +// Warranted is the operator's answer, handed once by the asker (asker.Client.Listen): a no ends the enrolment; a yes records +// the warrant, asks the vault for the token, moves the key to live with the bus's expiry at the token's, and says +// enrolment-issued (ADR 0587 §4, §5). +func (h *Holder) Warranted(o asks.Option, w asks.Warrant) error { + e, _, found, err := h.byAsk(w.Ask) + if err != nil { + return err + } + if !found { + return fmt.Errorf("the warrant answers the ask %s, which no open enrolment holds: nothing is made", w.Ask) + } + if e.Stage != StageAsked { + return nil // answered already + } + if o.ID != "yes" { + return h.refuse(e, "the operator declined") + } + if !h.now().Before(e.Expires) { + return h.refuse(e, "the enrolment's expiry passed before the operator answered") + } + if h.Warrant != nil { + if err := h.Warrant(w.Ask); err != nil { + 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 { + 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 + ttl := h.lifetimeOf(e.Expires) + if _, err := h.Store.Put(NodeKey(e.Node), e, ttl); err != nil { + return fmt.Errorf("the enrolment of %s cannot be moved to live: %w", e.Node, err) + } + if _, err := h.Store.Put(AddressKey(e.Address), Reservation{V: 1, Node: e.Node}, ttl); err != nil { + return fmt.Errorf("the address key %s cannot be given its expiry: %w", e.Address, err) + } + return h.sayEvent(EventIssued, e, "") +} + +// refuse ends an enrolment at stage asked: both keys go and enrolment-refused carries the key's content (ADR 0587 §4). +func (h *Holder) refuse(e Enrolment, reason string) error { + if err := h.sayEvent(EventRefused, e, reason); err != nil { + return fmt.Errorf("enrolment-refused of %s cannot be said, so its keys stay: %w", e.Node, err) + } + h.release(e) + return nil +} + +// Said is the asker's note of an ask that ended with no yes or no (expired, or refused by the router): the enrolment ends. +func (h *Holder) Said(hd asker.Handled, w asks.Warrant, err error) { + if err != nil { + h.logf("the answer to %s (%s) ended in an error: %v", w.Ask, hd, err) + } + if hd != asker.Recorded { + return + } + e, _, found, ferr := h.byAsk(w.Ask) + if ferr != nil || !found || e.Stage != StageAsked { + return + } + if rerr := h.refuse(e, "the ask ended without an answer ("+string(w.Outcome)+")"); rerr != nil { + h.logf("%v", rerr) + } +} + +// Again asks the operator once more, when the router no longer holds the ask of an enrolment that is still at stage asked +// (ADR 0587 §8): a new ask under a new id, for the same node, address and expiry. +func (h *Holder) Again(node string) (IssueAnswer, error) { + row, found, err := h.Store.Get(NodeKey(node)) + if err != nil { + return IssueAnswer{}, err + } + if !found { + return IssueAnswer{}, fmt.Errorf("no enrolment of %s is open", node) + } + e, err := decodeEnrolment(row.Value) + if err != nil { + return IssueAnswer{}, err + } + if e.Stage != StageAsked { + return IssueAnswer{}, fmt.Errorf("the enrolment of %s is %s, not asked: there is nothing to ask again (a redeem stopped midway is finished by the machine calling redeem again)", node, e.Stage) + } + if !h.now().Before(e.Expires) { + return IssueAnswer{}, fmt.Errorf("the enrolment of %s has expired; revoke it and issue it again", node) + } + if h.Asker != nil { + if state, _, werr := h.whereIs(e.Ask); werr == nil && (state == "open" || state == "held") { + return IssueAnswer{}, fmt.Errorf("the ask %s is still %s at the router: the operator can answer it", e.Ask, state) + } + } + old := e.Ask + e.Ask = "enrol-" + node + "-" + h.now().UTC().Format("20060102t150405") + if e.Ask == old { + e.Ask += "b" + } + if _, err := h.Store.Update(NodeKey(node), e, row.Revision); err != nil { + return IssueAnswer{}, fmt.Errorf("the enrolment of %s moved meanwhile: %w", node, err) + } + if err := h.raise(e); err != nil { + return IssueAnswer{}, err + } + return IssueAnswer{Node: node, Address: e.Address, Ask: e.Ask, Expires: e.Expires, Says: "the operator is asked again"}, nil +} + +// whereIs is the router's state of an ask, when the asker can read it. +func (h *Holder) whereIs(id string) (string, *asks.Warrant, error) { + if w, ok := h.Asker.(interface { + Where(string) (string, *asks.Warrant, error) + }); ok { + return w.Where(id) + } + return "", nil, errors.New("the asker cannot read the router's record") +} diff --git a/modules/mesh-enrolment/cmd/mesh-enrolment/issue_test.go b/modules/mesh-enrolment/cmd/mesh-enrolment/issue_test.go new file mode 100644 index 0000000..f94331a --- /dev/null +++ b/modules/mesh-enrolment/cmd/mesh-enrolment/issue_test.go @@ -0,0 +1,133 @@ +package main + +import ( + "strings" + "sync" + "testing" + "time" + + "git.novox.be/novox/mesh-sdk/go/asker" + "git.novox.be/novox/mesh-sdk/go/asks" +) + +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) { + cases := []struct { + name string + setup func(r *rig) + node string + lasts time.Duration + want []string + }{ + {"the lease names the controller", func(r *rig) { r.rd["mesh-controller.lease"]["holder"] = `{"instance":"novox.mesh-controller"}` }, "ace", 0, + []string{"issuing is off until the controller's lease is written", "token issue"}}, + {"a bad name and a long expiry", nil, "Ace.1", 48 * time.Hour, []string{"not a valid node name", "outside zero to 24h0m0s"}}, + {"a placement key and a node record", func(r *rig) { + r.rd["mesh-delivery-assigner.placement"]["ace.node"] = `{"v":1,"node":"ace","modules":[]}` + 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"}}, + } + for _, c := range cases { + r := newRig(t) + if c.setup != nil { + c.setup(r) + } + _, err := r.h.Issue("person.operator", c.node, "a new machine", c.lasts) + if err == nil { + t.Fatalf("%s: issued", c.name) + } + for _, w := range c.want { + mustContain(t, err.Error(), w) + } + if len(r.asker.asked) != 0 || strings.Contains(strings.Join(r.keys(), " "), "address.10.9.0.3") { + t.Errorf("%s: asked %v, keys %v", c.name, r.asker.asked, r.keys()) + } + } +} + +// 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) { + r := newRig(t) + var wg sync.WaitGroup + addrs := make([]string, 2) + for i, n := range []string{"ace", "g14"} { + wg.Add(1) + go func() { + defer wg.Done() + a, err := r.h.Issue("person.operator", n, "a new machine", 0) + if err != nil { + t.Error(err) + } + addrs[i] = a.Address + }() + } + wg.Wait() + if addrs[0] == "" || addrs[0] == addrs[1] || addrs[0] == "10.9.0.1" || addrs[1] == "10.9.0.1" { + t.Fatalf("addresses %v", addrs) + } + e, _ := r.enrolment("ace") + if e.Stage != StageAsked || e.Caller != "person.operator" || e.Ask == "" || len(r.asker.asked) != 2 { + t.Fatalf("%+v, asked %v", e, r.asker.asked) + } + ask, _ := r.h.AskOf(e) + if ask.Options[0].Level != asks.Approve || ask.Options[0].Binds == "" || ask.Check(r.now) != nil { + t.Errorf("the ask does not authorise the act: %+v, %v", ask.Options[0], ask.Check(r.now)) + } + if err := r.h.Warranted(yes, asks.Warrant{Ask: e.Ask}); err != nil { + t.Fatal(err) + } + live, _ := r.enrolment("ace") + if live.Stage != StageLive || r.st.ttl[NodeKey("ace")] != time.Hour || r.st.ttl[AddressKey(live.Address)] != time.Hour || + 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 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) + } + if err := r.h.Warranted(yes, asks.Warrant{Ask: e.Ask}); err != nil || len(r.said) != 1 { + t.Errorf("a second answer said %v: %v", r.events(), err) + } +} + +// 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) { + 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 { + t.Fatal(err) + } + if len(r.keys()) != 0 || r.events()[0] != EventRefused { + t.Fatalf("keys %v events %v", r.keys(), r.events()) + } + mustContain(t, string(r.said[0].Body), `"address":"`+a.Address+`"`) + b, _ := r.h.Issue("person.operator", "g14", "a new machine", 0) + r.h.Said(asker.Recorded, asks.Warrant{Ask: b.Ask, Outcome: asks.OutcomeExpired}, nil) + if len(r.keys()) != 0 || r.events()[1] != EventRefused { + t.Fatalf("keys %v events %v", r.keys(), r.events()) + } + c, _ := r.h.Issue("person.operator", "novox", "a new machine", 0) + r.vault.fail["token novox"] = true + if err := r.h.Warranted(yes, asks.Warrant{Ask: c.Ask}); err == nil { + t.Fatal("a vault that failed was taken for a token") + } + if e, _ := r.enrolment("novox"); e.Stage != StageAsked { + t.Fatalf("stage %s", e.Stage) + } + r.now = r.now.Add(time.Minute) + again, err := r.h.Again("novox") + if err != nil || again.Ask == c.Ask || len(r.asker.asked) != 4 { + t.Fatalf("again %+v, %v, asked %v", again, err, r.asker.asked) + } + if err := r.h.Warranted(yes, asks.Warrant{Ask: again.Ask}); err != nil { + t.Fatal(err) + } + if e, _ := r.enrolment("novox"); e.Stage != StageLive { + t.Errorf("stage %s", e.Stage) + } +} diff --git a/modules/mesh-enrolment/cmd/mesh-enrolment/kit_test.go b/modules/mesh-enrolment/cmd/mesh-enrolment/kit_test.go index e907b6b..9107ca7 100644 --- a/modules/mesh-enrolment/cmd/mesh-enrolment/kit_test.go +++ b/modules/mesh-enrolment/cmd/mesh-enrolment/kit_test.go @@ -142,18 +142,26 @@ func (v *fakeVault) SignReply(node string, reply []byte) (json.RawMessage, error } type fakeAsker struct { + mu sync.Mutex asked, cancelled []string err error } func (a *fakeAsker) Ask(k asks.Ask) error { + a.mu.Lock() + defer a.mu.Unlock() if a.err != nil { return a.err } a.asked = append(a.asked, k.ID) return nil } -func (a *fakeAsker) Cancel(id string) error { a.cancelled = append(a.cancelled, id); return nil } +func (a *fakeAsker) Cancel(id string) error { + a.mu.Lock() + defer a.mu.Unlock() + a.cancelled = append(a.cancelled, id) + return nil +} type said struct { Event, ID string @@ -162,6 +170,7 @@ type said struct { // rig is a holder over fakes, with the lease handed to mesh-grants, one hub in placement and a clock the test moves. type rig struct { + mu sync.Mutex t *testing.T h *Holder st *memStore @@ -189,6 +198,8 @@ func newRig(t *testing.T) *rig { r.h = &Holder{Store: r.st, Read: r.rd, Vault: r.vault, Asker: r.asker, AskLasts: time.Hour, Say: func(event string, body any, id string) error { raw, _ := json.Marshal(body) + r.mu.Lock() + defer r.mu.Unlock() r.said = append(r.said, said{event, id, raw}) return nil }, diff --git a/modules/mesh-enrolment/cmd/mesh-enrolment/tools.go b/modules/mesh-enrolment/cmd/mesh-enrolment/tools.go index 652bbc4..787a9d2 100644 --- a/modules/mesh-enrolment/cmd/mesh-enrolment/tools.go +++ b/modules/mesh-enrolment/cmd/mesh-enrolment/tools.go @@ -1,6 +1,7 @@ package main import ( + "fmt" "strings" "time" @@ -9,6 +10,8 @@ import ( // Tool names: the seat's verbs, as the manifest claims them (ADR 0587 §1, §8). const ( + IssueTool = Seat + ".issue" + AgainTool = Seat + ".again" TokensTool = Seat + ".tokens" ) @@ -31,11 +34,43 @@ func flag(a map[string]any, k string) bool { return a[k] == true || strings.EqualFold(arg(a, k), "true") } -var _ = time.Second +// lastsOf reads an expiry as a duration; none is the default. +func lastsOf(s string) (time.Duration, error) { + if s == "" { + return 0, nil + } + d, err := time.ParseDuration(s) + if err != nil { + return 0, fmt.Errorf("the expiry %q is no duration (for example 1h): %w", s, err) + } + return d, nil +} // Tools are what the runtime serves. func Tools(h *Holder) []stdio.Tool { return []stdio.Tool{ + {Name: IssueTool, + Description: "Open an enrolment: let a new machine join the mesh (novox/hq ADR 0587 §4). Takes the node's name, a why and an " + + "expiry (one hour unless given, at most 24 hours). Refused at once, naming every finding, for a name that is no node name " + + "or that a node record, a placement key or an open enrolment holds, and for every call before the controller's lease is " + + "written to a holder that is no controller. Otherwise it chooses the lowest free private-network address, reserves it, " + + "writes the enrolment at stage asked and asks the operator on a channel that proves who answered; only their yes makes " + + "the token (the vault makes it, this seat never holds it). The caller is the one the runtime proves.", + Input: obj(map[string]any{"node": str("the new machine's name"), "why": str("why this machine joins"), + "expires": str("how long the token lasts, e.g. 1h (default) up to 24h")}, "node", "why"), + Run: func(a map[string]any) (any, error) { + lasts, err := lastsOf(arg(a, "expires")) + if err != nil { + return nil, err + } + return h.Issue(stdio.CallerOf(a), arg(a, "node"), arg(a, "why"), lasts) + }}, + {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 " + + "by the machine calling redeem again with the same keys.", + Input: obj(map[string]any{"node": str("the machine")}, "node"), + Run: func(a map[string]any) (any, error) { return h.Again(arg(a, "node")) }}, {Name: TokensTool, Description: "What the seat holds (novox/hq ADR 0587 §8): every open enrolment, or one node's, with its stage (asked, live or " + "redeeming), address, expiry, ask, revision and age; whether issuing is on and why (it is on only from the controller's " +