A home node behind NAT (no Endpoint → not Reachable) that took over a tunnel must still listen on that tunnel's port: its LAN peers dial it there. ListenPort was gated on Reachable, which conflated 'a peer dials me here' with 'the hub can dial me' — so the takeover guard refused overlay-up, and the guard's suggested remedy (re-place with an endpoint) breaks a NAT'd node's path: it stops keepalive and hands the hub a private LAN address to dial. TakeOver now carries the found tunnel's port (already known to the controller), and the interface listens on it when the node is not otherwise reachable. Two tests; Endpoint-reachable nodes keep the old path unchanged.
289 lines
12 KiB
Go
289 lines
12 KiB
Go
package overlay
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func declarationFor(t *testing.T, node Node, peers []Peer) (string, []map[string]any) {
|
|
t.Helper()
|
|
raw, err := Declaration(node, peers, "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var d struct {
|
|
Declaration int `json:"declaration"`
|
|
Resources []map[string]any `json:"resources"`
|
|
}
|
|
if err := json.Unmarshal(raw, &d); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if d.Declaration != 1 {
|
|
t.Fatalf("declaration version %d", d.Declaration)
|
|
}
|
|
for _, r := range d.Resources {
|
|
if r["type"] == "file" {
|
|
return r["content"].(string), d.Resources
|
|
}
|
|
}
|
|
t.Fatal("the declaration has no configuration file in it")
|
|
return "", nil
|
|
}
|
|
|
|
func TestNoPrivateKeyEverTravels(t *testing.T) {
|
|
// The property the whole design rests on: the node generated its keypair and kept the private
|
|
// half, so the mesh composes a configuration for a node it cannot impersonate. A private key
|
|
// appearing here would mean the control plane had one — and a copy of its database would then
|
|
// be every node's network identity.
|
|
config, _ := declarationFor(t, Node{Name: "laptop", Key: "PUB", Address: "10.42.0.2"},
|
|
[]Peer{{Name: "anchor", Key: "HUB", Allowed: "10.42.0.0/16", Endpoint: "198.51.100.1:51820"}})
|
|
|
|
if strings.Contains(config, "PrivateKey") {
|
|
t.Error("the configuration carries a PrivateKey line; the mesh must never hold one")
|
|
}
|
|
if !strings.Contains(config, "private-key "+DefaultKeyPath) {
|
|
t.Error("the configuration does not point at the key file the node wrote, so the " +
|
|
"interface would come up with no key at all")
|
|
}
|
|
}
|
|
|
|
func TestTheDeclarationUsesOnlyShapesTheHostAlreadyHas(t *testing.T) {
|
|
// Connectivity needs nothing new from tier 0, and that is worth holding: the host does not
|
|
// know what a private network is, and should not learn.
|
|
_, resources := declarationFor(t, Node{Name: "laptop", Key: "PUB", Address: "10.42.0.2"}, nil)
|
|
|
|
allowed := map[string]bool{"package": true, "file": true, "service": true,
|
|
"directory": true, "container": true, "action": true}
|
|
for _, r := range resources {
|
|
if !allowed[r["type"].(string)] {
|
|
t.Errorf("the overlay declaration uses %q, which the host does not have", r["type"])
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestTheInterfaceComesBackAfterAReboot(t *testing.T) {
|
|
// A node whose overlay only exists while something is watching is not a node that survives
|
|
// being switched off and on — and it would come back unreachable, which is the worst way to
|
|
// come back.
|
|
_, resources := declarationFor(t, Node{Name: "laptop", Key: "PUB", Address: "10.42.0.2"}, nil)
|
|
for _, r := range resources {
|
|
if r["type"] == "service" {
|
|
if r["boot"] != "enabled" {
|
|
t.Error("the overlay interface is not enabled at boot")
|
|
}
|
|
if r["state"] != "running" {
|
|
t.Error("the overlay interface is not asked to be running")
|
|
}
|
|
return
|
|
}
|
|
}
|
|
t.Error("nothing in the declaration brings the interface up")
|
|
}
|
|
|
|
func TestTheConfigurationIsNotWorldReadable(t *testing.T) {
|
|
// It lists every peer's key and endpoint, which is a map of the mesh. Not secret the way a
|
|
// private key is, and not something to leave readable on a machine somebody else also uses.
|
|
// The peer list specifically, not every file. `/etc/hosts` is in here too and must be
|
|
// world-readable, or nothing on the machine resolves anything — a check that swept all files
|
|
// would force it to 0600 and break the machine to protect a file that is not secret.
|
|
_, resources := declarationFor(t, Node{Name: "laptop", Key: "PUB", Address: "10.42.0.2"}, nil)
|
|
for _, r := range resources {
|
|
if r["id"] == "overlay-config" && r["mode"] != "0600" {
|
|
t.Errorf("the peer list is mode %v", r["mode"])
|
|
}
|
|
if r["id"] == "mesh-names" && r["mode"] != "0644" {
|
|
t.Errorf("the name file is mode %v; nothing on the machine could read it", r["mode"])
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAPeerThatCannotBeDialledSaysSo(t *testing.T) {
|
|
// A [Peer] with no Endpoint is correct and looks like a mistake. Saying why stops somebody
|
|
// helpfully adding one that cannot work.
|
|
config, _ := declarationFor(t, Node{Name: "anchor", Key: "HUB", Address: "10.42.0.1",
|
|
Endpoint: "198.51.100.1:51820", Hub: true},
|
|
[]Peer{{Name: "laptop", Key: "PUB", Allowed: "10.42.0.2/32", Why: "routes through this hub"}})
|
|
|
|
if strings.Contains(config, "Endpoint =") {
|
|
t.Error("an endpoint was written for a peer that has none")
|
|
}
|
|
if !strings.Contains(config, "cannot be dialled") {
|
|
t.Error("the file does not say why that peer has no endpoint")
|
|
}
|
|
}
|
|
|
|
func TestTheFileSaysNotToEditIt(t *testing.T) {
|
|
// It is replaced whenever the graph changes. An edit survives until then and vanishes, which
|
|
// is worse than never being applied — the machine works, then stops, and nothing changed
|
|
// that anybody remembers.
|
|
config, _ := declarationFor(t, Node{Name: "laptop", Key: "PUB", Address: "10.42.0.2"}, nil)
|
|
if !strings.Contains(config, "Do not edit") {
|
|
t.Error("a generated file does not say it is generated")
|
|
}
|
|
}
|
|
|
|
func TestANodeWithNoAddressIsRefused(t *testing.T) {
|
|
// Rather than a configuration with a blank address, which wg-quick would reject on the
|
|
// machine, at boot, where the failure is much harder to see.
|
|
if _, err := Declaration(Node{Name: "laptop", Key: "PUB"}, nil, ""); err == nil {
|
|
t.Fatal("a node with no overlay address was given a configuration")
|
|
}
|
|
}
|
|
|
|
func TestOnlyAReachableNodeListens(t *testing.T) {
|
|
// A ListenPort on a node nothing can dial is a port open for no reason.
|
|
config, _ := declarationFor(t, Node{Name: "laptop", Key: "PUB", Address: "10.42.0.2"}, nil)
|
|
if strings.Contains(config, "ListenPort") {
|
|
t.Error("a node that cannot be dialled was told to listen")
|
|
}
|
|
config, _ = declarationFor(t, Node{Name: "anchor", Key: "HUB", Address: "10.42.0.1",
|
|
Endpoint: "198.51.100.1:51820"}, nil)
|
|
if !strings.Contains(config, "ListenPort = 51820") {
|
|
t.Error("a reachable node does not listen on the port its endpoint names")
|
|
}
|
|
}
|
|
|
|
func TestTheServiceIsRestartedWhenThePeerListChanges(t *testing.T) {
|
|
// The fault this exists to catch, found in the lab the moment a third node arrived: a running
|
|
// WireGuard interface does not re-read its configuration. The file was replaced, the service
|
|
// was already running so nothing reloaded it, and every existing node kept a network that no
|
|
// longer matched the mesh — while looking entirely successful.
|
|
//
|
|
// So the declaration has to verify what the interface is *carrying*, not what the file says.
|
|
//
|
|
// And it must be declared state rather than a command: the host refuses an action arriving
|
|
// over the link (novox/hq ADR 0005), correctly, which is how this shape was arrived at. There
|
|
// is a test below that no action is ever in here.
|
|
_, resources := declarationFor(t, Node{Name: "laptop", Key: "PUB", Address: "10.42.0.2"},
|
|
[]Peer{{Name: "anchor", Key: "HUBKEY", Allowed: "10.42.0.0/16"}})
|
|
|
|
for _, r := range resources {
|
|
if r["type"] != "service" {
|
|
continue
|
|
}
|
|
reflects := fmt.Sprint(r["restart-on"])
|
|
if !strings.Contains(reflects, "overlay-config") {
|
|
t.Errorf("the interface does not restart when its configuration changes: %v", reflects)
|
|
}
|
|
return
|
|
}
|
|
t.Error("nothing in the declaration brings the interface up")
|
|
}
|
|
|
|
func TestTheOverlayDeclarationCarriesNoAction(t *testing.T) {
|
|
// The link may not carry an action, and the host refuses a declaration containing one — whole,
|
|
// not in part. An overlay declaration with an action in it does not half-apply: it leaves the
|
|
// node with no network at all.
|
|
_, resources := declarationFor(t, Node{Name: "laptop", Key: "PUB", Address: "10.42.0.2"}, nil)
|
|
for _, r := range resources {
|
|
if r["type"] == "action" {
|
|
t.Errorf("the declaration contains an action (%v); the host will refuse the whole "+
|
|
"thing and the node will have no network", r["id"])
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestTheHubForwardsAndNobodyElseDoes(t *testing.T) {
|
|
// A hub carries traffic between its spokes, and Linux does not forward packets unless told
|
|
// to. Without it every spoke reaches the hub perfectly and no spoke reaches any other — which
|
|
// is how it failed in the lab, and it presents as a peering problem rather than a kernel
|
|
// setting, so it is worth being sure of.
|
|
hub, _ := declarationFor(t, Node{Name: "anchor", Key: "HUB", Address: "10.42.0.1",
|
|
Endpoint: "198.51.100.1:51820", Hub: true}, nil)
|
|
if !strings.Contains(hub, "ip_forward=1") {
|
|
t.Error("the hub does not enable forwarding, so its spokes cannot reach each other")
|
|
}
|
|
if !strings.Contains(hub, "ip_forward=0") {
|
|
t.Error("the hub never stops forwarding; a machine that stops being the hub would keep " +
|
|
"passing traffic it is no longer part of")
|
|
}
|
|
|
|
// And past the machine's own firewall. Any node with a container runtime has FORWARD set to
|
|
// DROP by Docker, so enabling ip_forward alone changes nothing — which is exactly how it
|
|
// failed, with every spoke reaching the hub and no spoke reaching any other.
|
|
// Checked as PostUp specifically. "contains FORWARD" passes on the PostDown line alone,
|
|
// which would leave a hub that tears down rules it never put up.
|
|
if !strings.Contains(hub, "PostUp = command -v iptables") {
|
|
t.Error("the hub does not open its own firewall, so a container runtime's DROP policy " +
|
|
"silently eats everything it was supposed to carry")
|
|
}
|
|
if !strings.Contains(hub, "PostDown = command -v iptables") {
|
|
t.Error("the hub never removes those rules, so a machine that stops being the hub keeps " +
|
|
"passing traffic it is no longer part of")
|
|
}
|
|
|
|
spoke, _ := declarationFor(t, Node{Name: "laptop", Key: "PUB", Address: "10.42.0.2"}, nil)
|
|
if strings.Contains(spoke, "FORWARD") {
|
|
t.Error("a spoke was given forwarding rules it has no use for")
|
|
}
|
|
if strings.Contains(spoke, "ip_forward") {
|
|
t.Error("a spoke was told to forward packets, which is not its job and widens what a " +
|
|
"compromised one could do")
|
|
}
|
|
}
|
|
|
|
// novox/hq ADR 0105: a node whose private network takes over the tunnel it found is told so on
|
|
// the interface's service, and nothing else about the declaration changes — the key is already
|
|
// the found one, taken at enrolment.
|
|
func TestTakingOverAFoundTunnelIsSaidOnTheInterfacesService(t *testing.T) {
|
|
node := Node{Name: "anchor", Key: "PUB", Address: "192.0.2.1", Hub: true,
|
|
Endpoint: "198.51.100.1:51900",
|
|
TakesOver: &TakeOver{Interface: "wg0", Unit: "wg-quick@wg0", Config: "/etc/wireguard/wg0.conf"}}
|
|
config, resources := declarationFor(t, node, nil)
|
|
|
|
var up map[string]any
|
|
for _, r := range resources {
|
|
if r["type"] == "service" {
|
|
up = r
|
|
}
|
|
}
|
|
takes, ok := up["takes-over"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("the interface's service does not say what it takes over: %+v", up)
|
|
}
|
|
if takes["unit"] != "wg-quick@wg0" || takes["config"] != "/etc/wireguard/wg0.conf" || takes["interface"] != "wg0" {
|
|
t.Errorf("the takeover names the wrong tunnel: %+v", takes)
|
|
}
|
|
if !strings.Contains(config, "ListenPort = 51900") {
|
|
t.Errorf("the hub's interface does not listen on the tunnel's port:\n%s", config)
|
|
}
|
|
if strings.Contains(config, "PrivateKey") {
|
|
t.Error("the found key travelled in the configuration; it is the node's own, set from its key file")
|
|
}
|
|
|
|
_, plain := declarationFor(t, Node{Name: "laptop", Key: "PUB", Address: "192.0.2.4"}, nil)
|
|
for _, r := range plain {
|
|
if _, says := r["takes-over"]; says {
|
|
t.Error("a node taking over nothing was told to take something over")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestATakenTunnelBringsItsListenPortEvenWhenNotDialable(t *testing.T) {
|
|
// A home node behind NAT (no Endpoint, so not Reachable) that took over a tunnel must still
|
|
// listen on that tunnel's port, because its LAN peers dial it there (novox/hq: a taken tunnel
|
|
// brings its port). Without this the takeover guard refuses overlay-up.
|
|
config, _ := declarationFor(t, Node{
|
|
Name: "shanks", Key: "SPOKE", Address: "10.10.0.3",
|
|
TakesOver: &TakeOver{Interface: "wg0", Unit: "wg-quick@wg0", Port: 51820},
|
|
}, nil)
|
|
|
|
if !strings.Contains(config, "ListenPort = 51820") {
|
|
t.Fatalf("a taken tunnel's port must be the mesh interface's ListenPort:\n%s", config)
|
|
}
|
|
if strings.Contains(config, "Endpoint =") {
|
|
t.Error("a node that only listens for LAN peers must not advertise an endpoint")
|
|
}
|
|
}
|
|
|
|
func TestANodeWithNoTunnelAndNoEndpointStillListensOnNothing(t *testing.T) {
|
|
// The guard against over-emitting: a plain spoke with neither an endpoint nor a taken tunnel
|
|
// writes no ListenPort — it purely dials out.
|
|
config, _ := declarationFor(t, Node{Name: "laptop", Key: "K", Address: "10.10.0.9"}, nil)
|
|
if strings.Contains(config, "ListenPort") {
|
|
t.Fatalf("a dial-only node needs no ListenPort:\n%s", config)
|
|
}
|
|
}
|