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.
This commit is contained in:
2026-09-24 00:02:07 +02:00
parent 3c836f0abb
commit 4566c5c9aa
11 changed files with 641 additions and 49 deletions
+68
View File
@@ -0,0 +1,68 @@
package identity
import (
"context"
"fmt"
"os"
"strings"
"testing"
"time"
"github.com/jackc/pgx/v5"
"github.com/novox/mesh-controller/internal/store"
)
// ForTest is a fresh, migrated identity store in a database of its own, dropped when the test
// ends. Exported for the same reason inventory.ForTest is: the check that a node's signed word
// is verified against the key the mesh recorded lives beside the link, and a second copy of this
// would be a second thing to keep true. It takes a *testing.T, so nothing that is not a test can
// call it.
func ForTest(t *testing.T) *Identity {
t.Helper()
admin := os.Getenv("MESH_TEST_POSTGRES")
if admin == "" {
t.Skip("no MESH_TEST_POSTGRES; run `make check` to raise one")
}
name := fmt.Sprintf("ident_%d_%s", time.Now().UnixNano()%1_000_000,
strings.ToLower(strings.NewReplacer("/", "", "-", "").Replace(t.Name())))
if len(name) > 60 {
name = name[:60]
}
conn, err := pgx.Connect(t.Context(), admin)
if err != nil {
t.Fatalf("cannot reach the test PostgreSQL: %v", err)
}
if _, err := conn.Exec(t.Context(), "create database "+name); err != nil {
t.Fatalf("cannot create %s: %v", name, err)
}
conn.Close(t.Context())
cut := strings.LastIndex(admin, "/")
t.Setenv(store.Variable(Name), admin[:cut]+"/"+name+"?sslmode=disable")
ident, err := Open(t.Context())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
ident.Close()
c, err := pgx.Connect(context.Background(), admin)
if err != nil {
return
}
defer c.Close(context.Background())
_, _ = c.Exec(context.Background(), "drop database if exists "+name+" with (force)")
})
if err := ident.Ready(t.Context(), 20*time.Second); err != nil {
t.Fatal(err)
}
migrations, err := Migrations()
if err != nil {
t.Fatal(err)
}
if _, err := ident.store.Migrate(t.Context(), migrations); err != nil {
t.Fatal(err)
}
return ident
}
+95 -33
View File
@@ -60,13 +60,24 @@ type Carried struct {
Port int `json:"port"`
Range string `json:"range"`
Peers int `json:"peers"`
// Taken is whether the found interface is down and the mesh's up with its key; Kept is where
// the found configuration's original was kept.
Taken bool `json:"taken"`
// State is one of the CarriedStates: the found interface is still up and the mesh's is not
// (not taken), the found one is down and the mesh's up with its key (taken), or the found one
// is down and the mesh's is not up — the one state where the peers reach nothing. Note is
// what the host did about it, when it did something. Kept is where the found configuration's
// original was kept.
State string `json:"state"`
Note string `json:"note,omitempty"`
Kept string `json:"kept,omitempty"`
At time.Time `json:"at"`
}
// The states a carried tunnel's account can be in, as the host says them.
const (
CarriedNotTaken = "not-taken"
CarriedTaken = "taken"
CarriedDown = "down"
)
// CarriedPeer is one peer of the adopted tunnel as the mesh holds it: a peer of the tunnel, and
// — once a node enrols with that key — a node of the mesh as well.
type CarriedPeer struct {
@@ -94,9 +105,13 @@ func (i *Inventory) RecordTunnel(ctx context.Context, nodeID string, t Tunnel) e
}
peers := make([]TunnelPeer, 0, len(t.Peers))
for _, p := range t.Peers {
host, err := peerHost(p.Address)
if err != nil {
return fmt.Errorf("the found tunnel's peer %s: %w", shortKey(p.PublicKey), err)
host, single := peerHost(p.Address)
if !single {
// A peer routed a range rather than one address is a spoke's view of its hub — the
// predecessor gives a spoke the whole subnet through the hub — and a hub is not a peer
// the mesh carries. Skipped, not refused: a spoke enrols with what it found, and only
// the hub's peers are ever carried (novox/hq ADR 0105).
continue
}
if !address.Masked().Contains(host) {
return fmt.Errorf("the found tunnel's peer %s is routed at %s, outside the tunnel's %s",
@@ -131,22 +146,19 @@ func (i *Inventory) RecordTunnel(ctx context.Context, nodeID string, t Tunnel) e
return tx.Commit(ctx)
}
// peerHost is the one host address a peer's allowed address names: a bare address, or a /32
// (or /128). A wider prefix is refused — a peer routed a whole range is not a machine with an
// address the mesh could give a node.
func peerHost(allowed string) (netip.Addr, error) {
// peerHost is the one host address a peer's allowed address names — a bare address, or a /32
// (or /128) — and false for anything wider or unreadable: a peer routed a whole range is not a
// machine with an address the mesh could give a node.
func peerHost(allowed string) (netip.Addr, bool) {
allowed = strings.TrimSpace(allowed)
if a, err := netip.ParseAddr(allowed); err == nil {
return a, nil
return a, true
}
p, err := netip.ParsePrefix(allowed)
if err != nil {
return netip.Addr{}, fmt.Errorf("%q is not an address", allowed)
if err != nil || !p.IsSingleIP() {
return netip.Addr{}, false
}
if !p.IsSingleIP() {
return netip.Addr{}, fmt.Errorf("%q names a range, and a peer of the tunnel is one address", allowed)
}
return p.Addr(), nil
return p.Addr(), true
}
func shortKey(key string) string {
@@ -198,20 +210,22 @@ func (i *Inventory) tunnelPeers(ctx context.Context, nodeID string) ([]TunnelPee
}
// AdoptedTunnel is the tunnel the mesh's private network runs over, if the hub adopted one: the
// hub's found tunnel, when the hub is adopted and its overlay key is the tunnel's. Absent, the mesh
// runs on its own range — and a hub that found a tunnel but holds another key did not adopt it,
// which `overlay show` says.
// hub's found tunnel, when the hub's overlay key is the tunnel's. Absent, the mesh runs on its own
// range — and a hub that found a tunnel but holds another key did not adopt it, which `overlay
// show` says.
//
// The condition on the key is the condition of the whole record: a hub raised with a key of its
// own would drop every peer's packets on the found port (novox/hq ADR 0105, option 2), so the
// tunnel is adopted only when the hub answers to the key its peers know.
// tunnel is adopted only when the hub answers to the key its peers know. **Not a condition on the
// node's mode**: the range and the carried peers are facts of the mesh once the tunnel is taken,
// and converging the hub — which flips its mode — must not renumber the mesh or drop the peers
// still reaching it.
func (i *Inventory) AdoptedTunnel(ctx context.Context) (Tunnel, string, bool, error) {
var name string
var key *string
var adopted bool
err := i.store.Pool().QueryRow(ctx,
`select name, overlay_key, adopted from node where is_hub and tunnel is not null`).
Scan(&name, &key, &adopted)
`select name, overlay_key from node where is_hub and tunnel is not null`).
Scan(&name, &key)
if errors.Is(err, pgx.ErrNoRows) {
return Tunnel{}, "", false, nil
}
@@ -222,7 +236,7 @@ func (i *Inventory) AdoptedTunnel(ctx context.Context) (Tunnel, string, bool, er
if err != nil {
return Tunnel{}, "", false, err
}
if !adopted || key == nil || *key != t.PublicKey {
if key == nil || *key != t.PublicKey {
return t, name, false, nil
}
return t, name, true, nil
@@ -235,7 +249,7 @@ func (i *Inventory) CarriedPeers(ctx context.Context) ([]CarriedPeer, error) {
rows, err := i.store.Pool().Query(ctx,
`select p.public_key, host(p.address), coalesce(n.name, '')
from tunnel_peer p
join node hub on hub.id = p.node and hub.is_hub and hub.adopted
join node hub on hub.id = p.node and hub.is_hub
and hub.tunnel is not null and hub.overlay_key = hub.tunnel->>'public_key'
left join node n on n.overlay_key = p.public_key
order by p.address`)
@@ -254,33 +268,81 @@ func (i *Inventory) CarriedPeers(ctx context.Context) ([]CarriedPeer, error) {
return out, rows.Err()
}
// Tunnels is every adopted node's found tunnel by node name, for the ones whose overlay key is the
// FoundTunnel is a node's found tunnel with the node's mode, for composing: the takeover is
// declared to an adopted node only, since only there is a found unit kept to be stopped.
type FoundTunnel struct {
Tunnel
NodeAdopted bool
}
// Tunnels is every node's found tunnel by node name, for the ones whose overlay key is the
// tunnel's — the ones whose private network takes it over. A found tunnel under another key is
// left running beside the mesh's, and ADR 0100's rule that the ranges differ applies to it.
func (i *Inventory) Tunnels(ctx context.Context) (map[string]Tunnel, error) {
func (i *Inventory) Tunnels(ctx context.Context) (map[string]FoundTunnel, error) {
rows, err := i.store.Pool().Query(ctx,
`select name, tunnel from node
where adopted and tunnel is not null and overlay_key = tunnel->>'public_key'`)
`select name, tunnel, adopted from node
where tunnel is not null and overlay_key = tunnel->>'public_key'`)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[string]Tunnel{}
out := map[string]FoundTunnel{}
for rows.Next() {
var name string
var raw []byte
if err := rows.Scan(&name, &raw); err != nil {
var adopted bool
if err := rows.Scan(&name, &raw, &adopted); err != nil {
return nil, err
}
var t Tunnel
if err := json.Unmarshal(raw, &t); err != nil {
return nil, err
}
out[name] = t
out[name] = FoundTunnel{Tunnel: t, NodeAdopted: adopted}
}
return out, rows.Err()
}
// ErrStaleRekey is a rekey that names a previous overlay key other than the one recorded: a
// replay of a rekey already done, or one made against a record that has since moved on.
var ErrStaleRekey = errors.New("the rekey names a previous overlay key that is not the node's current one")
// Rekey records that a node took a found tunnel's key as its overlay key after enrolling (novox/hq
// ADR 0105): the key and the tunnel are recorded as enrolment would have, and a hub is moved to the
// tunnel's address so nothing derived from it is stale. The caller has verified the node signed
// for this; what is checked here is that it follows the record — `previous` is the overlay key the
// node holds now — so the same message cannot be applied twice.
func (i *Inventory) Rekey(ctx context.Context, nodeID, previous, key string, t Tunnel) error {
if key != t.PublicKey {
return errors.New("a rekey takes a tunnel over with the tunnel's own key, and this names another")
}
var current *string
var hub bool
if err := i.store.Pool().QueryRow(ctx,
`select overlay_key, is_hub from node where id = $1`, nodeID).Scan(&current, &hub); err != nil {
return err
}
if (current == nil && previous != "") || (current != nil && *current != previous) {
return ErrStaleRekey
}
if err := i.RecordOverlayKey(ctx, nodeID, key); err != nil {
return err
}
if err := i.RecordTunnel(ctx, nodeID, t); err != nil {
return err
}
if hub {
address, err := netip.ParsePrefix(t.Address)
if err != nil {
return err
}
if _, err := i.place(ctx, nodeID, address.Addr().String()); err != nil {
return err
}
}
return nil
}
// RecordCarriedTunnel keeps what a node last said about carrying its found tunnel.
func (i *Inventory) RecordCarriedTunnel(ctx context.Context, nodeID string, c Carried) error {
c.At = time.Now().UTC()
+111 -7
View File
@@ -157,19 +157,123 @@ func TestATunnelUnderAnotherKeyIsNotAdopted(t *testing.T) {
}
}
func TestAPeerRoutedARangeIsRefused(t *testing.T) {
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: "198.51.100.0/24"})
err = inv.RecordTunnel(t.Context(), hub.ID, found)
if err == nil || !strings.Contains(err.Error(), "names a range") {
t.Fatalf("a peer routed a whole range was recorded as a machine with an address: %v", err)
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)
}
if _, err := inv.TunnelOf(t.Context(), "anchor"); !errors.Is(err, ErrNoTunnel) {
t.Fatalf("a refused tunnel was recorded anyway: %v", 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")
}
}
+40 -1
View File
@@ -181,6 +181,34 @@ func (e Enrolment) Enrol(ctx context.Context, request EnrolRequest) (reply Enrol
return reply, nil
}
// rekey applies a verified rekey: the node's overlay key and tunnel are recorded as enrolment
// would have recorded them, and a hub moves to the tunnel's address.
func (e Enrolment) rekey(ctx context.Context, node inventory.Node, r Rekey) error {
if r.Tunnel == nil || r.OverlayKey == "" {
return fmt.Errorf("%s sent a rekey naming no tunnel or no key; refused", node.Name)
}
if e.Identity == nil {
return fmt.Errorf("%s sent a rekey and this mesh has no identity store to verify it against", node.Name)
}
if err := e.Identity.VerifyNode(ctx, node.ID,
RekeyProof(node.Name, r.Previous, r.OverlayKey, r.Tunnel), r.Proof); err != nil {
return fmt.Errorf("%s's rekey is not signed by %s's identity key; refused: %w", node.Name, node.Name, err)
}
peers := make([]inventory.TunnelPeer, 0, len(r.Tunnel.Peers))
for _, p := range r.Tunnel.Peers {
peers = append(peers, inventory.TunnelPeer{PublicKey: p.PublicKey, Address: p.Address})
}
err := e.Inventory.Rekey(ctx, node.ID, r.Previous, r.OverlayKey, inventory.Tunnel{
Interface: r.Tunnel.Interface, Unit: r.Tunnel.Unit, Config: r.Tunnel.Config, Port: r.Tunnel.Port,
Address: r.Tunnel.Address, Range: r.Tunnel.Range, PublicKey: r.Tunnel.PublicKey, Peers: peers,
})
if err != nil {
return fmt.Errorf("%s's rekey was not recorded: %w", node.Name, err)
}
log.Printf("%s took over the tunnel on %s: its overlay key is the tunnel's now", node.Name, r.Tunnel.Interface)
return nil
}
// claimant names the key presenting a token, so a claim can be held for it alone.
func claimant(public ed25519.PublicKey) string {
sum := sha256.Sum256(public)
@@ -246,11 +274,22 @@ func (e Enrolment) Heard(ctx context.Context, report Report) (err error) {
if report.Tunnel != nil {
if err := e.Inventory.RecordCarriedTunnel(ctx, node.ID, inventory.Carried{
Interface: report.Tunnel.Interface, Port: report.Tunnel.Port, Range: report.Tunnel.Range,
Peers: report.Tunnel.Peers, Taken: report.Tunnel.Taken, Kept: report.Tunnel.Kept,
Peers: report.Tunnel.Peers, State: report.Tunnel.State, Note: report.Tunnel.Note,
Kept: report.Tunnel.Kept,
}); err != nil {
return err
}
}
// A node taking a found tunnel's key after enrolment (novox/hq ADR 0105). Verified against the
// node's live identity key before anything is written: the broker account authenticates the
// connection, the signature proves the node itself said it. Refused outright when the proof
// does not verify or is stale — a refusal, not "not now", so the node hears why.
if report.Rekey != nil {
if err := e.rekey(ctx, node, *report.Rekey); err != nil {
return err
}
return e.Inventory.Seen(ctx, node.ID)
}
// A bare word that a node is there is not an account of what the machine did or holds: it
// moves last_seen and touches nothing else. This arrives every minute (link.AliveEvery),
+43 -2
View File
@@ -8,6 +8,8 @@ package link
import (
"encoding/base64"
"strconv"
"strings"
"time"
)
@@ -161,18 +163,57 @@ type Report struct {
// 0105): the interface, its port, range and peer count, whether the found interface is down
// and the mesh's up in its place, and where the found configuration's original was kept.
Tunnel *CarriedTunnel `json:"tunnel,omitempty"`
// Rekey is a node taking a found tunnel's key as its overlay key after enrolment (novox/hq
// ADR 0105). A report carrying one is not an account of the machine: it moves the node's
// overlay key and tunnel and nothing else.
Rekey *Rekey `json:"rekey,omitempty"`
}
// CarriedTunnel is a node's account of the tunnel it took over.
// CarriedTunnel is a node's account of the tunnel it took over. State is "not-taken" (the found
// interface still up, the mesh's not), "taken" (the found one down, the mesh's up with its key) or
// "down" (the found one down and the mesh's not up: the peers reach nothing); Note is what the host
// did about it.
type CarriedTunnel struct {
Interface string `json:"interface"`
Port int `json:"port"`
Range string `json:"range"`
Peers int `json:"peers"`
Taken bool `json:"taken"`
State string `json:"state"`
Note string `json:"note,omitempty"`
Kept string `json:"kept,omitempty"`
}
// Rekey is a node saying it took a found tunnel's key as its overlay key after enrolling (novox/hq
// ADR 0105) — the path for a hub that enrolled before the mesh knew to take a tunnel over, since
// re-enrolling would rotate every key the node holds. Carried in a report, on the node's own
// authenticated connection, and signed with its identity key over RekeyProof, so a report forged
// on a stolen broker account cannot move a node's overlay key.
type Rekey struct {
// Previous is the overlay key the node holds now, as the mesh records it. A rekey naming
// another is stale — a replay, or made against a record that moved on — and is refused.
Previous string `json:"previous"`
OverlayKey string `json:"overlay_key"`
Tunnel *Tunnel `json:"tunnel"`
Proof []byte `json:"proof"`
}
// RekeyProof is what a node signs when it rekeys: the node, the key it leaves, the key it takes
// and the tunnel it took it from, so a proof cannot be moved to another node or another tunnel.
func RekeyProof(node, previous, key string, tunnel *Tunnel) []byte {
var t Tunnel
if tunnel != nil {
t = *tunnel
}
peers := make([]string, 0, len(t.Peers))
for _, p := range t.Peers {
peers = append(peers, p.PublicKey+"@"+p.Address)
}
return []byte("novox-mesh-rekey\x00" + node + "\x00" + previous + "\x00" + key + "\x00" +
t.Interface + "\x00" + t.Unit + "\x00" + t.Config + "\x00" + strconv.Itoa(t.Port) + "\x00" +
t.Address + "\x00" + t.Range + "\x00" + t.PublicKey + "\x00" + strings.Join(peers, ","))
}
// Held is one file or container found on an adopted node and kept as it was.
type Held struct {
ID string `json:"id"`
+135
View File
@@ -0,0 +1,135 @@
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
}
+9 -1
View File
@@ -52,6 +52,14 @@ type TakeOver struct {
Config string
}
// HostPrefix is one address as a route: /32 for IPv4, /128 for IPv6.
func HostPrefix(address string) string {
if strings.Contains(address, ":") {
return address + "/128"
}
return address + "/32"
}
// CarriedName is how a carried peer is named in a peer list: it has no node name, so it is named
// by the key its packets arrive under.
func CarriedName(key string) string {
@@ -203,7 +211,7 @@ func Compute(nodes []Node, overlayCIDR string) (Graph, error) {
}
peers = append(peers, Peer{
Name: CarriedName(c.Key), Key: c.Key,
Allowed: c.Address + "/32",
Allowed: HostPrefix(c.Address),
Why: "carried from the tunnel this hub took over — a peer of the tunnel, not yet a node of the mesh",
})
}