mesh-enrolment: take the tunnel key at issue, place the node on the yes, take it off again at an unconfirmed end
The operator's decision of 2026-11-02 01:05, on mesh-sdk 4224665: - issue takes overlay_key, the machine's tunnel public key as `nox-mesh-host key` prints it, and refuses a missing one. The token's first tunnel is whole: key, address, range (the hub's tunnel range in placement, the one the controller's token carries today; a hub with none is a finding, never a guess), hub key and hub endpoint. - On the operator's yes the node is proposed into placement at once (add-node with its tunnel key alone, on the warrant), and only then is the token made, so the hub peers it before redeem. Redeem writes no placement: it refuses a tunnel key other than the one issued for, or a node placement does not hold yet, and the certificate's call carries all four keys presented. - A revoke, a refusal or an expiry before confirm takes the node off placement (remove-node, same warrant) before the end is said, and the end brings the token's drop. - The assigner applies a remove-node: the node's key deleted at once, with no ask, placement-changed carrying the warrant. - The tunnel key is kept as one more member of the node key's JSON, beside delivery.Enrolment, until the contract carries it. Tests: rows in the existing tables and both integration tests (the token's Missing() is empty; the node is placed before redeem and leaves placement when its enrolment expires); no function added, mesh-delivery stays at 816. (issue 774) Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
This commit is contained in:
@@ -1141,7 +1141,7 @@ func TestAProposalIsAnsweredApprovedAndWritten(t *testing.T) {
|
||||
<-heard
|
||||
}
|
||||
joined, err := verbs.doPropose(map[string]any{"why": "enrolment of ace", "warrant": `{"node":"ace","address":"10.9.0.2","ask":"enrol-ace-1","warrant":"enrol-ace-1@2026-10-28T20:00:00Z"}`,
|
||||
"changes": `[{"op":"add-node","node":"ace","decided":{"address":"10.9.0.2"},"keys":{"identity":"aWQ=","sealing":"c2Vh"}}]`}, "novox.mesh-enrolment")
|
||||
"changes": `[{"op":"add-node","node":"ace","decided":{"address":"10.9.0.2"},"keys":{"overlay":"dHVubmVs"}}]`}, "novox.mesh-enrolment")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -1159,4 +1159,24 @@ func TestAProposalIsAnsweredApprovedAndWritten(t *testing.T) {
|
||||
if akey, _ := delivery.PlacementAskKey(joined.(proposed).Proposal.ID); b.hasKey(delivery.AssignerAsksBucket, akey) || b.hasKey(delivery.PlacementProposalsBucket, joined.(proposed).Proposal.ID) {
|
||||
t.Error("an ask was raised for the joining node, or its proposal stays open")
|
||||
}
|
||||
// Its enrolment ends before it confirms: a remove-node on the same warrant deletes its key at once, with no ask, and
|
||||
// placement-changed carries the warrant (the operator's decision of 2026-11-02 01:05).
|
||||
for len(heard) > 0 {
|
||||
<-heard
|
||||
}
|
||||
if _, err := verbs.doPropose(map[string]any{"why": "enrolment of ace revoked", "warrant": `{"node":"ace","address":"10.9.0.2","ask":"enrol-ace-1","warrant":"enrol-ace-1@2026-10-28T20:00:00Z"}`,
|
||||
"changes": `[{"op":"remove-node","node":"ace"}]`}, "novox.mesh-enrolment"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
select {
|
||||
case e := <-heard:
|
||||
if e.Warrant == nil || len(e.Nodes) != 1 || e.Nodes[0].Node != "ace" {
|
||||
t.Errorf("placement-changed for the removed node = %+v", e)
|
||||
}
|
||||
case <-time.After(15 * time.Second):
|
||||
t.Fatal("no placement-changed heard for the removed node")
|
||||
}
|
||||
if _, err := b.buckets[delivery.PlacementBucket].Get(b.ctx, "ace.node"); err == nil {
|
||||
t.Error("the removed node's key stays")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -560,6 +560,10 @@ func (p *passer) addNode(ctx context.Context, prop delivery.PlacementProposal, r
|
||||
}
|
||||
laid, findings := delivery.LayChanges(current, prop.Changes)
|
||||
node := prop.Changes[0].Node
|
||||
if prop.Changes[0].Op == delivery.OpRemoveNode {
|
||||
p.removeNode(ctx, prop, current, findings)
|
||||
return
|
||||
}
|
||||
value, ok := laid[node]
|
||||
if len(findings) > 0 || !ok {
|
||||
p.endProposal(ctx, prop, "the node cannot be added: "+strings.Join(findings, "; "))
|
||||
@@ -582,6 +586,33 @@ func (p *passer) addNode(ctx context.Context, prop delivery.PlacementProposal, r
|
||||
[]delivery.PlacementChange{c})
|
||||
}
|
||||
|
||||
// removeNode deletes the key of a joining node whose enrolment ended unconfirmed, on that enrolment's warrant (the operator's
|
||||
// decision of 2026-11-02 01:05): no node answers and no ask is raised, as for its add-node; placement-changed carries the warrant.
|
||||
// A node that runs modules or holds seats is no joining node and stays (LayChanges' finding ends the proposal).
|
||||
func (p *passer) removeNode(ctx context.Context, prop delivery.PlacementProposal, current map[string]delivery.Placement, findings []string) {
|
||||
node := prop.Changes[0].Node
|
||||
before, ok := current[node]
|
||||
if len(findings) > 0 || !ok {
|
||||
p.endProposal(ctx, prop, "the node cannot be taken off: "+strings.Join(findings, "; "))
|
||||
return
|
||||
}
|
||||
rev, err := p.writeNode(ctx, node, nil)
|
||||
if err != nil {
|
||||
p.endProposal(ctx, prop, "the node's key was not deleted: "+err.Error())
|
||||
return
|
||||
}
|
||||
c := delivery.ChangeOf(before, delivery.Placement{Node: node})
|
||||
c.Before, c.After = prop.Base[node], rev
|
||||
p.deleteCandidates(ctx, prop.ID)
|
||||
p.deletePulses(ctx, prop.ID)
|
||||
_ = p.kvs[delivery.PlacementProposalsBucket].Delete(ctx, prop.ID)
|
||||
p.w.put(delivery.PlacementProposalsBucket, prop.ID, nil)
|
||||
w := prop.Warrant
|
||||
p.sayChanged(prop, delivery.Answer{Option: assign.OptionYes, Warrant: w.Warrant,
|
||||
Words: fmt.Sprintf("the machine %s left placement: its enrolment (ask %s) ended before it confirmed", w.Node, w.Ask)},
|
||||
[]delivery.PlacementChange{c})
|
||||
}
|
||||
|
||||
// followWrite follows the keys a warrant wrote: the warrant recorded and placement-changed said. No verb of the
|
||||
// controller is called: the placement is the one source of where modules run (ADR 0567 §8, issue 774).
|
||||
func (p *passer) followWrite(prop delivery.PlacementProposal, answer delivery.Answer, changes []delivery.PlacementChange) {
|
||||
|
||||
@@ -154,7 +154,7 @@ func TestTheSeatsVerbs(t *testing.T) {
|
||||
// the node a joining one with no record; from another caller, naming another node or address, or with no warrant it is refused.
|
||||
addNode := func(by, warrant string) error {
|
||||
_, err := s.doPropose(map[string]any{"why": "enrolment of ace", "warrant": warrant, "changes": []any{map[string]any{"op": "add-node", "node": "ace",
|
||||
"decided": map[string]any{"address": "10.9.0.2"}, "keys": map[string]any{"identity": "aWQ=", "sealing": "c2Vh"}}}}, by)
|
||||
"decided": map[string]any{"address": "10.9.0.2"}, "keys": map[string]any{"overlay": "dHVubmVs"}}}}, by)
|
||||
return err
|
||||
}
|
||||
good := `{"node":"ace","address":"10.9.0.2","ask":"enrol-ace-1","warrant":"enrol-ace-1@2026-10-28T20:00:00Z"}`
|
||||
@@ -172,6 +172,28 @@ func TestTheSeatsVerbs(t *testing.T) {
|
||||
if err := addNode("novox.mesh-enrolment", good); err != nil {
|
||||
t.Errorf("an add-node from mesh-enrolment: %v", err)
|
||||
}
|
||||
// A remove-node (the operator's decision of 2026-11-02 01:05) takes a joining node that never confirmed off placement: from
|
||||
// mesh-enrolment, on the warrant naming that node, of a node running nothing, it is proposed with the node's key to delete;
|
||||
// of a node running modules, or with another node's warrant, it is refused.
|
||||
joiner, _ := delivery.EncodePlacement(delivery.Placement{V: delivery.Version, Node: "joiner", Decided: delivery.NodeDecided{Address: "10.9.0.7"}, Modules: []delivery.PlacedModule{}})
|
||||
st[delivery.PlacementState].values["placement.joiner"] = joiner
|
||||
removeNode := func(node, warrant string) (any, error) {
|
||||
return s.doPropose(map[string]any{"why": "enrolment ended", "warrant": warrant,
|
||||
"changes": []any{map[string]any{"op": "remove-node", "node": node}}}, "novox.mesh-enrolment")
|
||||
}
|
||||
for _, c := range []struct{ node, warrant, want string }{
|
||||
{"anchor", `{"node":"anchor","address":"10.9.0.1","ask":"a","warrant":"a@t"}`, "runs modules or holds seats"},
|
||||
{"joiner", `{"node":"g14","address":"10.9.0.7","ask":"a","warrant":"a@t"}`, "carries the warrant of g14"},
|
||||
} {
|
||||
if _, err := removeNode(c.node, c.warrant); err == nil || !strings.Contains(err.Error(), c.want) {
|
||||
t.Errorf("a remove-node of %s: %v, want a refusal saying %q", c.node, err, c.want)
|
||||
}
|
||||
}
|
||||
if got, err := removeNode("joiner", `{"node":"joiner","address":"10.9.0.7","ask":"a","warrant":"a@t"}`); err != nil {
|
||||
t.Errorf("a remove-node from mesh-enrolment: %v", err)
|
||||
} else if rm := got.(proposed).Proposal; !slices.Equal(rm.Nodes(), []string{"joiner"}) || !slices.Equal(rm.RemovedNodes(), []string{"joiner"}) {
|
||||
t.Errorf("the remove-node proposed %+v", rm)
|
||||
}
|
||||
if _, err := s.doHandBack(map[string]any{"why": "back"}, "op"); err == nil || !strings.Contains(err.Error(), "since the lease write") {
|
||||
t.Errorf("a hand-back: %v", err)
|
||||
}
|
||||
|
||||
@@ -67,6 +67,16 @@ func Propose(in ProposeIn) (delivery.PlacementProposal, map[string]*delivery.Pla
|
||||
out[node] = &pl
|
||||
p.Base[node] = in.Revisions[node]
|
||||
}
|
||||
// A remove-node deletes the key of a joining node that never confirmed (the operator's decision of 2026-11-02 01:05): its
|
||||
// candidate is no placement, and the checks across nodes read the mesh without it. LayChanges said why one cannot go.
|
||||
for _, node := range p.RemovedNodes() {
|
||||
if _, ok := in.Current[node]; !ok {
|
||||
continue
|
||||
}
|
||||
delete(all, node)
|
||||
out[node] = nil
|
||||
p.Base[node] = in.Revisions[node]
|
||||
}
|
||||
if len(findings) == 0 && len(out) == 0 {
|
||||
findings = append(findings, "the changes leave every placement as it is")
|
||||
}
|
||||
|
||||
@@ -10,8 +10,8 @@ import (
|
||||
)
|
||||
|
||||
// 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;
|
||||
// undoes what redeem had not finished, the vault retiring a node account it already minted, and a node the yes added to
|
||||
// placement is taken off it again. 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) {
|
||||
@@ -42,6 +42,10 @@ func (h *Holder) Revoke(caller, node, why string) (map[string]any, error) {
|
||||
return nil, fmt.Errorf("the vault did not retire the account minted for %s, so nothing is ended (call revoke again): %w", node, err)
|
||||
}
|
||||
}
|
||||
// A node that entered placement on the yes and never confirmed leaves it again (the operator's decision of 2026-11-02 01:05).
|
||||
if err := h.unplace(e, "revoked: "+why); err != nil {
|
||||
return nil, fmt.Errorf("%s is not taken off placement, 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 {
|
||||
@@ -92,6 +96,9 @@ func (h *Holder) Heard(op, key string, value []byte) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := h.unplace(e, "expired"); err != nil {
|
||||
return fmt.Errorf("%s, whose enrolment expired, is not taken off placement (offered again): %w", node, err)
|
||||
}
|
||||
body, err := delivery.EncodeEnrolmentExpiry(delivery.EnrolmentExpiry{Node: node, IssuedID: eventID(EventIssued, e)})
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"encoding/json"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -23,6 +24,11 @@ func revokeEndsAtAnyStageAndAnExpiryMarkerSaysExpired(t *testing.T) {
|
||||
if _, err := r.h.Revoke("person.operator", "ace", "never confirmed"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The node the yes placed, never confirmed, leaves placement on the enrolment's warrant before the end is said.
|
||||
if _, placed := r.rd["mesh-delivery-assigner.placement"]["ace.node"]; placed || len(r.added) == 0 ||
|
||||
!reflect.DeepEqual(r.added[len(r.added)-1].Change, delivery.PlacementProposalChange{Op: delivery.OpRemoveNode, Node: "ace"}) || r.added[len(r.added)-1].Warrant.Ask != "ask-ace" {
|
||||
t.Errorf("revoked and still placed: %+v", r.added)
|
||||
}
|
||||
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)
|
||||
}
|
||||
@@ -42,6 +48,9 @@ func revokeEndsAtAnyStageAndAnExpiryMarkerSaysExpired(t *testing.T) {
|
||||
if err := r.h.Heard(stdio.OpExpire, NodeKey("novox"), nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, placed := r.rd["mesh-delivery-assigner.placement"]["novox.node"]; placed {
|
||||
t.Error("expired and still placed")
|
||||
}
|
||||
last := r.said[len(r.said)-1]
|
||||
x, err := delivery.DecodeEnrolmentExpiry(last.Body)
|
||||
if err != nil || last.Event != EventExpired || x.Node != "novox" || !strings.HasPrefix(x.IssuedID, "enrolment-issued.novox.") {
|
||||
@@ -58,7 +67,7 @@ func revokeEndsAtAnyStageAndAnExpiryMarkerSaysExpired(t *testing.T) {
|
||||
// asked again when its first answer fails; and neither an event nor the bucket ever holds the token, sealed or not.
|
||||
func theDropFollowsOnlyAnEndAndNothingHoldsTheToken(t *testing.T) {
|
||||
r := newRig(t)
|
||||
a, _ := r.h.Issue("person.operator", "ace", "a new machine", 0)
|
||||
a, _ := r.h.Issue("person.operator", "ace", "a new machine", tunnelKey, 0)
|
||||
_ = r.h.Warranted(yes, askWarrant(a.Ask))
|
||||
if _, err := r.h.Tokens("ace", false); err != nil { // relays the sealed token
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -50,11 +50,18 @@ const (
|
||||
StageRedeeming = delivery.EnrolmentRedeeming
|
||||
)
|
||||
|
||||
// Enrolment and Reservation are the seat's state contract (delivery.Enrolment, delivery.EnrolmentAddress).
|
||||
type (
|
||||
Enrolment = delivery.Enrolment
|
||||
Reservation = delivery.EnrolmentAddress
|
||||
)
|
||||
// Reservation is the seat's state contract for an address key (delivery.EnrolmentAddress).
|
||||
type Reservation = delivery.EnrolmentAddress
|
||||
|
||||
// Enrolment is a node key: the seat's state contract (delivery.Enrolment) and the machine's tunnel key given at issue, which
|
||||
// the node enters placement with on the operator's yes and which redeem must present again (the operator's decision of
|
||||
// 2026-11-02 01:05). The contract does not carry the tunnel key yet: it is kept as one more member of the same JSON object,
|
||||
// which every reader of the contract decodes as before (delivery's decode ignores a member it does not know). Once
|
||||
// delivery.Enrolment carries `overlay-key` this wrapper goes.
|
||||
type Enrolment struct {
|
||||
delivery.Enrolment
|
||||
OverlayKey string `json:"overlay-key,omitempty"`
|
||||
}
|
||||
|
||||
const (
|
||||
nodePrefix = "node."
|
||||
@@ -78,12 +85,33 @@ func CheckNode(node string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func decodeEnrolment(raw []byte) (Enrolment, error) { return delivery.DecodeEnrolment(raw) }
|
||||
func decodeEnrolment(raw []byte) (Enrolment, error) {
|
||||
e, err := delivery.DecodeEnrolment(raw)
|
||||
if err != nil {
|
||||
return Enrolment{}, err
|
||||
}
|
||||
var tunnel struct {
|
||||
OverlayKey string `json:"overlay-key"`
|
||||
}
|
||||
_ = json.Unmarshal(raw, &tunnel)
|
||||
return Enrolment{Enrolment: e, OverlayKey: tunnel.OverlayKey}, nil
|
||||
}
|
||||
|
||||
// Encode is a value of the bucket as the seat's contract writes it: an Enrolment or a Reservation checked and versioned.
|
||||
func Encode(v any) (json.RawMessage, error) {
|
||||
switch x := v.(type) {
|
||||
case Enrolment:
|
||||
raw, err := delivery.EncodeEnrolment(x.Enrolment)
|
||||
if err != nil || x.OverlayKey == "" {
|
||||
return raw, err
|
||||
}
|
||||
var obj map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &obj); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
obj["overlay-key"], _ = json.Marshal(x.OverlayKey)
|
||||
return json.Marshal(obj)
|
||||
case delivery.Enrolment:
|
||||
return delivery.EncodeEnrolment(x)
|
||||
case Reservation:
|
||||
return delivery.EncodeEnrolmentAddress(x.Node)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -30,8 +31,9 @@ type Vault interface {
|
||||
Account(node, sealingKey, keysDigest string) (delivery.NodeAccountAnswer, error)
|
||||
// Retire asks the vault to retire the account it minted for node.
|
||||
Retire(node string) error
|
||||
// SignCertificate and SignReply sign through the vault's `sign`.
|
||||
SignCertificate(node, servingKey string) (delivery.SignAnswer, error)
|
||||
// SignCertificate and SignReply sign through the vault's `sign`; the certificate's call carries every public key the node
|
||||
// presented, which the vault checks against the digest node-account was minted for.
|
||||
SignCertificate(node string, keys delivery.JoiningKeys) (delivery.SignAnswer, error)
|
||||
SignReply(node string, reply []byte) (delivery.SignAnswer, error)
|
||||
// Signer is the vault's public signing key in base64, which the redeem answer names for the node to check it against its token.
|
||||
Signer() (string, error)
|
||||
@@ -51,7 +53,7 @@ type Holder struct {
|
||||
Asker Asker
|
||||
// Say publishes a seat event under a message id the bus keeps one of.
|
||||
Say func(event string, body any, id string) error
|
||||
// AddNode asks mesh-placement to add the node (ADR 0587 §7).
|
||||
// AddNode asks mesh-placement to add the node, or to take a node that never confirmed off again (ADR 0587 §7).
|
||||
AddNode func(AddNode) error
|
||||
// Warrant and Act record in the hand-act log, through the seat mesh-hand-acts: the operator's warrant for an ask, and an
|
||||
// act done by hand (a revoke).
|
||||
@@ -103,6 +105,59 @@ type AddNode struct {
|
||||
Warrant delivery.AddNodeWarrant
|
||||
}
|
||||
|
||||
// place adds the node into placement, with the address the operator approved and its tunnel key, on the issue's warrant
|
||||
// (ADR 0587 §7; the operator's decision of 2026-11-02 01:05), unless placement holds it at that address already.
|
||||
func (h *Holder) place(e Enrolment, placements map[string]delivery.Placement) error {
|
||||
if p, ok := placements[e.Node]; ok && p.Decided.Address == e.Address {
|
||||
return nil
|
||||
}
|
||||
w, err := h.warrantOf(e)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return h.AddNode(AddNode{Why: "enrolment of " + e.Node + ": " + e.Why, Warrant: w,
|
||||
Change: delivery.PlacementProposalChange{Op: delivery.OpAddNode, Node: e.Node, Decided: &delivery.NodeDecided{Address: e.Address},
|
||||
Keys: &delivery.JoiningKeys{Overlay: e.OverlayKey}}})
|
||||
}
|
||||
|
||||
// unplace takes a node that never confirmed off placement again, on the same warrant, when its enrolment ends revoked, refused
|
||||
// or expired: the hub stops peering a machine that will not join. A node placement does not hold is left as it is.
|
||||
func (h *Holder) unplace(e Enrolment, why string) error {
|
||||
placements, err := h.placements()
|
||||
if err != nil {
|
||||
return fmt.Errorf("placement cannot be read: %w", err)
|
||||
}
|
||||
if _, ok := placements[e.Node]; !ok {
|
||||
return nil
|
||||
}
|
||||
w, err := h.warrantOf(e)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return h.AddNode(AddNode{Why: "enrolment of " + e.Node + " ended unconfirmed: " + why, Warrant: w,
|
||||
Change: delivery.PlacementProposalChange{Op: delivery.OpRemoveNode, Node: e.Node}})
|
||||
}
|
||||
|
||||
func (h *Holder) warrantOf(e Enrolment) (delivery.AddNodeWarrant, error) {
|
||||
if h.AddNode == nil || h.WarrantOf == nil {
|
||||
return delivery.AddNodeWarrant{}, errors.New("no route to mesh-placement")
|
||||
}
|
||||
warrant, err := h.WarrantOf(e.Ask)
|
||||
if err != nil {
|
||||
return delivery.AddNodeWarrant{}, fmt.Errorf("the operator's warrant for %s cannot be read from the router: %w", e.Ask, err)
|
||||
}
|
||||
return delivery.AddNodeWarrant{Node: e.Node, Address: e.Address, Ask: e.Ask, Warrant: warrant}, nil
|
||||
}
|
||||
|
||||
// checkTunnelKey says a tunnel key is a WireGuard public key, as `nox-mesh-host key` prints it: 32 bytes in standard base64.
|
||||
func checkTunnelKey(k string) error {
|
||||
raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(k))
|
||||
if err != nil || len(raw) != 32 {
|
||||
return errors.New("no tunnel key is given (overlay_key: the line `nox-mesh-host key` prints on the new machine)")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// issuing says whether issue is on: only while the controller's lease is handed to a holder that is no controller (ADR 0587
|
||||
// §4), and why either way.
|
||||
func (h *Holder) issuing() (bool, string) {
|
||||
@@ -161,11 +216,12 @@ func (h *Holder) placements() (map[string]delivery.Placement, error) {
|
||||
}
|
||||
|
||||
// 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
|
||||
// controller's `token issue` builds it): the machine's own tunnel key given at issue, its address, the private network's
|
||||
// range (privateRange), 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) {
|
||||
func (h *Holder) firstTunnel(placements map[string]delivery.Placement, address, overlayKey string) (delivery.TokenTunnel, error) {
|
||||
var hub *delivery.Placement
|
||||
for _, p := range placements {
|
||||
if p.Decided.Hub {
|
||||
@@ -194,10 +250,14 @@ func (h *Holder) firstTunnel(placements map[string]delivery.Placement, address s
|
||||
if hub.Decided.Endpoint == "" {
|
||||
missing = append(missing, "no endpoint in its placement")
|
||||
}
|
||||
prefix, rerr := privateRange(placements)
|
||||
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
|
||||
if rerr != nil {
|
||||
return delivery.TokenTunnel{}, rerr
|
||||
}
|
||||
return delivery.TokenTunnel{Key: overlayKey, Address: address + "/32", Range: prefix.String(), HubKey: key, HubEndpoint: hub.Decided.Endpoint}, nil
|
||||
}
|
||||
|
||||
// hasRecord says the node already writes a record (a node of the mesh, ADR 0587 §4: "a node record ... holds it").
|
||||
@@ -214,10 +274,25 @@ func (h *Holder) hasRecord(node string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// privateRange is the private network's range: the hub's tunnel range in its placement, as the controller's `token issue`
|
||||
// writes it into the token today (the adopted tunnel's range). A mesh whose hub's placement names none has no range a token
|
||||
// can carry, and that is said rather than guessed.
|
||||
func privateRange(placements map[string]delivery.Placement) (netip.Prefix, error) {
|
||||
for _, p := range placements {
|
||||
if t := p.Decided.Tunnel; p.Decided.Hub && t != nil && t.Range != "" {
|
||||
r, err := netip.ParsePrefix(t.Range)
|
||||
if err != nil {
|
||||
return netip.Prefix{}, fmt.Errorf("the hub %s's tunnel range %q is no range: %w", p.Node, t.Range, err)
|
||||
}
|
||||
return r.Masked(), nil
|
||||
}
|
||||
}
|
||||
return netip.Prefix{}, errors.New("no private network is known: the hub's placement names no tunnel range")
|
||||
}
|
||||
|
||||
// nextAddress is the lowest address of the private network that no placement key and no reservation holds (ADR 0587 §4),
|
||||
// after skip. The network is the hub's range; the hub's own address and the tunnel's are never given.
|
||||
func nextAddress(placements map[string]delivery.Placement, reserved map[string]bool, skip map[string]bool) (string, error) {
|
||||
var prefix netip.Prefix
|
||||
taken := map[string]bool{}
|
||||
for _, p := range placements {
|
||||
if a := p.Decided.Address; a != "" {
|
||||
@@ -231,22 +306,11 @@ func nextAddress(placements map[string]delivery.Placement, reserved map[string]b
|
||||
taken[t.Address] = true
|
||||
}
|
||||
}
|
||||
if t.Range != "" && p.Decided.Hub && !prefix.IsValid() {
|
||||
if r, err := netip.ParsePrefix(t.Range); err == nil {
|
||||
prefix = r.Masked()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !prefix.IsValid() {
|
||||
for _, p := range placements {
|
||||
if a, err := netip.ParseAddr(p.Decided.Address); err == nil && p.Decided.Hub && a.Is4() {
|
||||
prefix = netip.PrefixFrom(a, 24).Masked()
|
||||
}
|
||||
}
|
||||
}
|
||||
if !prefix.IsValid() {
|
||||
return "", errors.New("no private network is known: the hub's placement names no range or address")
|
||||
prefix, err := privateRange(placements)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for a := prefix.Addr().Next(); prefix.Contains(a); a = a.Next() {
|
||||
s := a.String()
|
||||
|
||||
@@ -180,7 +180,7 @@ func (v *standInVault) Account(node, sealing, digest string) (delivery.NodeAccou
|
||||
return delivery.NodeAccountAnswer{Node: node, Login: delivery.FirstLogin(node), Account: "account-" + node}, nil
|
||||
}
|
||||
func (v *standInVault) Retire(node string) error { return nil }
|
||||
func (v *standInVault) SignCertificate(node, serving string) (delivery.SignAnswer, error) {
|
||||
func (v *standInVault) SignCertificate(node string, _ delivery.JoiningKeys) (delivery.SignAnswer, error) {
|
||||
return delivery.SignAnswer{Kind: delivery.KindNodeCertificate, Node: node, Certificate: "PEM"}, nil
|
||||
}
|
||||
func (v *standInVault) SignReply(node string, reply []byte) (delivery.SignAnswer, error) {
|
||||
@@ -257,10 +257,17 @@ func TestMeshEnrolmentWholeOnARealBus(t *testing.T) {
|
||||
_, err := js.Publish(ctx, "mesh.seat.mesh-enrolment.event."+event, raw, jetstream.WithMsgID(id))
|
||||
return err
|
||||
},
|
||||
// mesh-placement, stood in: an add-node writes the node's key, a remove-node deletes it, each saying placement-changed.
|
||||
AddNode: func(a AddNode) error {
|
||||
value := `{"v":1,"node":"` + a.Change.Node + `","decided":{"address":"` + a.Change.Decided.Address + `"},"modules":[]}`
|
||||
if _, err := placement.Put(ctx, a.Change.Node+".node", []byte(value)); err != nil {
|
||||
return err
|
||||
if a.Change.Op == delivery.OpRemoveNode {
|
||||
if err := placement.Delete(ctx, a.Change.Node+".node"); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
value := `{"v":1,"node":"` + a.Change.Node + `","decided":{"address":"` + a.Change.Decided.Address + `"},"modules":[]}`
|
||||
if _, err := placement.Put(ctx, a.Change.Node+".node", []byte(value)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
_, err := js.Publish(ctx, "mesh.seat.mesh-placement.event.placement-changed", []byte(`{}`))
|
||||
return err
|
||||
@@ -330,7 +337,7 @@ func TestMeshEnrolmentWholeOnARealBus(t *testing.T) {
|
||||
}
|
||||
|
||||
// Issue, the operator's yes, redeem as enrol.ace, confirm as node.ace.
|
||||
a, err := h.Issue("person.operator", "ace", "a new machine", 0)
|
||||
a, err := h.Issue("person.operator", "ace", "a new machine", tunnelKey, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -338,19 +345,19 @@ 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).
|
||||
// The token is whole (ADR 0587 §3, insight of 2026-11-02): it carries the first tunnel built at issue, the machine's tunnel
|
||||
// key given then, the private network's range and the hub's key and endpoint.
|
||||
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(); len(missing) != 0 || tok.Tunnel == nil || tok.Tunnel.Key != tunnelKey || tok.Tunnel.Address != a.Address+"/32" {
|
||||
t.Fatalf("the token misses %v: tunnel %+v", missing, 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)
|
||||
// The node is in placement before redeem: the operator's yes placed it, so the hub peers it before the machine dials.
|
||||
if ace, err := placement.Get(ctx, "ace.node"); err != nil || !strings.Contains(string(ace.Value()), a.Address) {
|
||||
t.Fatalf("ace is not in placement before redeem: %v", err)
|
||||
}
|
||||
args := redeemArgs("ace", 1, "serving-ace")
|
||||
args := redeemArgsWith("ace", 1, "serving-ace", tunnelKey)
|
||||
if _, err := h.Redeem("enrol.somebody", args); err == nil {
|
||||
t.Fatal("redeemed by another caller")
|
||||
}
|
||||
@@ -385,7 +392,7 @@ func TestMeshEnrolmentWholeOnARealBus(t *testing.T) {
|
||||
}
|
||||
|
||||
// A token never redeemed expires on the bus: the holder hears the marker, says enrolment-expired and the token is dropped.
|
||||
if _, err := h.Issue("person.operator", "g14", "a second machine", 2*time.Second); err != nil {
|
||||
if _, err := h.Issue("person.operator", "g14", "a second machine", tunnelKey, 2*time.Second); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := h.Warranted(asks.Option{ID: "yes"}, asks.Warrant{Ask: asker.asked[len(asker.asked)-1]}); err != nil {
|
||||
@@ -400,4 +407,8 @@ func TestMeshEnrolmentWholeOnARealBus(t *testing.T) {
|
||||
if hasUser("enrol.g14") {
|
||||
t.Error("the user enrol.g14 stays after its key expired")
|
||||
}
|
||||
// It never confirmed, so it left placement again before its end was said.
|
||||
if _, err := placement.Get(ctx, "g14.node"); err == nil {
|
||||
t.Error("g14 stays in placement after its enrolment expired")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ type IssueAnswer struct {
|
||||
// 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) {
|
||||
func (h *Holder) Issue(caller, node, why, overlayKey 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")
|
||||
@@ -57,6 +57,11 @@ func (h *Holder) Issue(caller, node, why string, lasts time.Duration) (IssueAnsw
|
||||
if strings.TrimSpace(why) == "" {
|
||||
findings = append(findings, "no why is given: the operator is asked why this machine joins")
|
||||
}
|
||||
// The machine's tunnel key, made there by `nox-mesh-host key`: on the operator's yes the node enters placement with it, so
|
||||
// the hub peers it before the machine redeems (the operator's decision of 2026-11-02 01:05).
|
||||
if err := checkTunnelKey(overlayKey); err != nil {
|
||||
findings = append(findings, err.Error())
|
||||
}
|
||||
if lasts == 0 {
|
||||
lasts = delivery.DefaultTokenLife
|
||||
}
|
||||
@@ -85,7 +90,7 @@ func (h *Holder) Issue(caller, node, why string, lasts time.Duration) (IssueAnsw
|
||||
}
|
||||
}
|
||||
if perr == nil {
|
||||
if _, err := h.firstTunnel(placements, "0.0.0.0"); err != nil {
|
||||
if _, err := h.firstTunnel(placements, "0.0.0.0", overlayKey); err != nil {
|
||||
findings = append(findings, err.Error())
|
||||
}
|
||||
}
|
||||
@@ -93,7 +98,8 @@ func (h *Holder) Issue(caller, node, why string, lasts time.Duration) (IssueAnsw
|
||||
return IssueAnswer{}, fmt.Errorf("no enrolment of %q is issued: %s", node, strings.Join(findings, "; "))
|
||||
}
|
||||
now := h.now()
|
||||
e := Enrolment{Node: node, Why: strings.TrimSpace(why), Caller: caller, Stage: StageAsked, Issued: now, Expires: now.Add(lasts)}
|
||||
e := Enrolment{Enrolment: delivery.Enrolment{Node: node, Why: strings.TrimSpace(why), Caller: caller, Stage: StageAsked,
|
||||
Issued: now, Expires: now.Add(lasts)}, OverlayKey: overlayKey}
|
||||
e.Ask = "enrol-" + node + "-" + now.UTC().Format("20060102t150405")
|
||||
skip := map[string]bool{}
|
||||
for {
|
||||
@@ -214,7 +220,7 @@ func (h *Holder) byAsk(ask string) (Enrolment, Row, bool, error) {
|
||||
}
|
||||
|
||||
// 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
|
||||
// the warrant, adds the node to placement with its tunnel key, 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)
|
||||
@@ -242,10 +248,15 @@ func (h *Holder) Warranted(o asks.Option, w asks.Warrant) error {
|
||||
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)
|
||||
tunnel, err := h.firstTunnel(placements, e.Address, e.OverlayKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("the token's tunnel is not built (mesh-enrolment.again asks again): %w", err)
|
||||
}
|
||||
// The node enters placement now, with its tunnel key, on this warrant: the hub peers it before the machine redeems over
|
||||
// the tunnel (the operator's decision of 2026-11-02 01:05). Only then is the token made.
|
||||
if err := h.place(e, placements); err != nil {
|
||||
return fmt.Errorf("the node %s is not added to placement, so no token is made (mesh-enrolment.again asks again): %w", e.Node, 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)
|
||||
}
|
||||
@@ -262,6 +273,10 @@ func (h *Holder) Warranted(o asks.Option, w asks.Warrant) error {
|
||||
|
||||
// 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 {
|
||||
// A yes whose token failed added the node already; a refusal takes it off again.
|
||||
if err := h.unplace(e, reason); err != nil {
|
||||
return fmt.Errorf("%s is not taken off placement, so its enrolment stays: %w", e.Node, err)
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
@@ -31,6 +32,9 @@ func issueRefusesNamingEveryFinding(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"}},
|
||||
{"no tunnel key and no range", func(r *rig) {
|
||||
r.rd["mesh-delivery-assigner.placement"]["hub.node"] = strings.Replace(hubPlacement, `,"range":"10.9.0.0/24"`, "", 1)
|
||||
}, "ace", -1, []string{"no tunnel key is given", "names no tunnel range"}},
|
||||
// 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)
|
||||
@@ -45,7 +49,11 @@ func issueRefusesNamingEveryFinding(t *testing.T) {
|
||||
if c.setup != nil {
|
||||
c.setup(r)
|
||||
}
|
||||
_, err := r.h.Issue("person.operator", c.node, "a new machine", c.lasts)
|
||||
key, lasts := tunnelKey, c.lasts
|
||||
if lasts == -1 {
|
||||
key, lasts = "", 0
|
||||
}
|
||||
_, err := r.h.Issue("person.operator", c.node, "a new machine", key, lasts)
|
||||
if err == nil {
|
||||
t.Fatalf("%s: issued", c.name)
|
||||
}
|
||||
@@ -68,7 +76,7 @@ func twoIssuesAtOnceHoldTwoAddressesAndAYesMakesTheToken(t *testing.T) {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
a, err := r.h.Issue("person.operator", n, "a new machine", 0)
|
||||
a, err := r.h.Issue("person.operator", n, "a new machine", tunnelKey, 0)
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
@@ -95,9 +103,19 @@ func twoIssuesAtOnceHoldTwoAddressesAndAYesMakesTheToken(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 {
|
||||
if got, want := r.vault.tunnels["ace"], (delivery.TokenTunnel{Key: tunnelKey, Address: live.Address + "/32", Range: "10.9.0.0/24",
|
||||
HubKey: "hub-tunnel-key", HubEndpoint: "192.0.2.1:51820"}); got != want {
|
||||
t.Errorf("the token's tunnel %+v, want %+v", got, want)
|
||||
}
|
||||
// The yes added the node to placement with its tunnel key alone, on the warrant, before the token was made.
|
||||
if p, tok := slices.Index(r.vault.calls, "add-node ace"), slices.Index(r.vault.calls, "token ace"); p < 0 || tok < p {
|
||||
t.Errorf("the token was made before placement held the node: %v", r.vault.calls)
|
||||
}
|
||||
for _, a := range r.added {
|
||||
if a.Change.Node == "ace" && (*a.Change.Keys != delivery.JoiningKeys{Overlay: tunnelKey} || a.Warrant.Ask != e.Ask || a.Change.Decided.Address != live.Address) {
|
||||
t.Errorf("the add-node at the yes %+v", a)
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
@@ -110,20 +128,20 @@ func twoIssuesAtOnceHoldTwoAddressesAndAYesMakesTheToken(t *testing.T) {
|
||||
// vault that fails leaves the enrolment at asked, for `again` to ask once more.
|
||||
func aDeclinedOrExpiredAskTakesBothKeysAndAVaultFailureKeepsTheEnrolmentForAgain(t *testing.T) {
|
||||
r := newRig(t)
|
||||
a, _ := r.h.Issue("person.operator", "ace", "a new machine", 0)
|
||||
a, _ := r.h.Issue("person.operator", "ace", "a new machine", tunnelKey, 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())
|
||||
if len(r.keys()) != 0 || r.events()[0] != EventRefused || len(r.added) != 0 {
|
||||
t.Fatalf("keys %v events %v placement %v", r.keys(), r.events(), r.added)
|
||||
}
|
||||
mustContain(t, string(r.said[0].Body), `"address":"`+a.Address+`"`)
|
||||
b, _ := r.h.Issue("person.operator", "g14", "a new machine", 0)
|
||||
b, _ := r.h.Issue("person.operator", "g14", "a new machine", tunnelKey, 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)
|
||||
c, _ := r.h.Issue("person.operator", "novox", "a new machine", tunnelKey, 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")
|
||||
@@ -131,6 +149,10 @@ func aDeclinedOrExpiredAskTakesBothKeysAndAVaultFailureKeepsTheEnrolmentForAgain
|
||||
if e, _ := r.enrolment("novox"); e.Stage != StageAsked {
|
||||
t.Fatalf("stage %s", e.Stage)
|
||||
}
|
||||
// The yes placed the node before the vault failed: a refusal now would take it off again (decline, below for ace).
|
||||
if _, placed := r.rd["mesh-delivery-assigner.placement"]["novox.node"]; !placed {
|
||||
t.Fatal("the yes did not place novox before the vault was asked")
|
||||
}
|
||||
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 {
|
||||
|
||||
@@ -117,6 +117,7 @@ type fakeVault struct {
|
||||
calls []string
|
||||
fail map[string]bool
|
||||
tunnels map[string]delivery.TokenTunnel
|
||||
cert delivery.JoiningKeys // the keys the certificate's call carried
|
||||
}
|
||||
|
||||
func (v *fakeVault) do(what string) error {
|
||||
@@ -143,7 +144,9 @@ func (v *fakeVault) Account(node, sealing, digest string) (delivery.NodeAccountA
|
||||
return delivery.NodeAccountAnswer{Node: node, Login: "node." + node, Account: "account-" + node}, v.do("account " + node)
|
||||
}
|
||||
func (v *fakeVault) Retire(node string) error { return v.do("retire " + node) }
|
||||
func (v *fakeVault) SignCertificate(node, serving string) (delivery.SignAnswer, error) {
|
||||
func (v *fakeVault) SignCertificate(node string, k delivery.JoiningKeys) (delivery.SignAnswer, error) {
|
||||
serving := k.Serving
|
||||
v.cert = k
|
||||
return delivery.SignAnswer{Kind: delivery.KindNodeCertificate, Node: node, Certificate: "PEM-" + serving}, v.do("certificate " + node)
|
||||
}
|
||||
func (v *fakeVault) SignReply(node string, reply []byte) (delivery.SignAnswer, error) {
|
||||
@@ -199,6 +202,9 @@ type rig struct {
|
||||
// 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"}}`
|
||||
|
||||
// tunnelKey is a machine's tunnel public key as `nox-mesh-host key` prints it.
|
||||
const tunnelKey = "dHVubmVsLWtleS1vZi10aGUtbmV3LW1hY2hpbmUtMDE="
|
||||
|
||||
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 {
|
||||
@@ -218,10 +224,14 @@ 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
|
||||
r.vault.calls = append(r.vault.calls, a.Change.Op+" "+a.Change.Node)
|
||||
if r.placeErr == nil { // placement writes or deletes 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":[]}`
|
||||
if a.Change.Op == delivery.OpRemoveNode {
|
||||
delete(r.rd["mesh-delivery-assigner.placement"], n+".node")
|
||||
} else {
|
||||
r.rd["mesh-delivery-assigner.placement"][n+".node"] = `{"v":1,"node":"` + n + `","decided":{"address":"` + a.Change.Decided.Address + `"},"modules":[]}`
|
||||
}
|
||||
}
|
||||
return r.placeErr
|
||||
},
|
||||
|
||||
@@ -13,8 +13,9 @@ 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), 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,
|
||||
// compare-and-set, with no expiry) once placement holds the node (written on the operator's yes; the vault reads the node's
|
||||
// address there) and the tunnel key presented is the one issued for, asks the vault for the node's account and the certificate
|
||||
// of the keys 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) {
|
||||
@@ -34,18 +35,15 @@ func (h *Holder) Redeem(caller string, a delivery.Redeem) (delivery.RedeemAnswer
|
||||
return delivery.RedeemAnswer{}, errors.New("no sealing key is presented, so the account cannot be sealed to the node: the enrolment is not spent")
|
||||
}
|
||||
digest := a.KeysDigest()
|
||||
e, err := h.spend(a.Node, digest)
|
||||
e, err := h.spend(a.Node, digest, a.OverlayKey)
|
||||
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)
|
||||
}
|
||||
cert, err := h.Vault.SignCertificate(e.Node, a.ServingKey)
|
||||
cert, err := h.Vault.SignCertificate(e.Node, delivery.JoiningKeys{Identity: a.PublicKey, Overlay: a.OverlayKey, Sealing: a.SealingKey, Serving: a.ServingKey})
|
||||
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)
|
||||
}
|
||||
@@ -76,7 +74,7 @@ func (h *Holder) Redeem(caller string, a delivery.Redeem) (delivery.RedeemAnswer
|
||||
|
||||
// spend moves the key from live to redeeming with the presented keys' digest, by compare-and-set, before its expiry; at
|
||||
// redeeming it only checks the digest. A key that moved meanwhile is read again once.
|
||||
func (h *Holder) spend(node, digest string) (Enrolment, error) {
|
||||
func (h *Holder) spend(node, digest, overlay string) (Enrolment, error) {
|
||||
for attempt := 0; attempt < 3; attempt++ {
|
||||
row, found, err := h.Store.Get(NodeKey(node))
|
||||
if err != nil {
|
||||
@@ -89,6 +87,16 @@ func (h *Holder) spend(node, digest string) (Enrolment, error) {
|
||||
if err != nil {
|
||||
return Enrolment{}, err
|
||||
}
|
||||
// The tunnel key presented is the one the operator approved at issue, which placement and the hub already hold.
|
||||
if overlay != e.OverlayKey {
|
||||
return Enrolment{}, fmt.Errorf("the tunnel key presented is not the one %s's enrolment was issued for: the enrolment is not spent", node)
|
||||
}
|
||||
// The node entered placement on the operator's yes; the vault reads its address there.
|
||||
if placements, err := h.placements(); err != nil {
|
||||
return Enrolment{}, fmt.Errorf("placement cannot be read (call redeem again with the same keys): %w", err)
|
||||
} else if p, ok := placements[node]; !ok || p.Decided.Address != e.Address {
|
||||
return Enrolment{}, fmt.Errorf("placement does not hold %s at %s yet (call redeem again with the same keys)", node, e.Address)
|
||||
}
|
||||
switch e.Stage {
|
||||
case StageRedeeming:
|
||||
if e.KeysDigest != digest {
|
||||
@@ -116,28 +124,3 @@ func (h *Holder) spend(node, digest string) (Enrolment, error) {
|
||||
}
|
||||
return Enrolment{}, fmt.Errorf("the enrolment of %s kept changing: call redeem again", node)
|
||||
}
|
||||
|
||||
// place writes the node into placement with the issue's warrant, unless placement holds it at the approved address already.
|
||||
func (h *Holder) place(e Enrolment, a delivery.Redeem) error {
|
||||
placements, err := h.placements()
|
||||
if err != nil {
|
||||
return fmt.Errorf("placement cannot be read (call redeem again with the same keys): %w", err)
|
||||
}
|
||||
if p, ok := placements[e.Node]; ok && p.Decided.Address == e.Address {
|
||||
return nil
|
||||
}
|
||||
if h.AddNode == nil || h.WarrantOf == nil {
|
||||
return errors.New("no route to mesh-placement")
|
||||
}
|
||||
warrant, err := h.WarrantOf(e.Ask)
|
||||
if err != nil {
|
||||
return fmt.Errorf("the operator's warrant for %s cannot be read from the router (call redeem again with the same keys): %w", e.Ask, err)
|
||||
}
|
||||
change := delivery.PlacementProposalChange{Op: delivery.OpAddNode, Node: e.Node, Decided: &delivery.NodeDecided{Address: e.Address},
|
||||
Keys: &delivery.JoiningKeys{Identity: a.PublicKey, Overlay: a.OverlayKey, Sealing: a.SealingKey, Serving: a.ServingKey}}
|
||||
if err := h.AddNode(AddNode{Why: "enrolment of " + e.Node + ": " + e.Why, Change: change,
|
||||
Warrant: delivery.AddNodeWarrant{Node: e.Node, Address: e.Address, Ask: e.Ask, Warrant: warrant}}); err != nil {
|
||||
return fmt.Errorf("%w (call redeem again with the same keys)", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"bytes"
|
||||
"crypto/ed25519"
|
||||
"encoding/base64"
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -13,9 +12,14 @@ import (
|
||||
|
||||
// redeemArgs are a node's redeem call with a proof signed by the key seeded, over the keys given.
|
||||
func redeemArgs(node string, seed byte, serving string) delivery.Redeem {
|
||||
return redeemArgsWith(node, seed, serving, "overlay-"+node)
|
||||
}
|
||||
|
||||
// redeemArgsWith is redeemArgs presenting the tunnel key given.
|
||||
func redeemArgsWith(node string, seed byte, serving, overlay string) delivery.Redeem {
|
||||
priv := ed25519.NewKeyFromSeed(bytes.Repeat([]byte{seed}, ed25519.SeedSize))
|
||||
a := delivery.Redeem{Node: node, PublicKey: base64.StdEncoding.EncodeToString(priv.Public().(ed25519.PublicKey)),
|
||||
OverlayKey: "overlay-" + node, SealingKey: "sealing-" + node, ServingKey: serving}
|
||||
OverlayKey: overlay, SealingKey: "sealing-" + node, ServingKey: serving}
|
||||
a.Proof = base64.StdEncoding.EncodeToString(ed25519.Sign(priv, delivery.RedeemProof(node, a.PublicKey, a.OverlayKey, a.SealingKey, a.ServingKey)))
|
||||
return a
|
||||
}
|
||||
@@ -50,6 +54,14 @@ func redeemRefusesWhatItMustAndSpendsTheTokenOnce(t *testing.T) {
|
||||
_, _ = r.st.Put(NodeKey("ace"), e, 0)
|
||||
}, "waits for the operator's yes"},
|
||||
{"no enrolment", "enrol.novox", redeemArgs("novox", 3, "s"), nil, "no enrolment of novox is open"},
|
||||
{"another tunnel key than issued", "enrol.ace", func() delivery.Redeem {
|
||||
priv := ed25519.NewKeyFromSeed(bytes.Repeat([]byte{1}, ed25519.SeedSize))
|
||||
a := good
|
||||
a.OverlayKey = "another-tunnel-key"
|
||||
a.Proof = base64.StdEncoding.EncodeToString(ed25519.Sign(priv, delivery.RedeemProof("ace", a.PublicKey, a.OverlayKey, a.SealingKey, a.ServingKey)))
|
||||
return a
|
||||
}(), nil, "not the one ace's enrolment was issued for"},
|
||||
{"not in placement yet", "enrol.ace", good, func(r *rig) { delete(r.rd["mesh-delivery-assigner.placement"], "ace.node") }, "placement does not hold ace"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
r := newRig(t)
|
||||
@@ -78,13 +90,13 @@ func redeemRefusesWhatItMustAndSpendsTheTokenOnce(t *testing.T) {
|
||||
if e.Stage != StageRedeeming || e.KeysDigest != good.KeysDigest() || r.st.ttl[NodeKey("ace")] != 0 || r.st.ttl[AddressKey("10.9.0.2")] != 0 {
|
||||
t.Errorf("spent %+v, ttl %v/%v", e, r.st.ttl[NodeKey("ace")], r.st.ttl[AddressKey("10.9.0.2")])
|
||||
}
|
||||
if len(r.added) != 1 || r.added[0].Warrant != (delivery.AddNodeWarrant{Ask: "ask-ace", Node: "ace", Address: "10.9.0.2", Warrant: "ask-ace@2026-11-02T10:00:00Z"}) ||
|
||||
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" {
|
||||
// Redeem writes no placement: the yes did. It binds the identity keys, which the vault checks against the account's digest.
|
||||
if len(r.added) != 0 || 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)
|
||||
// The certificate's call carries every key presented, which the vault checks against the account's digest.
|
||||
if r.vault.cert.Digest() != good.KeysDigest() {
|
||||
t.Errorf("the certificate was asked with %+v", r.vault.cert)
|
||||
}
|
||||
r.loaded = false
|
||||
if _, err := r.h.Redeem("enrol.ace", good); err == nil {
|
||||
@@ -113,8 +125,8 @@ func aRedeemStoppedMidwayIsFinishedByTheSameKeysAgain(t *testing.T) {
|
||||
if err != nil || got != want {
|
||||
t.Errorf("%s: %v, answer %+v, want %+v", stop, err, got, want)
|
||||
}
|
||||
if stop == "sign ace" && len(r.added) != 1 {
|
||||
t.Errorf("placement was written %d times", len(r.added))
|
||||
if len(r.added) != 0 {
|
||||
t.Errorf("redeem wrote placement %d times", len(r.added))
|
||||
}
|
||||
}
|
||||
// Placement that already holds the node at the approved address is not written again.
|
||||
|
||||
@@ -13,13 +13,16 @@ import (
|
||||
|
||||
// seed writes an open enrolment of node at stage, with its address key, as the holder would have.
|
||||
func (r *rig) seed(node, address string, stage string) {
|
||||
e := Enrolment{Node: node, Address: address, Why: "a new machine", Caller: "person.operator", Ask: "ask-" + node, Stage: stage,
|
||||
Issued: r.now.Add(-time.Minute), Expires: r.now.Add(time.Hour)}
|
||||
e := Enrolment{Enrolment: delivery.Enrolment{Node: node, Address: address, Why: "a new machine", Caller: "person.operator", Ask: "ask-" + node,
|
||||
Stage: stage, Issued: r.now.Add(-time.Minute), Expires: r.now.Add(time.Hour)}, OverlayKey: "overlay-" + node}
|
||||
if stage == StageRedeeming {
|
||||
e.KeysDigest = delivery.KeysDigest("pk", "ov", "se", "sv")
|
||||
}
|
||||
_, _ = r.st.Put(NodeKey(node), e, 0)
|
||||
_, _ = r.st.Put(AddressKey(address), Reservation{Node: node}, 0)
|
||||
if stage != StageAsked { // the operator's yes placed it, with its tunnel key
|
||||
r.rd["mesh-delivery-assigner.placement"][node+".node"] = `{"v":1,"node":"` + node + `","decided":{"address":"` + address + `"},"modules":[]}`
|
||||
}
|
||||
}
|
||||
|
||||
// 774/S54/AC1 and AC2: tokens answers each stage with its expiry, ask and revision, the fill and whether issuing is on;
|
||||
|
||||
@@ -50,22 +50,24 @@ func Tools(h *Holder) []stdio.Tool {
|
||||
"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.",
|
||||
"writes the enrolment at stage asked and asks the operator on a channel that proves who answered; only their yes adds the " +
|
||||
"node to placement with the machine's tunnel key (overlay_key, as `nox-mesh-host key` prints it there), so the hub peers " +
|
||||
"it, and then makes the token, which carries the first tunnel (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"),
|
||||
"expires": str("how long the token lasts, e.g. 1h (default) up to 24h"),
|
||||
"overlay_key": str("the new machine's tunnel public key, as `nox-mesh-host key` prints it there")}, "node", "why", "overlay_key"),
|
||||
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)
|
||||
return h.Issue(stdio.CallerOf(a), arg(a, "node"), arg(a, "why"), arg(a, "overlay_key"), lasts)
|
||||
}},
|
||||
{Name: RedeemTool,
|
||||
Description: "The joining machine's one call (novox/hq ADR 0587 §5, §6), answered only for a caller the runtime proves to be " +
|
||||
"enrol.<node>: its node-engine presents the public halves of the keys it made where it runs and a proof signed with its " +
|
||||
"identity key. The token is spent (once, with those keys), the vault gives the node's account and signs its certificate, " +
|
||||
"the node is written into placement on the issue's warrant, and the answer is given once the bus node loaded node.<node>, " +
|
||||
"identity key, and the tunnel key it was issued for. The token is spent (once, with those keys) once placement holds the " +
|
||||
"node (placed on the operator's yes), the vault gives the node's account and signs its certificate, and the answer is given once the bus node loaded node.<node>, " +
|
||||
"signed by the vault. Called again with the same keys it finishes what is not done and answers the same.",
|
||||
Input: obj(map[string]any{"node": str("the machine's name"), "public_key": str("its identity key, base64"),
|
||||
"overlay_key": str("its private-network key"), "sealing_key": str("its sealing key, base64"),
|
||||
@@ -89,8 +91,8 @@ func Tools(h *Holder) []stdio.Tool {
|
||||
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 " +
|
||||
"the key and its address key, and at redeeming the vault retires an account it already minted; a node the yes placed " +
|
||||
"and that never confirmed is taken off placement again. 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"),
|
||||
|
||||
@@ -40,8 +40,8 @@ func answer[T any](raw json.RawMessage, err error) (T, error) {
|
||||
}
|
||||
|
||||
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}
|
||||
// The whole first tunnel the token carries (delivery.TokenTunnel, catalogue's enrolment-token).
|
||||
tunnel := map[string]any{"hub_key": t.HubKey, "hub_endpoint": t.HubEndpoint, "address": t.Address, "key": t.Key, "range": t.Range}
|
||||
return answer[delivery.EnrolmentTokenAnswer](v.call(vaultToken, map[string]any{"node": node,
|
||||
"expires": expires.UTC().Format(time.RFC3339), "tunnel": tunnel}))
|
||||
}
|
||||
@@ -74,8 +74,9 @@ func (v vaultOver) Retire(node string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func (v vaultOver) SignCertificate(node, servingKey string) (delivery.SignAnswer, error) {
|
||||
return answer[delivery.SignAnswer](v.call(vaultSign, map[string]any{"kind": delivery.KindNodeCertificate, "node": node, "serving_key": servingKey}))
|
||||
func (v vaultOver) SignCertificate(node string, k delivery.JoiningKeys) (delivery.SignAnswer, error) {
|
||||
return answer[delivery.SignAnswer](v.call(vaultSign, map[string]any{"kind": delivery.KindNodeCertificate, "node": node,
|
||||
"public_key": k.Identity, "overlay_key": k.Overlay, "sealing_key": k.Sealing, "serving_key": k.Serving}))
|
||||
}
|
||||
|
||||
func (v vaultOver) SignReply(node string, reply []byte) (delivery.SignAnswer, error) {
|
||||
|
||||
Reference in New Issue
Block a user