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.
136 lines
5.0 KiB
Go
136 lines
5.0 KiB
Go
package link_test
|
|
|
|
import (
|
|
"crypto/ed25519"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/novox/mesh-controller/internal/identity"
|
|
"github.com/novox/mesh-controller/internal/inventory"
|
|
"github.com/novox/mesh-controller/internal/link"
|
|
)
|
|
|
|
// novox/hq ADR 0105: a hub that enrolled before the mesh knew to take a tunnel over rekeys onto the
|
|
// found tunnel's key without re-enrolling — which would rotate every key it holds and remake every
|
|
// credential the mesh sealed to it. The rekey rides in a report and is signed with the node's
|
|
// identity key; the mesh verifies it against the key it recorded, and refuses one signed by
|
|
// another key or one already applied.
|
|
|
|
const (
|
|
ownKey = "THE-MESHS-OWN-KEY======================="
|
|
tunnelKey = "TUNNEL-KEY-the-found-interfaces-public-key="
|
|
)
|
|
|
|
func theTunnel() *link.Tunnel {
|
|
return &link.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: []link.TunnelPeer{{PublicKey: "PEER-A=", Address: "192.0.2.2/32"}}}
|
|
}
|
|
|
|
// anEnrolledHub is a hub the way it stands before the feature: adopted, placed, its overlay key its
|
|
// own, its identity key recorded — and a mesh holding both stores.
|
|
func anEnrolledHub(t *testing.T) (link.Enrolment, inventory.Node, ed25519.PrivateKey) {
|
|
t.Helper()
|
|
inv := inventory.ForTest(t)
|
|
ident := identity.ForTest(t)
|
|
ctx := t.Context()
|
|
hub, err := inv.AddNodeAs(ctx, "anchor", true)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
public, private, err := ed25519.GenerateKey(nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := ident.RecordNodeKey(ctx, hub.ID, public); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := inv.RecordOverlayKey(ctx, hub.ID, ownKey); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := inv.SetPlace(ctx, "anchor", "anchor.example:51900", "hosting", true, "10.42.0.1"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return link.Enrolment{Inventory: inv, Identity: ident}, hub, private
|
|
}
|
|
|
|
func TestASignedRekeyMovesTheHubOntoItsTunnel(t *testing.T) {
|
|
e, hub, private := anEnrolledHub(t)
|
|
ctx := t.Context()
|
|
rekey := &link.Rekey{Previous: ownKey, OverlayKey: tunnelKey, Tunnel: theTunnel()}
|
|
rekey.Proof = ed25519.Sign(private, link.RekeyProof("anchor", ownKey, tunnelKey, theTunnel()))
|
|
|
|
if err := e.Heard(ctx, link.Report{Node: "anchor", Rekey: rekey}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
placed, err := e.Inventory.Overlays(ctx)
|
|
if err != nil || len(placed) != 1 {
|
|
t.Fatal(placed, err)
|
|
}
|
|
if placed[0].Key != tunnelKey || placed[0].Address != "192.0.2.1" {
|
|
t.Fatalf("the hub is not on the tunnel's key and address: %+v", placed[0])
|
|
}
|
|
tunnel, _, adopted, err := e.Inventory.AdoptedTunnel(ctx)
|
|
if err != nil || !adopted || tunnel.Range != "192.0.2.0/24" || len(tunnel.Peers) != 1 {
|
|
t.Fatalf("the tunnel is not adopted after the rekey: %+v %t %v", tunnel, adopted, err)
|
|
}
|
|
_ = hub
|
|
|
|
// Replayed, it is stale: the previous key it names is no longer the node's.
|
|
err = e.Heard(ctx, link.Report{Node: "anchor", Rekey: rekey})
|
|
if err == nil || !strings.Contains(err.Error(), "previous overlay key") {
|
|
t.Fatalf("a replayed rekey was accepted: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestARekeySignedByAnotherKeyIsRefusedAndChangesNothing(t *testing.T) {
|
|
e, _, _ := anEnrolledHub(t)
|
|
ctx := t.Context()
|
|
_, stranger, err := ed25519.GenerateKey(nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rekey := &link.Rekey{Previous: ownKey, OverlayKey: tunnelKey, Tunnel: theTunnel()}
|
|
rekey.Proof = ed25519.Sign(stranger, link.RekeyProof("anchor", ownKey, tunnelKey, theTunnel()))
|
|
|
|
err = e.Heard(ctx, link.Report{Node: "anchor", Rekey: rekey})
|
|
if err == nil || !strings.Contains(err.Error(), "not signed by anchor's identity key") {
|
|
t.Fatalf("a rekey signed by a stranger was accepted: %v", err)
|
|
}
|
|
placed, _ := e.Inventory.Overlays(ctx)
|
|
if placed[0].Key != ownKey || placed[0].Address != "10.42.0.1" {
|
|
t.Fatalf("a refused rekey changed the record: %+v", placed[0])
|
|
}
|
|
if _, _, adopted, _ := e.Inventory.AdoptedTunnel(ctx); adopted {
|
|
t.Fatal("a refused rekey recorded a tunnel")
|
|
}
|
|
|
|
// And a proof moved to another tunnel — the signature was over one tunnel, the message names
|
|
// another — does not verify either.
|
|
moved := &link.Rekey{Previous: ownKey, OverlayKey: tunnelKey, Tunnel: theTunnel()}
|
|
other := theTunnel()
|
|
other.Port = 51820
|
|
moved.Proof = ed25519.Sign(mustPrivate(t, e, "anchor"), link.RekeyProof("anchor", ownKey, tunnelKey, other))
|
|
if err := e.Heard(ctx, link.Report{Node: "anchor", Rekey: moved}); err == nil {
|
|
t.Fatal("a proof over another tunnel was accepted")
|
|
}
|
|
}
|
|
|
|
// mustPrivate is a fresh key recorded as the node's live one, for signing in a test that needs
|
|
// the node's own signature after the fixture's key is out of scope.
|
|
func mustPrivate(t *testing.T, e link.Enrolment, node string) ed25519.PrivateKey {
|
|
t.Helper()
|
|
public, private, err := ed25519.GenerateKey(nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
n, err := e.Inventory.NodeByName(t.Context(), node)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := e.Identity.RecordNodeKey(t.Context(), n.ID, public); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return private
|
|
}
|