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:
@@ -14,6 +14,7 @@ import (
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/overlay"
|
||||
)
|
||||
|
||||
// A node is adopted or converged (novox/hq ADR 0100), and it is said to be adopted wherever the
|
||||
@@ -85,12 +86,19 @@ func showTunnel(ctx context.Context, inv *inventory.Inventory, name string) erro
|
||||
switch {
|
||||
case !said:
|
||||
fmt.Printf(" %-17s not yet taken over — the node has not said so\n", "")
|
||||
case carried.Taken:
|
||||
case carried.State == inventory.CarriedTaken:
|
||||
fmt.Printf(" %-17s taken over: %s is down and disabled, never flushed; the mesh's interface "+
|
||||
"runs with its key, port and %d peer(s)\n", "", carried.Interface, carried.Peers)
|
||||
case carried.State == inventory.CarriedDown:
|
||||
fmt.Printf(" %-17s TUNNEL DOWN: %s is stopped and the mesh's interface is not up — the peers "+
|
||||
"reach nothing. On the machine: systemctl start %s\n", "", carried.Interface,
|
||||
"wg-quick@"+carried.Interface)
|
||||
default:
|
||||
fmt.Printf(" %-17s NOT taken over: %s is still the interface the peers reach\n", "", carried.Interface)
|
||||
}
|
||||
if said && carried.Note != "" {
|
||||
fmt.Printf(" %-17s %s\n", "", carried.Note)
|
||||
}
|
||||
if said && carried.Kept != "" {
|
||||
fmt.Printf(" %-17s its configuration's original kept at %s\n", "", carried.Kept)
|
||||
}
|
||||
@@ -247,6 +255,28 @@ func converge(ctx context.Context, open *stores, node string, yes bool, digest s
|
||||
return "", fmt.Errorf("%s still holds what it found, and a service is taken on its own, "+
|
||||
"never by the flip:\n%s", node, strings.Join(holding, "\n"))
|
||||
}
|
||||
// And refused while a peer of the tunnel this hub took over has not enrolled (novox/hq ADR
|
||||
// 0105): the flip loads the derived filter and retires the found firewall, and a machine the
|
||||
// mesh has no record of is not one the filter admits — it would go dark.
|
||||
if _, hubName, adopted, err := inv.AdoptedTunnel(ctx); err != nil {
|
||||
return "", err
|
||||
} else if adopted && hubName == node {
|
||||
carried, err := inv.CarriedPeers(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var waiting []string
|
||||
for _, c := range carried {
|
||||
if c.EnrolledAs == "" {
|
||||
waiting = append(waiting, fmt.Sprintf(" %s at %s", overlay.CarriedName(c.PublicKey), c.Address))
|
||||
}
|
||||
}
|
||||
if len(waiting) > 0 {
|
||||
return "", fmt.Errorf("%s carries peers of the tunnel it took over that have not enrolled, and "+
|
||||
"converging would cut them off — enrol each first (`overlay show` says which are enrolled):\n%s",
|
||||
node, strings.Join(waiting, "\n"))
|
||||
}
|
||||
}
|
||||
|
||||
shelf, err := inv.Catalogue(ctx)
|
||||
if err != nil {
|
||||
|
||||
@@ -224,7 +224,21 @@ func network(ctx context.Context, inv *inventory.Inventory, on map[string]bool,
|
||||
Name: p.Name, Key: p.Key, Endpoint: p.Endpoint,
|
||||
Site: p.Site, Hub: p.Hub, Address: p.Address,
|
||||
}
|
||||
if t, takes := tunnels[p.Name]; takes {
|
||||
if t, takes := tunnels[p.Name]; takes && t.NodeAdopted {
|
||||
// Only an adopted node is told to take the found unit over: on a converged one there
|
||||
// is nothing found to keep, and the host refuses the field. The range and the carried
|
||||
// peers do not depend on the mode; the takeover does.
|
||||
//
|
||||
// **Refused, not composed, when the hub's record disagrees with the tunnel.** A
|
||||
// declaration that stopped the found unit and raised the mesh's interface on another
|
||||
// port or address would leave every peer dark while reporting the tunnel taken — so a
|
||||
// hub placed before it took the tunnel over (or at the wrong port) is named here, and
|
||||
// nothing is sent until it is re-placed.
|
||||
if wrong := disagrees(p, t.Tunnel); wrong != "" {
|
||||
return nil, fmt.Errorf("%s takes over the tunnel on %s and its placement disagrees with it: %s. "+
|
||||
"Re-place it — `overlay place %s --hub --endpoint <host>:%d …` — and push again; "+
|
||||
"nothing was composed", p.Name, t.Interface, wrong, p.Name, t.Port)
|
||||
}
|
||||
n.TakesOver = &overlay.TakeOver{Interface: t.Interface, Unit: t.Unit, Config: t.Config}
|
||||
}
|
||||
if p.Hub {
|
||||
@@ -390,10 +404,11 @@ func overlayShow(ctx context.Context, open *stores) error {
|
||||
fmt.Printf(" hub — over the tunnel it took over on %s (range %s, port %d)",
|
||||
tunnel.Interface, tunnel.Range, tunnel.Port)
|
||||
case n.Hub && hubName == n.Name && tunnel.Interface != "":
|
||||
fmt.Printf(" hub — found a tunnel on %s and did NOT take it over: its key is not the tunnel's",
|
||||
tunnel.Interface)
|
||||
fmt.Printf(" hub — found a tunnel on %s and did NOT take it over: its key is not the tunnel's; "+
|
||||
"`mesh-host overlay take --tunnel %s` on the machine takes it", tunnel.Interface, tunnel.Interface)
|
||||
case n.Hub:
|
||||
fmt.Print(" hub")
|
||||
fmt.Print(" hub — found no tunnel; if the machine runs the predecessor's, " +
|
||||
"`mesh-host overlay take --tunnel <iface>` there adopts it (novox/hq ADR 0105)")
|
||||
case !n.Reachable():
|
||||
fmt.Print(" not dialable")
|
||||
}
|
||||
@@ -421,6 +436,30 @@ func overlayShow(ctx context.Context, open *stores) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// disagrees says how a node's placement differs from the tunnel it takes over — its address not
|
||||
// the tunnel's, its endpoint not on the tunnel's port — or nothing when both agree.
|
||||
func disagrees(p inventory.Overlay, t inventory.Tunnel) string {
|
||||
var wrong []string
|
||||
want := t.Address
|
||||
if i := strings.Index(want, "/"); i >= 0 {
|
||||
want = want[:i]
|
||||
}
|
||||
if p.Address != want {
|
||||
wrong = append(wrong, fmt.Sprintf("its address is %s and the tunnel's is %s", orNothing(p.Address), want))
|
||||
}
|
||||
if p.Reachable() && portOfEndpoint(p.Endpoint) != strconv.Itoa(t.Port) {
|
||||
wrong = append(wrong, fmt.Sprintf("its endpoint %s is not on the tunnel's port %d", p.Endpoint, t.Port))
|
||||
}
|
||||
return strings.Join(wrong, "; ")
|
||||
}
|
||||
|
||||
func orNothing(s string) string {
|
||||
if s == "" {
|
||||
return "unset"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// portOfEndpoint is the port in host:port, or empty.
|
||||
func portOfEndpoint(endpoint string) string {
|
||||
if i := strings.LastIndex(endpoint, ":"); i >= 0 {
|
||||
|
||||
@@ -189,3 +189,49 @@ func TestTheOverlaysRangeIsTheAdoptedTunnels(t *testing.T) {
|
||||
t.Errorf("the hub's peer list does not carry the tunnel's peer: %+v", computed["anchor"])
|
||||
}
|
||||
}
|
||||
|
||||
// A takeover is composed only for a hub whose placement agrees with the tunnel: an address or an
|
||||
// endpoint port that differs would have the host stop the found interface and raise the mesh's
|
||||
// where no peer is listening.
|
||||
func TestATakeoverIsNotComposedForAHubPlacedOffItsTunnel(t *testing.T) {
|
||||
open := aMesh(t)
|
||||
ctx := t.Context()
|
||||
inv := open.inventory
|
||||
if err := inv.SetAdopted(ctx, "anchor", true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hub, err := inv.NodeByName(ctx, "anchor")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const key = "THE-TUNNELS-KEY========================="
|
||||
if err := inv.RecordOverlayKey(ctx, hub.ID, key); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := inv.RecordTunnel(ctx, hub.ID, inventory.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: key}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// aMesh placed anchor at 10.77.0.1 on :51820 — the record of a hub placed before it took the
|
||||
// tunnel over.
|
||||
_, _, err = graph(ctx, open)
|
||||
if err == nil {
|
||||
t.Fatal("a takeover was composed for a hub whose address and port are not the tunnel's")
|
||||
}
|
||||
for _, want := range []string{"10.77.0.1", "192.0.2.1", "51820", "51900", "overlay place anchor"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("the refusal does not say %q: %v", want, err)
|
||||
}
|
||||
}
|
||||
// Re-placed on the tunnel, it composes.
|
||||
if err := inv.SetPlace(ctx, "anchor", "", "", false, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := overlayPlace(ctx, inv, []string{"anchor", "--endpoint", "198.51.100.10:51900", "--site", "here", "--hub"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := graph(ctx, open); err != nil {
|
||||
t.Fatalf("re-placed on the tunnel, the graph still refuses: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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(¤t, &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()
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -43,8 +43,28 @@ anchor's firewall is the predecessor's, installed by the bed):
|
||||
installer finds `wg0` itself (one interface besides `mesh0`) and takes its port and range. The
|
||||
bed asserts genesis **says** it found and took the tunnel.
|
||||
|
||||
**Review changes (2026-09-24).** A spoke's `wg0.conf` names one peer — the hub — routed the
|
||||
whole range; the controller skips range-routed peers, so T2's enrolment carries no peer from the
|
||||
spoke. A hub that enrolled *before* this feature (a generated key) takes the tunnel over without
|
||||
re-enrolling: `mesh-host overlay take --tunnel wg0` on the machine rekeys the overlay key only and
|
||||
sends a signed rekey; the bed adds **R0** for it below. A takeover is composed only for a hub
|
||||
placed at the tunnel's address on the tunnel's port, and the host stops nothing until the declared
|
||||
interface matches the found one and the key file holds the found key; a mesh interface that fails
|
||||
to start gives the found unit back. The host's account has three states: `not-taken`, `taken`,
|
||||
`down`.
|
||||
|
||||
## Assertions, in the record's order
|
||||
|
||||
- **R0 — a hub enrolled with its own key takes the tunnel over by rekeying.** Genesis is run
|
||||
adopted *without* the tunnel being found (the bed stops `wg-quick@wg0` for the run, so the
|
||||
installer sees no tunnel, then starts it again — the pre-feature shape). Then on the anchor:
|
||||
`mesh-host overlay take --tunnel wg0`; `node show anchor` says "tunnel found wg0 …"; `overlay
|
||||
place anchor --hub --endpoint 192.0.2.10:51900 --site hosting` (with `:51820` first, which must
|
||||
be refused naming 51900); `plan anchor --json` names `Address = 10.10.0.1/32`, `ListenPort =
|
||||
51900`, two `/32` peers, `takes-over` wg0 and nothing in 10.42.0.0/16; then `push anchor --wait
|
||||
2m` and T1's assertions hold. `overlay take` run a second time is refused by the controller as
|
||||
stale and changes nothing.
|
||||
|
||||
- **T1 — the tunnel changes hands and the peers notice nothing.** After genesis and the push
|
||||
that raises the private network on the anchor:
|
||||
- `wg show interfaces` on the anchor lists `mesh0` and not `wg0`;
|
||||
|
||||
Reference in New Issue
Block a user