From b05ac4f4b2dd7b0a0932725eda5dddb0cb140f1a Mon Sep 17 00:00:00 2001 From: jochens Date: Fri, 2 Oct 2026 13:31:22 +0200 Subject: [PATCH 1/3] A token can be issued for a machine's tunnel key, and it joins through the tunnel token issue --overlay-key records the key the machine made, binds the token to it, gives the machine its address and makes it a peer of the hub, pushing the hub before the token is shown. The token carries the hub's tunnel and the bus at its holder's address on the private network, and enrolment refuses any other key (novox/hq ADR 0169). The bus is no longer public, so a machine outside the mesh can join only this way; a token without a key is still what the machine running the bus joins its own mesh with. Also a token verb, which says it replaces running the command by hand and adding a peer to the hub with wg. --- cmd/mesh-controller/network.go | 15 +++- cmd/mesh-controller/nodes.go | 87 ++++++++++++++++++- cmd/mesh-controller/seatverbs.go | 20 +++++ cmd/mesh-controller/seatverbs_schema_test.go | 2 + cmd/mesh-controller/seatverbs_test.go | 8 ++ internal/catalogue/verbs.go | 11 +++ ...079-a-token-is-issued-for-a-tunnel-key.sql | 7 ++ internal/inventory/nodes.go | 27 ++++++ internal/link/enrol_tunnel_key_test.go | 37 ++++++++ internal/link/enrolment.go | 12 +++ internal/token/token.go | 33 +++++++ internal/token/token_test.go | 35 ++++++++ module.json | 1 + 13 files changed, 293 insertions(+), 2 deletions(-) create mode 100644 internal/inventory/migrations/0079-a-token-is-issued-for-a-tunnel-key.sql create mode 100644 internal/link/enrol_tunnel_key_test.go diff --git a/cmd/mesh-controller/network.go b/cmd/mesh-controller/network.go index 8e612e1d..49cf5d76 100644 --- a/cmd/mesh-controller/network.go +++ b/cmd/mesh-controller/network.go @@ -232,9 +232,22 @@ func network(ctx context.Context, inv *inventory.Inventory, on map[string]bool, if err != nil { return nil, err } + // **A machine joining is on the network before it is anything else** (novox/hq ADR 0169). Its + // token was issued for its tunnel key and gave it an address, so while that token can still be + // used the hub carries it as a peer: it brings its tunnel up from the token and enrols over it. + // When the token is spent the machine is on the network by what it runs, as every other is; when + // it expires unused, the peer goes with it at the hub's next composition. + joining, err := inv.NodesWithALiveToken(ctx) + if err != nil { + return nil, err + } + isJoining := map[string]bool{} + for _, name := range joining { + isJoining[name] = true + } nodes := make([]overlay.Node, 0, len(places)) for _, p := range places { - if !on[p.Name] { + if !on[p.Name] && !(isJoining[p.Name] && p.Key != "" && p.Address != "") { continue } n := overlay.Node{ diff --git a/cmd/mesh-controller/nodes.go b/cmd/mesh-controller/nodes.go index 8c63a064..d784e574 100644 --- a/cmd/mesh-controller/nodes.go +++ b/cmd/mesh-controller/nodes.go @@ -2,15 +2,18 @@ package main import ( "context" + "encoding/base64" "encoding/json" "errors" "flag" "fmt" - "github.com/novox/mesh-controller/internal/conditions" + "net" "strings" "time" "github.com/novox/mesh-controller/internal/broker" + "github.com/novox/mesh-controller/internal/catalogue" + "github.com/novox/mesh-controller/internal/conditions" "github.com/novox/mesh-controller/internal/inventory" "github.com/novox/mesh-controller/internal/token" ) @@ -247,6 +250,8 @@ func tokenCommand(ctx context.Context, args []string) error { validFor := set.Duration("for", time.Hour, "how long the token may be used") adopted := set.Bool("adopted", false, "the machine joining is in use: it is adopted, and keeps what is found on it") + tunnelKey := set.String("overlay-key", "", + "the public half of the tunnel key the machine made (`nox-mesh-host key`): it joins through the tunnel") if err := set.Parse(args[1:]); err != nil { return err } @@ -300,6 +305,14 @@ func tokenCommand(ctx context.Context, args []string) error { default: return err } + // **Through the tunnel** (novox/hq ADR 0169): the machine's key recorded, its address given, the + // hub sent it as a peer — all before the token is shown, so the tunnel answers the first time the + // machine knocks. The bus is then reached at its address on the private network. + if *tunnelKey != "" { + if made.Tunnel, made.Broker, err = throughTheTunnel(ctx, open, issued.Node, *tunnelKey, made.Broker); err != nil { + return err + } + } encoded, err := made.Encode() if err != nil { return err @@ -324,6 +337,78 @@ func tokenCommand(ctx context.Context, args []string) error { return nil } +// throughTheTunnel makes a machine a peer of the hub for its token, and says what the token carries +// for it: its first tunnel, and the bus at its address on the private network (novox/hq ADR 0169). +// +// The hub is pushed here, before the token is shown. A token shown before the hub knew the key is a +// tunnel that does not answer, and a machine that cannot tell that from a bus that is down. +func throughTheTunnel(ctx context.Context, open *stores, node inventory.Node, key, busAt string) ( + *token.Tunnel, string, error) { + inv := open.inventory + key = strings.TrimSpace(key) + if raw, err := base64.StdEncoding.DecodeString(key); err != nil || len(raw) != 32 { + return nil, "", fmt.Errorf("%q is not a tunnel public key: it is 32 bytes in base64, as "+ + "`nox-mesh-host key` prints it", key) + } + // The bus on the private network is its holder's address at the bus's own port, so the port must + // be known before anything is recorded. + _, port, err := net.SplitHostPort(busAt) + if err != nil || port == "" { + return nil, "", fmt.Errorf("the bus's address %q has no port to reach it on", busAt) + } + places, err := inv.Overlays(ctx) + if err != nil { + return nil, "", err + } + var hub *inventory.Overlay + for i := range places { + if places[i].Hub { + hub = &places[i] + } + } + if hub == nil || hub.Key == "" || hub.Endpoint == "" || hub.Address == "" { + return nil, "", errors.New("this mesh has no hub with a key, an address and an endpoint to " + + "dial, so there is no tunnel to join through: place one (`overlay place --hub " + + "--endpoint :`), or issue the token without --overlay-key") + } + if err := inv.RecordOverlayKey(ctx, node.ID, key); err != nil { + return nil, "", err + } + if err := inv.BindTokenToKey(ctx, node.ID, key); err != nil { + return nil, "", err + } + cidr, err := overlayRange(ctx, inv) + if err != nil { + return nil, "", err + } + address, err := inv.AssignAddress(ctx, node.ID, cidr) + if err != nil { + return nil, "", err + } + // The bus at its holder's address on the private network, reached through the hub like the rest + // of the range; the hub's own when no machine is recorded as holding it yet. + busAddress := hub.Address + if holders, err := seatHolders(ctx, inv); err != nil { + return nil, "", err + } else if h, held := holders[catalogue.BrokerSeat]; held { + for _, p := range places { + if p.Name == h.Node && p.Address != "" { + busAddress = p.Address + } + } + } + if err := sendTo(ctx, open, []string{hub.Name}); err != nil { + return nil, "", fmt.Errorf("%s was made a peer of the hub, and the hub could not be sent "+ + "it, so the tunnel would not answer — the token is not shown; issue it again once %s "+ + "can be pushed: %w", node.Name, hub.Name, err) + } + // An address, not a name — nothing resolves before the machine has joined (novox/hq ADR 0004). + return &token.Tunnel{ + Key: key, Address: address + "/32", Range: cidr, + HubKey: hub.Key, HubEndpoint: hub.Endpoint, + }, net.JoinHostPort(busAddress, port), nil +} + // issueFor is the inventory's half of issuing a token: the record, made when it is new, adopted // when the operator says so, and the one-time secret for it. The node in what it returns carries // its mode, which is what the token says. diff --git a/cmd/mesh-controller/seatverbs.go b/cmd/mesh-controller/seatverbs.go index 351df3e2..848acd70 100644 --- a/cmd/mesh-controller/seatverbs.go +++ b/cmd/mesh-controller/seatverbs.go @@ -675,6 +675,26 @@ func (a *verbArguments) commandLine() ([]string, error) { // Neither shape: the command says its usage, which names both, and that is the answer the // caller needs. return []string{"rotate"}, nil + case "token": + // `token issue` at a shell (novox/hq ADR 0169). Exactly one of node or new; the command + // refuses both or neither in its own words. + argv := []string{"token", "issue"} + if n := str("node"); n != "" { + argv = append(argv, "--node", n) + } + if n := str("new"); n != "" { + argv = append(argv, "--new", n) + } + if k := str("overlay_key"); k != "" { + argv = append(argv, "--overlay-key", k) + } + if d := str("for"); d != "" { + argv = append(argv, "--for", d) + } + if str("adopted") == "true" { + argv = append(argv, "--adopted") + } + return argv, nil case "settings": // `settings set|clear` at a shell (novox/hq issue 198). The values travel as an argument // because a tool has no file to hand the command; the command reads either. diff --git a/cmd/mesh-controller/seatverbs_schema_test.go b/cmd/mesh-controller/seatverbs_schema_test.go index 9ab03392..d998cb59 100644 --- a/cmd/mesh-controller/seatverbs_schema_test.go +++ b/cmd/mesh-controller/seatverbs_schema_test.go @@ -272,6 +272,8 @@ var accountedFlags = map[string]map[string]string{ }, "builds": {"n": "=limit"}, "plans": {"n": "=limit", "what-if": "=repository"}, + // The machine's tunnel key, named as the verb's other arguments are (novox/hq ADR 0169). + "token issue": {"overlay-key": "=overlay_key"}, "durations": { "json": "set by the verb: the answer is data", "all": "withheld: every measurement of a fortnight is more than a call should carry; `command` reaches it", diff --git a/cmd/mesh-controller/seatverbs_test.go b/cmd/mesh-controller/seatverbs_test.go index 64d3799f..70c2457f 100644 --- a/cmd/mesh-controller/seatverbs_test.go +++ b/cmd/mesh-controller/seatverbs_test.go @@ -62,6 +62,14 @@ func TestRotateTakesAProvisionOrAnOwnSecret(t *testing.T) { } } +// `token` is `token issue` at a shell, with the machine's tunnel key (novox/hq ADR 0169). +func TestTokenIssuesForAMachineAndItsTunnelKey(t *testing.T) { + argv, err := argvFor("token", map[string]any{"new": "laptop", "overlay_key": "k", "for": "2h"}) + if err != nil || strings.Join(argv, " ") != "token issue --new laptop --overlay-key k --for 2h" { + t.Fatalf("token: %v %v", argv, err) + } +} + // `settings` is `settings set|clear` at a shell, with the values passed inline (novox/hq issue 198). func TestSettingsSetsOrClearsALayer(t *testing.T) { argv, err := argvFor("settings", map[string]any{"module": "dnsmasq", "values": `{"a":1}`, "node": "ace"}) diff --git a/internal/catalogue/verbs.go b/internal/catalogue/verbs.go index 94f3c619..65803dfd 100644 --- a/internal/catalogue/verbs.go +++ b/internal/catalogue/verbs.go @@ -228,6 +228,17 @@ var ControllerVerbs = []Verb{ "node": "the machine that runs the module", "module": "the module's name", }, []string{"node", "module"})}, + {Name: "token", Description: "Issue a one-time token for a machine to join with. Give the public half of the " + + "tunnel key the machine made (`nox-mesh-host key`): the machine is given its address and made a peer of " + + "the hub, and joins through the tunnel. The token is shown once, in the answer.", + Input: schema(map[string]string{ + "node": "a machine the mesh already has a record for", + "new": "or the name of a machine to create the record for", + "overlay_key": "the public half of the machine's tunnel key", + "for": "how long it may be used, as a duration (default 1h)", + "adopted": "\"true\" when the machine is in use and joins adopted", + }, nil, "adopted"), + Replaces: []string{"mesh-controller token issue", "wg set"}}, {Name: "settings", Description: "Read or set what an assignment is configured with: a module's settings for the whole " + "mesh, or for one machine. Without values or clear, answers the layer as it stands — read it before setting it; " + "with history, the layers it replaced. Setting replaces that layer whole and answers each key it adds (+), " + diff --git a/internal/inventory/migrations/0079-a-token-is-issued-for-a-tunnel-key.sql b/internal/inventory/migrations/0079-a-token-is-issued-for-a-tunnel-key.sql new file mode 100644 index 00000000..6faeae69 --- /dev/null +++ b/internal/inventory/migrations/0079-a-token-is-issued-for-a-tunnel-key.sql @@ -0,0 +1,7 @@ +-- A token issued for a tunnel key (novox/hq ADR 0169). +-- +-- A machine that joins through the tunnel makes its key first, and the token is issued for it: the +-- hub is told the key before the token is shown. So enrolment must take that key and no other — a +-- different one is a machine the hub does not know, offering a tunnel that would never answer. Null +-- for a token issued without one, which enrols as before. +alter table enrolment_token add column overlay_key text; diff --git a/internal/inventory/nodes.go b/internal/inventory/nodes.go index 0e1dbc66..4c13ad05 100644 --- a/internal/inventory/nodes.go +++ b/internal/inventory/nodes.go @@ -361,6 +361,33 @@ func (i *Inventory) Claim(ctx context.Context, secret, by string, again bool) (N return scanNode(i.store.Pool().QueryRow(ctx, `select `+nodeColumns+` from node where id = $1`, id)) } +// BindTokenToKey records the tunnel key a node's live token was issued for (novox/hq ADR 0169), so +// enrolment takes that key and no other. Refused when the node has no live token to bind: a key +// recorded against nothing would be a promise nothing keeps. +func (i *Inventory) BindTokenToKey(ctx context.Context, node, key string) error { + tag, err := i.store.Pool().Exec(ctx, + `update enrolment_token set overlay_key = $2 + where node = $1 and redeemed is null and expires > now()`, node, key) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return fmt.Errorf("no live token to issue for the tunnel key") + } + return nil +} + +// TokenKey is the tunnel key a token was issued for, or empty when it was issued without one. +func (i *Inventory) TokenKey(ctx context.Context, secret string) (string, error) { + var key *string + err := i.store.Pool().QueryRow(ctx, + `select overlay_key from enrolment_token where secret = $1`, hashSecret(secret)).Scan(&key) + if err != nil || key == nil { + return "", err + } + return *key, nil +} + // Spend makes a claimed token used, only for the presenter holding the claim. The last write to the // store in an enrolment, so a token is spent exactly when the node it enrolled is complete. Spent // again by the same presenter is not an error: an answer lost after the first spend. diff --git a/internal/link/enrol_tunnel_key_test.go b/internal/link/enrol_tunnel_key_test.go new file mode 100644 index 00000000..f5604f42 --- /dev/null +++ b/internal/link/enrol_tunnel_key_test.go @@ -0,0 +1,37 @@ +package link_test + +import ( + "context" + "strings" + "testing" + + "github.com/novox/mesh-controller/internal/link" +) + +// **A token issued for a tunnel key takes that key and no other** (novox/hq ADR 0169). The hub was +// sent the key before the token was shown, so another key is a machine it does not know. +func TestATokenIssuedForATunnelKeyTakesThatKeyAndNoOther(t *testing.T) { + inv, ident := aMeshReadyToEnrol(t) + ctx := context.Background() + secret, public := aTokenFor(t, inv, "joiner") + node, err := inv.NodeByName(ctx, "joiner") + if err != nil { + t.Fatal(err) + } + const issuedFor = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + if err := inv.BindTokenToKey(ctx, node.ID, issuedFor); err != nil { + t.Fatal(err) + } + e := link.Enrolment{Inventory: inv, Identity: ident} + + _, err = e.Enrol(ctx, link.EnrolRequest{Node: "joiner", Secret: secret, PublicKey: public, + OverlayKey: "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB="}) + if err == nil || !strings.Contains(err.Error(), "issued for the tunnel key") { + t.Fatalf("a token issued for one key took another: %v", err) + } + + if _, err := e.Enrol(ctx, link.EnrolRequest{Node: "joiner", Secret: secret, PublicKey: public, + OverlayKey: issuedFor}); err != nil { + t.Fatalf("the key the token was issued for was refused: %v", err) + } +} diff --git a/internal/link/enrolment.go b/internal/link/enrolment.go index 45d00ab2..6c3cab6b 100644 --- a/internal/link/enrolment.go +++ b/internal/link/enrolment.go @@ -87,6 +87,18 @@ func (e Enrolment) Enrol(ctx context.Context, request EnrolRequest) (reply Enrol if err != nil { return EnrolReply{}, err } + // **A token issued for a tunnel key takes that key and no other** (novox/hq ADR 0169). The hub + // was told it before the token was shown; another key is a machine the hub does not know. + // Checked before anything is recorded, so a refusal changes nothing. + bound, err := e.Inventory.TokenKey(ctx, secret) + if err != nil { + return EnrolReply{}, err + } + if bound != "" && request.OverlayKey != bound { + return EnrolReply{}, fmt.Errorf("%s's token was issued for the tunnel key %s and the machine "+ + "offered %q — the key it made with `nox-mesh-host key` is the one to issue for", + node.Name, bound, request.OverlayKey) + } if _, err := e.Identity.RecordNodeKey(ctx, node.ID, public); err != nil { return EnrolReply{}, fmt.Errorf("%s's key could not be recorded: %w", node.Name, err) diff --git a/internal/token/token.go b/internal/token/token.go index ee7ab491..b97e7118 100644 --- a/internal/token/token.go +++ b/internal/token/token.go @@ -55,6 +55,26 @@ type Token struct { // that it speaks the firewall found on the machine, because an adopted node keeps that firewall // in force. Absent for a converged node, so a converged token is byte for byte what it was. Adopted bool `json:"adopted,omitempty"` + + // Tunnel is the one peer a joining machine needs, when the token was issued for its tunnel key + // (novox/hq ADR 0169). The machine brings its tunnel up from this alone and reaches the bus over + // it, at an address on the private network — so the bus is never open to the internet. Absent + // on a token issued without a key, which then reads byte for byte as before. + Tunnel *Tunnel `json:"tunnel,omitempty"` +} + +// Tunnel is the joining machine's side of its first tunnel: its own address and the hub to reach. +type Tunnel struct { + // Key is the public half of the key the machine made itself, which this token was issued for. + // The private half never left the machine (novox/hq ADR 0004). + Key string `json:"key"` + // Address is the machine's own address on the private network, with its prefix. + Address string `json:"address"` + // Range is the private network, routed through the hub until the machine is told more. + Range string `json:"range"` + // HubKey and HubEndpoint are the hub's tunnel key and where it is dialled. + HubKey string `json:"hub_key"` + HubEndpoint string `json:"hub_endpoint"` } // Missing names the parts that are not filled in. @@ -83,6 +103,19 @@ func (t Token) Missing() []string { if strings.TrimSpace(t.Secret) == "" { missing = append(missing, "the one-time secret — nothing to present") } + if t.Tunnel != nil { + for _, part := range []struct{ value, says string }{ + {t.Tunnel.Key, "the machine's own tunnel key — the hub would not know it"}, + {t.Tunnel.Address, "the machine's address on the private network"}, + {t.Tunnel.Range, "the private network's range — nothing to route through the hub"}, + {t.Tunnel.HubKey, "the hub's tunnel key — nothing to dial"}, + {t.Tunnel.HubEndpoint, "where the hub's tunnel is dialled"}, + } { + if strings.TrimSpace(part.value) == "" { + missing = append(missing, part.says) + } + } + } return missing } diff --git a/internal/token/token_test.go b/internal/token/token_test.go index ecf3f785..c4d656f5 100644 --- a/internal/token/token_test.go +++ b/internal/token/token_test.go @@ -172,3 +172,38 @@ func TestATokenWithNoNameIsRefused(t *testing.T) { t.Fatalf("the refusal does not say what is missing: %v", without.Missing()) } } + +// A token issued for a tunnel key carries the one peer a joining machine needs (novox/hq ADR 0169), +// and says which part is missing rather than producing a tunnel that never answers. +func TestATokenThroughTheTunnelCarriesThePeerOrSaysWhatIsMissing(t *testing.T) { + whole := Token{Node: "n", Broker: "10.42.0.1:4222", Fingerprint: "sha256:x", Signer: make([]byte, 32), Secret: "s", + Tunnel: &Tunnel{Key: "k", Address: "10.42.0.9/32", Range: "10.42.0.0/16", HubKey: "h", HubEndpoint: "198.51.100.1:51820"}} + if !whole.Complete() { + t.Fatalf("a whole token through the tunnel reads as missing %v", whole.Missing()) + } + encoded, err := whole.Encode() + if err != nil { + t.Fatal(err) + } + back, err := Decode(encoded) + if err != nil { + t.Fatal(err) + } + if back.Tunnel == nil || *back.Tunnel != *whole.Tunnel { + t.Fatalf("the tunnel did not survive the round trip: %+v", back.Tunnel) + } + + part := whole + part.Tunnel = &Tunnel{Key: "k", Address: "10.42.0.9/32"} + if len(part.Missing()) != 3 { + t.Errorf("a tunnel without the hub and the range should name three missing parts: %v", part.Missing()) + } + + // And a token issued without a key carries no tunnel at all, byte for byte as before. + plain := whole + plain.Tunnel = nil + raw, _ := plain.Encode() + if strings.Contains(raw, "tunnel") { + t.Error("a token without a key mentions a tunnel") + } +} diff --git a/module.json b/module.json index 4a940723..0ee497d0 100644 --- a/module.json +++ b/module.json @@ -49,6 +49,7 @@ "push", "rotate", "issue", + "token", "settings", "command", "queue", From 913095d2919f550a5b9f748ba344d75094cf32f3 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 8 Oct 2026 01:46:34 +0200 Subject: [PATCH 2/3] Keep a join token nowhere but in its caller's answer The calls the controller serves are kept on the bus with their answers, and calls answers anyone who may call its seat. A token is the one-time right to become a machine of the mesh, so the token verb's answer is kept as withheld (novox/hq ADR 0169). --- internal/link/calls.go | 17 +++++++++++++++++ internal/link/calls_test.go | 24 ++++++++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/internal/link/calls.go b/internal/link/calls.go index 588b9e78..56101e31 100644 --- a/internal/link/calls.go +++ b/internal/link/calls.go @@ -302,9 +302,26 @@ func (l *CallLog) finish(c *Call, answer []byte, failed, answeredAlready bool) { } else { c.State = CallAnswered } + if shownOnce[c.Seat+"."+c.Verb] { + // **A secret shown once is never kept on the bus** (novox/hq ADR 0169): a join token is the + // one-time right to become a machine of the mesh, and `calls` answers anyone who may call the + // seat. Its caller was sent it; kept in this process's memory only when its caller has not + // had it yet — told the call was still running — and then until a restart, never beyond. + withheld, _ := json.Marshal(map[string]any{"not kept": "a secret shown once, to its caller"}) + onTheBus := *c + onTheBus.Answer = withheld + if !answeredAlready { + c.Answer = withheld + } + l.keep(onTheBus) + return + } l.keep(*c) } +// shownOnce are the verbs whose answer is a secret shown once to its caller, as `.`. +var shownOnce = map[string]bool{"mesh-controller.token": true} + // Recent is the kept calls, newest first, as copies: this process's from memory, and, when they are // kept durably, every other the bus holds — a call a controller before this one served included. // Memory wins for a call in both, being the newer word on it. A bus that cannot be read is said in diff --git a/internal/link/calls_test.go b/internal/link/calls_test.go index faa5d589..b06ee147 100644 --- a/internal/link/calls_test.go +++ b/internal/link/calls_test.go @@ -199,3 +199,27 @@ func TestAHandoverRefusalIsMarkedRetryable(t *testing.T) { t.Fatal("an ordinary refusal was marked to be asked again") } } + +// **A join token is shown to its caller and kept nowhere** (novox/hq ADR 0169): `calls` answers anyone +// who may call the seat, and a token is the one-time right to become a machine of the mesh. +func TestAJoinTokenIsShownToItsCallerAndNotKept(t *testing.T) { + l, a := NewCallLog(), newAnswers(t) + writes := make(chan Call, 4) + l.writes = writes + l.serveCall("mesh-controller", "token", json.RawMessage(`{"new":"laptop"}`), "_INBOX.x.1", + func(context.Context, json.RawMessage) (any, error) { return "token for laptop: s3cret-join", nil }, + a.respond, nil) + if got, _ := a.only()["result"].(string); !strings.Contains(got, "s3cret-join") { + t.Fatalf("its caller was not shown the token: %v", got) + } + recent, _ := l.Recent() + if len(recent) != 1 || strings.Contains(string(recent[0].Answer), "s3cret-join") { + t.Fatalf("the token was kept in memory: %+v", recent) + } + close(writes) + for c := range writes { + if strings.Contains(string(c.Answer), "s3cret-join") { + t.Fatalf("the token was sent to be kept on the bus: %s", c.Answer) + } + } +} From 8bbfdb53db2b653254b46653109dca6b3ef9ef6f Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 8 Oct 2026 01:46:34 +0200 Subject: [PATCH 3/3] Hold that a joining machine is a peer of the hub only while its token lives The rows of novox/hq ADR 0169's table the controller answers for: the hub's composed tunnel carries a machine whose live token was issued for its key, drops it when the token expires unused, and nothing is recorded for something that is not a tunnel key. --- .../join_through_the_tunnel_test.go | 102 ++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 cmd/mesh-controller/join_through_the_tunnel_test.go diff --git a/cmd/mesh-controller/join_through_the_tunnel_test.go b/cmd/mesh-controller/join_through_the_tunnel_test.go new file mode 100644 index 00000000..5782644b --- /dev/null +++ b/cmd/mesh-controller/join_through_the_tunnel_test.go @@ -0,0 +1,102 @@ +package main + +import ( + "context" + "testing" + "time" + + "github.com/novox/mesh-controller/internal/inventory" +) + +// aMachineJoining is a machine with a live token issued for a tunnel key, given its address — what +// `token issue --overlay-key` records before it pushes the hub (novox/hq ADR 0169). +func aMachineJoining(t *testing.T, ctx context.Context, inv *inventory.Inventory, name string, + validFor time.Duration) string { + t.Helper() + record, err := inv.AddNode(ctx, name) + if err != nil { + t.Fatal(err) + } + if _, err := inv.IssueToken(ctx, name, validFor); err != nil { + t.Fatal(err) + } + key := aPublicKey(t) + if err := inv.RecordOverlayKey(ctx, record.ID, key); err != nil { + t.Fatal(err) + } + if err := inv.BindTokenToKey(ctx, record.ID, key); err != nil { + t.Fatal(err) + } + cidr, err := overlayRange(ctx, inv) + if err != nil { + t.Fatal(err) + } + if _, err := inv.AssignAddress(ctx, record.ID, cidr); err != nil { + t.Fatal(err) + } + return key +} + +// hubPeers are the keys the hub's composed tunnel carries. +func hubPeers(t *testing.T, ctx context.Context, inv *inventory.Inventory) map[string]bool { + t.Helper() + g, err := network(ctx, inv, map[string]bool{"anchor": true, "laptop": true}, nil) + if err != nil { + t.Fatal(err) + } + out := map[string]bool{} + for _, p := range g.Graph()["anchor"] { + out[p.Key] = true + } + return out +} + +// **A machine joining is a peer of the hub while its token can be used, and not after** (novox/hq +// ADR 0169): the hub's composed tunnel carries a machine whose token was issued for its key, so the +// tunnel answers the first time it knocks; a token that expired unused takes the peer with it at the +// hub's next composition. +func TestAMachineJoiningIsAPeerOfTheHubUntilItsTokenExpires(t *testing.T) { + open := aMesh(t) + ctx := t.Context() + inv := open.inventory + + joining := aMachineJoining(t, ctx, inv, "joiner", time.Hour) + expired := aMachineJoining(t, ctx, inv, "late", 2*time.Second) + time.Sleep(2100 * time.Millisecond) + + peers := hubPeers(t, ctx, inv) + if !peers[joining] { + t.Fatalf("the hub does not carry the machine its live token was issued for: %v", peers) + } + if peers[expired] { + t.Fatalf("the hub still carries a machine whose token expired unused: %v", peers) + } + + // A token issued without a key gives the hub nothing to carry: there is no key to carry. + if _, err := inv.AddNode(ctx, "keyless"); err != nil { + t.Fatal(err) + } + if _, err := inv.IssueToken(ctx, "keyless", time.Hour); err != nil { + t.Fatal(err) + } + if after := hubPeers(t, ctx, inv); len(after) != len(peers) { + t.Fatalf("a token issued without a key changed the hub's peers: %v, was %v", after, peers) + } +} + +// **A tunnel key that is not one is refused before anything is recorded** (novox/hq ADR 0169): a +// token issued for it would be a tunnel the hub could never answer. +func TestATokenIsNotIssuedForSomethingThatIsNotATunnelKey(t *testing.T) { + open := aMesh(t) + ctx := t.Context() + record, err := open.inventory.AddNode(ctx, "joiner") + if err != nil { + t.Fatal(err) + } + if _, _, err := throughTheTunnel(ctx, open, record, "not-a-key", "10.77.0.1:4222"); err == nil { + t.Fatal("a token was issued for something that is not a tunnel key") + } + if held := placementOf(t, ctx, open.inventory, "joiner"); held.Key != "" || held.Address != "" { + t.Fatalf("a refused key left a record behind: %+v", held) + } +}