Files
jschoubben 4566c5c9aa Adopt the tunnel as a mesh fact, refuse a mismatched takeover, and rekey after enrolment
Review of the ADR 0105 build (hq ADR 0105). Four things it got wrong and one
path it lacked:

- A predecessor spoke's tunnel names one peer, the hub, routed the whole
  range; recording refused it and the whole enrolment failed. Range-routed
  peers are skipped now — only the hub's peers are ever carried.
- The range and the carried peers were conditions on the node being adopted,
  so converging the hub would have renumbered the mesh and dropped the peers
  still reaching it. They are facts of the tunnel record now, mode aside; the
  takeover alone is declared to an adopted node. Converging the hub is refused
  while a carried peer has not enrolled, naming it.
- A push composed a takeover for a hub whose address or endpoint disagreed
  with the tunnel, which would have the host stop the found interface and
  raise the mesh's where no peer listens. The graph refuses to compose it,
  naming both and the placement that fixes it.
- The host's account said taken or not; "found down and the mesh's not up"
  read as not taken. Three states now, and an account on every takeover.
- A hub that enrolled before this feature holds a key of its own, and
  re-enrolling would rotate every key the mesh sealed credentials to. A node
  now rekeys in a report, signed with its identity key over the key it
  leaves, the key it takes and the tunnel; the mesh verifies against the live
  key, refuses a stale or foreign proof, records key and tunnel, and moves a
  hub to the tunnel's address. `overlay show` names the path for a hub that
  found no tunnel.

Also: a carried IPv6 peer is routed /128, and identity.ForTest exists so the
link can be tested against a real identity store.
2026-09-24 00:02:07 +02:00

280 lines
11 KiB
Go

