Carries MTU from the reported tunnel (mesh-host#28) through inventory, the overlay graph's TakeOver, into the generated config's [Interface]. A tuned path keeps its MTU across the takeover instead of regressing to 1420 and hanging transfers no ping would reveal. Two emit tests; a tunnel with no MTU writes no line.
312 lines
13 KiB
Go
312 lines
13 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)
|
|
}
|
|
}
|
|
|
|
func TestATakenTunnelCarriesItsMTU(t *testing.T) {
|
|
// A path tuned to a smaller MTU (1380 here) must survive the takeover — the mesh interface
|
|
// comes up with the same MTU, or transfers hang silently (novox/hq: a taken tunnel carries
|
|
// its MTU).
|
|
config, _ := declarationFor(t, Node{
|
|
Name: "shanks", Key: "SPOKE", Address: "10.10.0.3",
|
|
TakesOver: &TakeOver{Interface: "wg0", Port: 51820, MTU: 1380},
|
|
}, nil)
|
|
if !strings.Contains(config, "MTU = 1380") {
|
|
t.Fatalf("the tuned MTU was dropped:\n%s", config)
|
|
}
|
|
}
|
|
|
|
func TestNoMTULineWhenTheTunnelSetNone(t *testing.T) {
|
|
config, _ := declarationFor(t, Node{
|
|
Name: "shanks", Key: "SPOKE", Address: "10.10.0.3",
|
|
TakesOver: &TakeOver{Interface: "wg0", Port: 51820},
|
|
}, nil)
|
|
if strings.Contains(config, "MTU") {
|
|
t.Fatalf("no MTU was found, so none should be written:\n%s", config)
|
|
}
|
|
}
|