Adopt the predecessor's tunnel in place: its range, its address, its peers (hq ADR 0105) #49
@@ -14,6 +14,7 @@ import (
|
|||||||
|
|
||||||
"github.com/novox/mesh-controller/internal/catalogue"
|
"github.com/novox/mesh-controller/internal/catalogue"
|
||||||
"github.com/novox/mesh-controller/internal/inventory"
|
"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
|
// 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 {
|
switch {
|
||||||
case !said:
|
case !said:
|
||||||
fmt.Printf(" %-17s not yet taken over — the node has not said so\n", "")
|
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 "+
|
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)
|
"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:
|
default:
|
||||||
fmt.Printf(" %-17s NOT taken over: %s is still the interface the peers reach\n", "", carried.Interface)
|
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 != "" {
|
if said && carried.Kept != "" {
|
||||||
fmt.Printf(" %-17s its configuration's original kept at %s\n", "", 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, "+
|
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"))
|
"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)
|
shelf, err := inv.Catalogue(ctx)
|
||||||
if err != nil {
|
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,
|
Name: p.Name, Key: p.Key, Endpoint: p.Endpoint,
|
||||||
Site: p.Site, Hub: p.Hub, Address: p.Address,
|
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}
|
n.TakesOver = &overlay.TakeOver{Interface: t.Interface, Unit: t.Unit, Config: t.Config}
|
||||||
}
|
}
|
||||||
if p.Hub {
|
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)",
|
fmt.Printf(" hub — over the tunnel it took over on %s (range %s, port %d)",
|
||||||
tunnel.Interface, tunnel.Range, tunnel.Port)
|
tunnel.Interface, tunnel.Range, tunnel.Port)
|
||||||
case n.Hub && hubName == n.Name && tunnel.Interface != "":
|
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",
|
fmt.Printf(" hub — found a tunnel on %s and did NOT take it over: its key is not the tunnel's; "+
|
||||||
tunnel.Interface)
|
"`mesh-host overlay take --tunnel %s` on the machine takes it", tunnel.Interface, tunnel.Interface)
|
||||||
case n.Hub:
|
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():
|
case !n.Reachable():
|
||||||
fmt.Print(" not dialable")
|
fmt.Print(" not dialable")
|
||||||
}
|
}
|
||||||
@@ -421,6 +436,30 @@ func overlayShow(ctx context.Context, open *stores) error {
|
|||||||
return nil
|
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.
|
// portOfEndpoint is the port in host:port, or empty.
|
||||||
func portOfEndpoint(endpoint string) string {
|
func portOfEndpoint(endpoint string) string {
|
||||||
if i := strings.LastIndex(endpoint, ":"); i >= 0 {
|
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"])
|
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"`
|
Port int `json:"port"`
|
||||||
Range string `json:"range"`
|
Range string `json:"range"`
|
||||||
Peers int `json:"peers"`
|
Peers int `json:"peers"`
|
||||||
// Taken is whether the found interface is down and the mesh's up with its key; Kept is where
|
// State is one of the CarriedStates: the found interface is still up and the mesh's is not
|
||||||
// the found configuration's original was kept.
|
// (not taken), the found one is down and the mesh's up with its key (taken), or the found one
|
||||||
Taken bool `json:"taken"`
|
// 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"`
|
Kept string `json:"kept,omitempty"`
|
||||||
At time.Time `json:"at"`
|
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
|
// 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.
|
// — once a node enrols with that key — a node of the mesh as well.
|
||||||
type CarriedPeer struct {
|
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))
|
peers := make([]TunnelPeer, 0, len(t.Peers))
|
||||||
for _, p := range t.Peers {
|
for _, p := range t.Peers {
|
||||||
host, err := peerHost(p.Address)
|
host, single := peerHost(p.Address)
|
||||||
if err != nil {
|
if !single {
|
||||||
return fmt.Errorf("the found tunnel's peer %s: %w", shortKey(p.PublicKey), err)
|
// 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) {
|
if !address.Masked().Contains(host) {
|
||||||
return fmt.Errorf("the found tunnel's peer %s is routed at %s, outside the tunnel's %s",
|
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)
|
return tx.Commit(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
// peerHost is the one host address a peer's allowed address names: a bare address, or a /32
|
// 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
|
// (or /128) — and false for anything wider or unreadable: a peer routed a whole range is not a
|
||||||
// address the mesh could give a node.
|
// machine with an address the mesh could give a node.
|
||||||
func peerHost(allowed string) (netip.Addr, error) {
|
func peerHost(allowed string) (netip.Addr, bool) {
|
||||||
allowed = strings.TrimSpace(allowed)
|
allowed = strings.TrimSpace(allowed)
|
||||||
if a, err := netip.ParseAddr(allowed); err == nil {
|
if a, err := netip.ParseAddr(allowed); err == nil {
|
||||||
return a, nil
|
return a, true
|
||||||
}
|
}
|
||||||
p, err := netip.ParsePrefix(allowed)
|
p, err := netip.ParsePrefix(allowed)
|
||||||
if err != nil {
|
if err != nil || !p.IsSingleIP() {
|
||||||
return netip.Addr{}, fmt.Errorf("%q is not an address", allowed)
|
return netip.Addr{}, false
|
||||||
}
|
}
|
||||||
if !p.IsSingleIP() {
|
return p.Addr(), true
|
||||||
return netip.Addr{}, fmt.Errorf("%q names a range, and a peer of the tunnel is one address", allowed)
|
|
||||||
}
|
|
||||||
return p.Addr(), nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func shortKey(key string) string {
|
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
|
// 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
|
// hub's found tunnel, when the hub's overlay key is the tunnel's. Absent, the mesh runs on its own
|
||||||
// runs on its own range — and a hub that found a tunnel but holds another key did not adopt it,
|
// range — and a hub that found a tunnel but holds another key did not adopt it, which `overlay
|
||||||
// which `overlay show` says.
|
// show` says.
|
||||||
//
|
//
|
||||||
// The condition on the key is the condition of the whole record: a hub raised with a key of its
|
// 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
|
// 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) {
|
func (i *Inventory) AdoptedTunnel(ctx context.Context) (Tunnel, string, bool, error) {
|
||||||
var name string
|
var name string
|
||||||
var key *string
|
var key *string
|
||||||
var adopted bool
|
|
||||||
err := i.store.Pool().QueryRow(ctx,
|
err := i.store.Pool().QueryRow(ctx,
|
||||||
`select name, overlay_key, adopted from node where is_hub and tunnel is not null`).
|
`select name, overlay_key from node where is_hub and tunnel is not null`).
|
||||||
Scan(&name, &key, &adopted)
|
Scan(&name, &key)
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return Tunnel{}, "", false, nil
|
return Tunnel{}, "", false, nil
|
||||||
}
|
}
|
||||||
@@ -222,7 +236,7 @@ func (i *Inventory) AdoptedTunnel(ctx context.Context) (Tunnel, string, bool, er
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return Tunnel{}, "", false, err
|
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, false, nil
|
||||||
}
|
}
|
||||||
return t, name, true, 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,
|
rows, err := i.store.Pool().Query(ctx,
|
||||||
`select p.public_key, host(p.address), coalesce(n.name, '')
|
`select p.public_key, host(p.address), coalesce(n.name, '')
|
||||||
from tunnel_peer p
|
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'
|
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
|
left join node n on n.overlay_key = p.public_key
|
||||||
order by p.address`)
|
order by p.address`)
|
||||||
@@ -254,33 +268,81 @@ func (i *Inventory) CarriedPeers(ctx context.Context) ([]CarriedPeer, error) {
|
|||||||
return out, rows.Err()
|
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
|
// 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.
|
// 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,
|
rows, err := i.store.Pool().Query(ctx,
|
||||||
`select name, tunnel from node
|
`select name, tunnel, adopted from node
|
||||||
where adopted and tunnel is not null and overlay_key = tunnel->>'public_key'`)
|
where tunnel is not null and overlay_key = tunnel->>'public_key'`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
out := map[string]Tunnel{}
|
out := map[string]FoundTunnel{}
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var name string
|
var name string
|
||||||
var raw []byte
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
var t Tunnel
|
var t Tunnel
|
||||||
if err := json.Unmarshal(raw, &t); err != nil {
|
if err := json.Unmarshal(raw, &t); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
out[name] = t
|
out[name] = FoundTunnel{Tunnel: t, NodeAdopted: adopted}
|
||||||
}
|
}
|
||||||
return out, rows.Err()
|
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.
|
// RecordCarriedTunnel keeps what a node last said about carrying its found tunnel.
|
||||||
func (i *Inventory) RecordCarriedTunnel(ctx context.Context, nodeID string, c Carried) error {
|
func (i *Inventory) RecordCarriedTunnel(ctx context.Context, nodeID string, c Carried) error {
|
||||||
c.At = time.Now().UTC()
|
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)
|
inv := fresh(t)
|
||||||
hub, err := inv.AddNodeAs(t.Context(), "anchor", true)
|
hub, err := inv.AddNodeAs(t.Context(), "anchor", true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
found := theFoundTunnel()
|
found := theFoundTunnel()
|
||||||
found.Peers = append(found.Peers, TunnelPeer{PublicKey: "WIDE", Address: "198.51.100.0/24"})
|
found.Peers = append(found.Peers, TunnelPeer{PublicKey: "WIDE", Address: "192.0.2.0/24"})
|
||||||
err = inv.RecordTunnel(t.Context(), hub.ID, found)
|
if err := inv.RecordTunnel(t.Context(), hub.ID, found); err != nil {
|
||||||
if err == nil || !strings.Contains(err.Error(), "names a range") {
|
t.Fatal(err)
|
||||||
t.Fatalf("a peer routed a whole range was recorded as a machine with an address: %v", err)
|
|
||||||
}
|
}
|
||||||
if _, err := inv.TunnelOf(t.Context(), "anchor"); !errors.Is(err, ErrNoTunnel) {
|
got, err := inv.TunnelOf(t.Context(), "anchor")
|
||||||
t.Fatalf("a refused tunnel was recorded anyway: %v", err)
|
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
|
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.
|
// claimant names the key presenting a token, so a claim can be held for it alone.
|
||||||
func claimant(public ed25519.PublicKey) string {
|
func claimant(public ed25519.PublicKey) string {
|
||||||
sum := sha256.Sum256(public)
|
sum := sha256.Sum256(public)
|
||||||
@@ -246,11 +274,22 @@ func (e Enrolment) Heard(ctx context.Context, report Report) (err error) {
|
|||||||
if report.Tunnel != nil {
|
if report.Tunnel != nil {
|
||||||
if err := e.Inventory.RecordCarriedTunnel(ctx, node.ID, inventory.Carried{
|
if err := e.Inventory.RecordCarriedTunnel(ctx, node.ID, inventory.Carried{
|
||||||
Interface: report.Tunnel.Interface, Port: report.Tunnel.Port, Range: report.Tunnel.Range,
|
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 {
|
}); err != nil {
|
||||||
return err
|
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
|
// 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),
|
// moves last_seen and touches nothing else. This arrives every minute (link.AliveEvery),
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ package link
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -161,18 +163,57 @@ type Report struct {
|
|||||||
// 0105): the interface, its port, range and peer count, whether the found interface is down
|
// 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.
|
// and the mesh's up in its place, and where the found configuration's original was kept.
|
||||||
Tunnel *CarriedTunnel `json:"tunnel,omitempty"`
|
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 {
|
type CarriedTunnel struct {
|
||||||
Interface string `json:"interface"`
|
Interface string `json:"interface"`
|
||||||
Port int `json:"port"`
|
Port int `json:"port"`
|
||||||
Range string `json:"range"`
|
Range string `json:"range"`
|
||||||
Peers int `json:"peers"`
|
Peers int `json:"peers"`
|
||||||
Taken bool `json:"taken"`
|
State string `json:"state"`
|
||||||
|
Note string `json:"note,omitempty"`
|
||||||
Kept string `json:"kept,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.
|
// Held is one file or container found on an adopted node and kept as it was.
|
||||||
type Held struct {
|
type Held struct {
|
||||||
ID string `json:"id"`
|
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
|
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
|
// 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.
|
// by the key its packets arrive under.
|
||||||
func CarriedName(key string) string {
|
func CarriedName(key string) string {
|
||||||
@@ -203,7 +211,7 @@ func Compute(nodes []Node, overlayCIDR string) (Graph, error) {
|
|||||||
}
|
}
|
||||||
peers = append(peers, Peer{
|
peers = append(peers, Peer{
|
||||||
Name: CarriedName(c.Key), Key: c.Key,
|
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",
|
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
|
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.
|
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
|
## 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
|
- **T1 — the tunnel changes hands and the peers notice nothing.** After genesis and the push
|
||||||
that raises the private network on the anchor:
|
that raises the private network on the anchor:
|
||||||
- `wg show interfaces` on the anchor lists `mesh0` and not `wg0`;
|
- `wg show interfaces` on the anchor lists `mesh0` and not `wg0`;
|
||||||
|
|||||||
Reference in New Issue
Block a user