package inventory
import (
"errors"
"strings"
"testing"
)
// novox/hq ADR 0105: the mesh adopts the predecessor's tunnel in place. The controller reads the
// hub's address and range from the adopted tunnel, assigns an enrolling node the address its key
// already had, and refuses to hand out an address the tunnel already holds.
const (
tunnelKey = "TUNNEL-KEY-the-found-interfaces-public-key="
peerTwo = "PEER-KEY-two============================="
peerThree = "PEER-KEY-three==========================="
)
// theFoundTunnel is what a hub presents at enrolment: the predecessor's interface on a
// documentation range, with two peers each routed one address.
func theFoundTunnel() Tunnel {
return Tunnel{
Interface: "wg0", Unit: "wg-quick@wg0", Config: "/etc/wireguard/wg0.conf",
Port: 51900, Address: "192.0.2.1/24", Range: "192.0.2.0/24", PublicKey: tunnelKey,
Peers: []TunnelPeer{{PublicKey: peerTwo, Address: "192.0.2.2/32"},
{PublicKey: peerThree, Address: "192.0.2.3"}},
}
}
// anAdoptedHub is an adopted node that enrolled with the found tunnel's key and presented the
// tunnel, then was placed as the hub — the order genesis does it in.
func anAdoptedHub(t *testing.T, inv *Inventory) Node {
t.Helper()
hub, err := inv.AddNodeAs(t.Context(), "anchor", true)
if err != nil {
t.Fatal(err)
}
if err := inv.RecordOverlayKey(t.Context(), hub.ID, tunnelKey); err != nil {
t.Fatal(err)
}
if err := inv.RecordTunnel(t.Context(), hub.ID, theFoundTunnel()); err != nil {
t.Fatal(err)
}
if err := inv.SetPlace(t.Context(), "anchor", "anchor.example:51900", "hosting", true, ""); err != nil {
t.Fatal(err)
}
return hub
}
func TestTheHubsAddressAndRangeComeFromTheAdoptedTunnel(t *testing.T) {
inv := fresh(t)
hub := anAdoptedHub(t, inv)
tunnel, name, adopted, err := inv.AdoptedTunnel(t.Context())
if err != nil {
t.Fatal(err)
}
if !adopted || name != "anchor" || tunnel.Range != "192.0.2.0/24" || tunnel.Port != 51900 {
t.Fatalf("the adopted tunnel did not read back: adopted=%t on %s, %+v", adopted, name, tunnel)
}
if len(tunnel.Peers) != 2 || tunnel.Peers[0].Address != "192.0.2.2" || tunnel.Peers[1].Address != "192.0.2.3" {
t.Fatalf("the peers did not read back as one host address each: %+v", tunnel.Peers)
}
// Whatever range the caller would allocate from, the hub is at the tunnel's own address.
address, err := inv.AssignAddress(t.Context(), hub.ID, "10.42.0.0/16")
if err != nil {
t.Fatal(err)
}
if address != "192.0.2.1" {
t.Fatalf("the hub was given %s, not the address the tunnel it took over had", address)
}
}
func TestAnEnrollingNodeKeepsTheAddressTheTunnelHadForItsKey(t *testing.T) {
inv := fresh(t)
anAdoptedHub(t, inv)
// A predecessor machine enrols: its host took its found interface's key as its overlay key,
// which is the key the hub's tunnel already routes to.
peer, err := inv.AddNodeAs(t.Context(), "home-server", true)
if err != nil {
t.Fatal(err)
}
if err := inv.RecordOverlayKey(t.Context(), peer.ID, peerThree); err != nil {
t.Fatal(err)
}
address, err := inv.AssignAddress(t.Context(), peer.ID, "192.0.2.0/24")
if err != nil {
t.Fatal(err)
}
if address != "192.0.2.3" {
t.Fatalf("the enrolling peer was given %s, not the 192.0.2.3 the tunnel had for its key", address)
}
carried, err := inv.CarriedPeers(t.Context())
if err != nil {
t.Fatal(err)
}
byKey := map[string]CarriedPeer{}
for _, c := range carried {
byKey[c.PublicKey] = c
}
if byKey[peerThree].EnrolledAs != "home-server" || byKey[peerTwo].EnrolledAs != "" {
t.Fatalf("the registry cannot say which peer is a node now: %+v", carried)
}
}
func TestAFreshNodeIsNeverGivenAnAddressTheTunnelHolds(t *testing.T) {
inv := fresh(t)
anAdoptedHub(t, inv)
// .1 is the hub, .2 and .3 are peers of the tunnel that have not enrolled: a new machine with
// a key of its own gets the next one, from the same range.
fresh, err := inv.AddNode(t.Context(), "laptop")
if err != nil {
t.Fatal(err)
}
if err := inv.RecordOverlayKey(t.Context(), fresh.ID, "A-KEY-OF-ITS-OWN========================"); err != nil {
t.Fatal(err)
}
address, err := inv.AssignAddress(t.Context(), fresh.ID, "192.0.2.0/24")
if err != nil {
t.Fatal(err)
}
if address != "192.0.2.4" {
t.Fatalf("a fresh node was given %s; 192.0.2.2 and .3 are the tunnel's peers and .1 its hub", address)
}
}
func TestATunnelUnderAnotherKeyIsNotAdopted(t *testing.T) {
// A hub whose overlay key is not the found tunnel's would drop every peer's packets on the
// found port (ADR 0105, option 2). Such a tunnel is recorded and not adopted: the mesh keeps
// its own range, and ADR 0100's non-overlap rule stands for it.
inv := fresh(t)
hub, err := inv.AddNodeAs(t.Context(), "anchor", true)
if err != nil {
t.Fatal(err)
}
if err := inv.RecordOverlayKey(t.Context(), hub.ID, "THE-MESHS-OWN-KEY======================="); err != nil {
t.Fatal(err)
}
if err := inv.RecordTunnel(t.Context(), hub.ID, theFoundTunnel()); err != nil {
t.Fatal(err)
}
if err := inv.SetPlace(t.Context(), "anchor", "anchor.example:51820", "hosting", true, ""); err != nil {
t.Fatal(err)
}
if _, _, adopted, err := inv.AdoptedTunnel(t.Context()); err != nil || adopted {
t.Fatalf("a tunnel under another key was adopted (err %v)", err)
}
if carried, err := inv.CarriedPeers(t.Context()); err != nil || len(carried) != 0 {
t.Fatalf("peers of a tunnel that was not adopted are carried: %+v (err %v)", carried, err)
}
if address, err := inv.AssignAddress(t.Context(), hub.ID, "10.42.0.0/16"); err != nil || address != "10.42.0.1" {
t.Fatalf("the hub was given %s (err %v); it should allocate from the mesh's own range", address, err)
}
}
func TestAPeerRoutedARangeIsNotCarried(t *testing.T) {
// A peer routed a whole range is a spoke's view of its hub, never a machine with an address
// the mesh could carry: skipped, and the single-address peers beside it kept.
inv := fresh(t)
hub, err := inv.AddNodeAs(t.Context(), "anchor", true)
if err != nil {
t.Fatal(err)
}
found := theFoundTunnel()
found.Peers = append(found.Peers, TunnelPeer{PublicKey: "WIDE", Address: "192.0.2.0/24"})
if err := inv.RecordTunnel(t.Context(), hub.ID, found); err != nil {
t.Fatal(err)
}
got, err := inv.TunnelOf(t.Context(), "anchor")
if err != nil || len(got.Peers) != 2 {
t.Fatalf("the range-routed peer was carried, or the others dropped: %+v %v", got.Peers, err)
}
// A single address outside the tunnel's range is still refused: it is not a peer this tunnel
// routes to.
found.Peers = []TunnelPeer{{PublicKey: "ELSEWHERE", Address: "198.51.100.7/32"}}
if err := inv.RecordTunnel(t.Context(), hub.ID, found); err == nil || !strings.Contains(err.Error(), "outside") {
t.Fatalf("a peer outside the range was recorded: %v", err)
}
}
// A predecessor spoke's tunnel has one peer — the hub — routed the whole range. Its enrolment must
// not fail on it: only the hub's peers are ever carried, so a range-routed peer is skipped.
func TestASpokesTunnelEnrolsWithItsHubPeerSkipped(t *testing.T) {
inv := fresh(t)
anAdoptedHub(t, inv)
spoke, err := inv.AddNodeAs(t.Context(), "home-server", true)
if err != nil {
t.Fatal(err)
}
if err := inv.RecordOverlayKey(t.Context(), spoke.ID, peerThree); err != nil {
t.Fatal(err)
}
if err := inv.RecordTunnel(t.Context(), spoke.ID, Tunnel{
Interface: "wg0", Unit: "wg-quick@wg0", Config: "/etc/wireguard/wg0.conf", Port: 51900,
Address: "192.0.2.3/24", Range: "192.0.2.0/24", PublicKey: peerThree,
Peers: []TunnelPeer{{PublicKey: tunnelKey, Address: "192.0.2.0/24"}},
}); err != nil {
t.Fatalf("a spoke-shaped tunnel was refused: %v", err)
}
got, err := inv.TunnelOf(t.Context(), "home-server")
if err != nil || len(got.Peers) != 0 {
t.Fatalf("the spoke's hub was recorded as a peer to carry: %+v %v", got.Peers, err)
}
// Nothing about the hub's carried peers changed: still two, one now enrolled.
carried, err := inv.CarriedPeers(t.Context())
if err != nil || len(carried) != 2 {
t.Fatalf("carried peers: %+v %v", carried, err)
}
if address, err := inv.AssignAddress(t.Context(), spoke.ID, "192.0.2.0/24"); err != nil || address != "192.0.2.3" {
t.Fatalf("the spoke did not keep its address: %s %v", address, err)
}
}
// Converging the hub flips its mode and nothing else: the range stays the tunnel's and the peers
// stay carried, or the mesh would renumber itself and drop the peers still reaching it.
func TestConvergingTheHubKeepsTheRangeAndTheCarriedPeers(t *testing.T) {
inv := fresh(t)
hub := anAdoptedHub(t, inv)
if _, err := inv.AssignAddress(t.Context(), hub.ID, "192.0.2.0/24"); err != nil {
t.Fatal(err)
}
if _, err := inv.Converge(t.Context(), "anchor"); err != nil {
t.Fatal(err)
}
tunnel, _, adopted, err := inv.AdoptedTunnel(t.Context())
if err != nil || !adopted || tunnel.Range != "192.0.2.0/24" {
t.Fatalf("converging renumbered the mesh: adopted=%t %+v %v", adopted, tunnel, err)
}
if carried, err := inv.CarriedPeers(t.Context()); err != nil || len(carried) != 2 {
t.Fatalf("converging dropped the carried peers: %+v %v", carried, err)
}
found, err := inv.Tunnels(t.Context())
if err != nil || found["anchor"].NodeAdopted {
t.Fatalf("a converged hub still reads as adopted for the takeover: %+v %v", found, err)
}
}
// A hub that enrolled with a key of its own takes the tunnel over afterwards by rekeying: the key
// and the tunnel are recorded, the hub moves to the tunnel's address, and the same rekey applied
// again is stale.
func TestARekeyTakesTheTunnelOverAfterEnrolment(t *testing.T) {
inv := fresh(t)
hub, err := inv.AddNodeAs(t.Context(), "anchor", true)
if err != nil {
t.Fatal(err)
}
const own = "THE-MESHS-OWN-KEY======================="
if err := inv.RecordOverlayKey(t.Context(), hub.ID, own); err != nil {
t.Fatal(err)
}
if err := inv.SetPlace(t.Context(), "anchor", "anchor.example:51900", "hosting", true, ""); err != nil {
t.Fatal(err)
}
if address, err := inv.AssignAddress(t.Context(), hub.ID, "10.42.0.0/16"); err != nil || address != "10.42.0.1" {
t.Fatalf("before the rekey the hub is on the mesh's own range: %s %v", address, err)
}
if err := inv.Rekey(t.Context(), hub.ID, own, tunnelKey, theFoundTunnel()); err != nil {
t.Fatal(err)
}
_, _, adopted, err := inv.AdoptedTunnel(t.Context())
if err != nil || !adopted {
t.Fatalf("the tunnel is not adopted after the rekey (%v)", err)
}
placed, err := inv.Overlays(t.Context())
if err != nil || len(placed) != 1 || placed[0].Address != "192.0.2.1" || placed[0].Key != tunnelKey {
t.Fatalf("the hub did not move to the tunnel's address under the tunnel's key: %+v %v", placed, err)
}
if err := inv.Rekey(t.Context(), hub.ID, own, tunnelKey, theFoundTunnel()); !errors.Is(err, ErrStaleRekey) {
t.Fatalf("the same rekey applied again was not refused as stale: %v", err)
}
if err := inv.Rekey(t.Context(), hub.ID, tunnelKey, "ANOTHER-KEY=============================", theFoundTunnel()); err == nil {
t.Fatal("a rekey to a key that is not the tunnel's was accepted")
}
}