diff --git a/internal/overlay/declaration.go b/internal/overlay/declaration.go deleted file mode 100644 index 7c100afe..00000000 --- a/internal/overlay/declaration.go +++ /dev/null @@ -1,197 +0,0 @@ -package overlay - -import ( - "encoding/json" - "fmt" - "strings" -) - -// The overlay is delivered as an ordinary declaration. -// -// novox/hq 08-connectivity: what the host receives is an interface configuration and a peer list, -// as files. It does not compute them and it does not know what a private network is — the shapes -// it already has are enough, which is why connectivity needs nothing new from tier 0. -// -// **No private key travels.** The configuration points at a file the node wrote from a key the -// mesh has never seen, using WireGuard's own ability to set one after the interface is up. So the -// control plane composes a complete configuration for a node it cannot impersonate. - -// Interface is what the private network is called on a machine, and where the node's own key -// lives. A constant rather than a setting: two nodes disagreeing about the name would produce a -// mesh where each is configured correctly and nothing meets. -const ( - Interface = "mesh0" - ConfigPath = "/etc/wireguard/" + Interface + ".conf" - Unit = "wg-quick@" + Interface - DefaultKeyPath = "/var/lib/mesh-host/overlay.key" -) - -// Resource is one entry in a declaration, built here and read by the host. -type Resource map[string]any - -// Declaration is what the mesh sends a node to put it on the network. -// -// Three resources and nothing clever: the tools, the configuration, and the interface running. A -// person can read it, which is the point — this is the first thing a node is ever told, and if it -// is wrong the node is unreachable and the mistake has to be findable by eye. -func Declaration(node Node, peers []Peer, keyPath string) ([]byte, error) { - if node.Address == "" { - return nil, fmt.Errorf("%s has no address on the overlay, so there is nothing to configure", - node.Name) - } - if keyPath == "" { - keyPath = DefaultKeyPath - } - - up := Resource{ - "id": "overlay-up", "type": "service", "unit": Unit, - "state": "running", - // Enabled, so the node comes back onto the network after a reboot without waiting to - // be told again. A node whose overlay only exists while something is watching is not - // a node that survives being switched off and on. - "boot": "enabled", - // And restarted when the peer list changes, because a running interface does not - // re-read its configuration. - // - // This is the whole of it: a node joins, every existing node's peer list changes, - // each file is replaced — and without this the service is already running, nothing - // reloads it, and every node keeps a network that no longer matches the mesh. It - // reports complete success. The lab found it the moment a third node arrived. - // - // Declared state rather than a command. The service must reflect the file; the host - // works out that it does not. A command to restart would be an action, and the link - // may not carry one (novox/hq ADR 0005) — the host refused exactly that, correctly, - // which is how this shape was arrived at. - "restart-on": []string{"overlay-config"}, - } - if node.TakesOver != nil { - // The private network takes over the tunnel it found (novox/hq ADR 0105): before this - // unit starts, the host stops and disables the found one — never flushing it — and keeps - // its configuration like any held file. The key is already the found one: the node took - // it as its own overlay key when it enrolled, which is why the mesh's peer list for it - // carries the found peers under the key they know. - up["takes-over"] = map[string]any{ - "interface": node.TakesOver.Interface, - "unit": node.TakesOver.Unit, - "config": node.TakesOver.Config, - } - } - - resources := []Resource{ - { - "id": "overlay-tools", "type": "package", "package": "wireguard-tools", - }, - { - "id": "overlay-config", "type": "file", "path": ConfigPath, - // Readable only by root: it lists every peer's key and endpoint, which is a map of - // the mesh. Not secret in the way a private key is, and not something to leave - // world-readable on a machine somebody else also uses. - "mode": "0600", - "content": config(node, peers, keyPath), - }, - up, - } - - // The names used to be appended here, on the argument that a node with peers and no names is - // half on the network. True, and the wrong place to fix it: names would be identical over a - // different private network, so bundling them with WireGuard made one module out of two - // things. They are their own module now, requiring this one — which is what keeps them - // arriving together without pretending they are the same concern. - return json.Marshal(map[string]any{"declaration": 1, "resources": resources}) -} - -// config writes the interface file. -// -// Deliberately in the order a person would read it: who I am, then who I talk to, each with a -// line saying why it is there. A generated file that cannot be understood by the person it -// confuses is a generated file that gets edited by hand. -func config(node Node, peers []Peer, keyPath string) string { - var b strings.Builder - b.WriteString("# Generated by the mesh. Do not edit — this file is replaced whenever the\n") - b.WriteString("# peer graph changes, and an edit would survive until the next change and\n") - b.WriteString("# then vanish, which is worse than not being applied at all.\n") - fmt.Fprintf(&b, "#\n# node %s", node.Name) - if node.Site != "" { - fmt.Fprintf(&b, ", at %s", node.Site) - } - if !node.Reachable() { - b.WriteString(", not dialable — it opens every path itself") - } - b.WriteString("\n\n[Interface]\n") - fmt.Fprintf(&b, "Address = %s/32\n", node.Address) - if node.Reachable() { - if port := portOf(node.Endpoint); port != "" { - fmt.Fprintf(&b, "ListenPort = %s\n", port) - } - } else if node.TakesOver != nil && node.TakesOver.Port != 0 { - // Not dialable from the hub, but a LAN peer dials this node on the tunnel it took over, - // so the mesh's interface must listen on that same port (novox/hq: a taken tunnel brings - // its port). Without this the takeover guard refuses overlay-up, and re-placing the node - // with an endpoint — the guard's suggested remedy — breaks a NAT'd node's path. - fmt.Fprintf(&b, "ListenPort = %d\n", node.TakesOver.Port) - } - // The MTU the found tunnel carried, when it set one: a path tuned to 1380 (say) stalls TLS - // and hangs transfers if the mesh's interface comes up at the 1420 default, and no ping shows - // it (novox/hq: a taken tunnel carries its MTU). - if node.TakesOver != nil && node.TakesOver.MTU != 0 { - fmt.Fprintf(&b, "MTU = %d\n", node.TakesOver.MTU) - } - // The private key is set from a file the node wrote, so it never appears here and never - // travelled. Everything else in this file came from the mesh; this one line is the node's. - fmt.Fprintf(&b, "PostUp = wg set %%i private-key %s\n", keyPath) - - if node.Hub { - // A hub carries traffic *between* its spokes, and a Linux machine does not forward - // packets unless it is told to. Without this every spoke reaches the hub perfectly and - // no spoke reaches any other — which is exactly how it failed in the lab, and it looks - // like a peering problem rather than a kernel setting. - // - // Here rather than in a separate resource because it is part of what being a hub means, - // and because it should last exactly as long as the interface does: a machine that stops - // being the hub should stop forwarding, and PostDown below is how that happens. - b.WriteString("PostUp = sysctl -q -w net.ipv4.ip_forward=1\n") - b.WriteString("PostDown = sysctl -q -w net.ipv4.ip_forward=0\n") - - // And past the machine's own firewall, which on any node with a container runtime is - // closed. Docker sets the FORWARD policy to DROP and inserts its chains, so the foundation - // this mesh installs at tier 1 silently breaks the network it builds at tier 2: every - // spoke reaches the hub, no spoke reaches any other, and every part of it reports - // success. Found in the lab; nothing about it is visible from the mesh's own state. - // - // Inserted at the top so it precedes those chains, and removed on the way down so a - // machine that stops being the hub stops carrying other people's traffic. Guarded on - // iptables existing: a machine without it has no policy to get past. - for _, direction := range []string{"-i", "-o"} { - fmt.Fprintf(&b, - "PostUp = command -v iptables >/dev/null && iptables -I FORWARD 1 %s %%i -j ACCEPT || true\n", - direction) - } - for _, direction := range []string{"-i", "-o"} { - fmt.Fprintf(&b, - "PostDown = command -v iptables >/dev/null && iptables -D FORWARD %s %%i -j ACCEPT || true\n", - direction) - } - } - - for _, p := range peers { - fmt.Fprintf(&b, "\n# %s — %s\n[Peer]\n", p.Name, p.Why) - fmt.Fprintf(&b, "PublicKey = %s\n", p.Key) - fmt.Fprintf(&b, "AllowedIPs = %s\n", p.Allowed) - if p.Endpoint != "" { - fmt.Fprintf(&b, "Endpoint = %s\n", p.Endpoint) - } else { - b.WriteString("# no endpoint: this peer cannot be dialled and opens the path itself\n") - } - if p.Keepalive { - b.WriteString("PersistentKeepalive = 25\n") - } - } - return b.String() -} - -func portOf(endpoint string) string { - if i := strings.LastIndex(endpoint, ":"); i >= 0 { - return endpoint[i+1:] - } - return "" -} diff --git a/internal/overlay/declaration_test.go b/internal/overlay/declaration_test.go deleted file mode 100644 index 64fabd97..00000000 --- a/internal/overlay/declaration_test.go +++ /dev/null @@ -1,311 +0,0 @@ -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) - } -} diff --git a/internal/overlay/generator.go b/internal/overlay/generator.go deleted file mode 100644 index 6c829f0b..00000000 --- a/internal/overlay/generator.go +++ /dev/null @@ -1,195 +0,0 @@ -package overlay - -import ( - "encoding/json" - "fmt" - "strconv" - - "github.com/novox/mesh-controller/internal/catalogue" -) - -// The private network as a module rather than as code beside the module system. -// -// A machine's peer list is derived from every other machine, so it differs on each one and -// changes when any of them changes — there is nothing that could be written in a manifest. So the -// module says its resources are computed, and this computes them. -// -// What that buys, beyond one mechanism instead of two: **a machine is on the private network -// because it was assigned the module.** Before this, every machine with a key and an address was -// on it, and there was no way to say a machine should stay off. -// -// It is worth saying what is *not* here, because networking looks like it should be foundational. -// The host needs none of this. It has an address and a route to the broker before the mesh exists -// — that is the machine's own networking — and the broker's address is carried in the enrolment -// token rather than resolved. So the private network is something the mesh installs on top, like -// anything else, and a node without it is an ordinary node that nothing reaches directly. - -// What a module asks for when it needs machines to reach each other. **This is the name that -// matters** — WireGuard is one way to answer it, and naming the requirement after the answer is -// how a mesh ends up unable to have a second one. -const Requirement = "private-network" - -// Name is the module that answers it with WireGuard. -// -// **It writes no names.** A machine's mesh names are answered by the mesh's one resolver (novox/hq -// ADR 0194), and /etc/hosts is the file of one module, the holder of `node-hostname` (ADR 0199, -// ADR 0223): the controller writes into no file another seat's holder owns. If the mesh ever needs a line there, it -// asks that holder to register it. This module asked for a `node-names` fact written into /etc/hosts -// until 2026-10-05; the host gives that region back at the first push without it. -const Name = "mesh-wireguard" - -// Addressing is the mesh handing out addresses on the private network itself. -// -// The mesh's resolver answers names from it, which is why it requires this rather than a private -// network in general. A different VPN that hands out its own addresses would come with its own -// names — the mesh has nothing to answer about a machine whose address it did not choose. -const Addressing = "mesh-addressing" - -// TheNetwork is what a machine can only have one of. -// -// Providing a private network is not the singular part — a machine could reasonably run two VPNs -// for two different purposes. Being **the** one the mesh runs over is singular, and without -// saying so a person who chose a different VPN can still end up with this one dragged back in by -// something that needed the mesh's own addresses. Which is exactly what happened, once. -const TheNetwork = "the-private-network" - -// **A resolver's data went the same way as the names.** Two constants used to sit here — a -// `mesh-resolver` module that would write the machines as wildcards, and a `resolver-data` -// requirement a daemon would ask for. Nothing ever provided or consumed either: a module that -// answers names asks for the `node-zones` fact in its own manifest (catalogue.FactsInto) and -// requires Addressing, since the file is made of the mesh's addresses and means nothing off the -// network. A requirement nothing provides is refused at resolution, so leaving the names here -// would only have documented a mechanism that does not exist. - -// **There is no bundle any more** (novox/hq ADR 0226). A `networking` module requiring this one and -// nothing else used to be what a machine was assigned, so that "get the network working" was one -// word and a second VPN could be chosen by assigning it instead. The mesh has one private network, -// every machine is on it, and the bundle was a second name for this module that every machine -// carried. A machine is assigned Name directly; another VPN is still chosen by assigning it in its -// place, which is all the bundle ever did. - -// Generator answers what one node's network configuration is. -type Generator struct { - nodes []Node - graph Graph - // keyPath is where each node keeps the private half it generated. Named rather than carried: - // the mesh has never seen it and never will. - keyPath string - // No registry. Being on the network is still what grants a machine the right to pull from the - // mesh's artifact store in the clear (novox/hq ADR 0082), but the runtime's file is the runtime's - // module's: the private network writes nothing into it, and that module states the registry - // itself, told where the store is reached by ${seat:mesh-artifact-store:reach} (novox/hq ADR - // 0222, issue 190). -} - -// From builds a generator over the machines that are part of the network. -// -// The nodes given are the ones assigned the module — not every node the mesh knows. A machine -// that was never given it is absent from everybody's peer list and from the names, which is what -// "not on the network" has to mean. -func From(nodes []Node, cidr, keyPath string) (*Generator, error) { - graph, err := Compute(nodes, cidr) - if err != nil { - return nil, err - } - return &Generator{nodes: nodes, graph: graph, keyPath: keyPath}, nil -} - -// Resources is one node's interface, peers and names. -func (g *Generator) Resources(node string) ([]map[string]any, bool, error) { - peers, part := g.graph[node] - if !part { - // Assigned and not yet placed on the network. Ordinary and brief, so it is an answer - // rather than an error. - return nil, false, nil - } - var self Node - for _, n := range g.nodes { - if n.Name == node { - self = n - } - } - - raw, err := Declaration(self, peers, g.keyPath) - if err != nil { - return nil, false, err - } - var parsed struct { - Resources []map[string]any `json:"resources"` - } - if err := json.Unmarshal(raw, &parsed); err != nil { - return nil, false, err - } - return parsed.Resources, true, nil -} - -// Nodes are the machines this generator was built over, so a caller can say who is on the network. -func (g *Generator) Nodes() []Node { return g.nodes } - -// Graph is the peer list per node, for showing. -func (g *Generator) Graph() Graph { return g.graph } - -// Manifest is the module the mesh provides for itself. -// -// It ships with the control plane rather than coming from a repository, because the thing that -// computes it ships with the control plane. That is the only way it is unusual: it is assigned, -// unassigned, resolved and settled exactly like a module somebody wrote. -func Manifest() map[string]any { - return map[string]any{ - "module": Name, - "version": "1", - "computed": Name, - "provides": []string{Requirement, Addressing}, - "claims": []map[string]any{{"name": TheNetwork, "scope": "node"}}, - } -} - -// Empty is a network nobody is on. -// -// A mesh where no machine was given the module. Legitimate rather than broken — every node still -// reaches the broker, which is what being in the mesh is — so it answers "not part of this" for -// everyone instead of refusing for want of a hub. -func Empty() *Generator { return &Generator{graph: Graph{}} } - -// Listens is the port this node accepts the private network on, which only a hub has. -// -// **A fact about this machine's place in the mesh, not about the module.** Every machine on the -// network runs the same module; a hub is dialled by every node at other sites and needs its port -// open, and a machine that is not a hub dials out and needs nothing open at all. A static field in -// a manifest is one answer for every machine that runs it, so it cannot say this — and the machine -// it would get wrong is the one facing the public internet, which is the machine that most needs -// filtering. -// -// The port is the one in the endpoint, which is also where the interface takes its ListenPort -// from. One source, so a rule set cannot open a port the interface is not on. -func (g *Generator) Listens(node string) ([]catalogue.Listening, error) { - for _, n := range g.nodes { - if n.Name != node { - continue - } - if !n.Reachable() { - // It dials out and nothing dials it. Opening a port here would be opening one on a - // machine nothing connects to, which is not harmless — it is a rule with no source - // that somebody later has to work out the reason for. - return nil, nil - } - port := portOf(n.Endpoint) - if port == "" { - return nil, fmt.Errorf( - "%s is reachable at %q and no port can be read from it, so what it must accept "+ - "the private network on is unknown", node, n.Endpoint) - } - number, err := strconv.Atoi(port) - if err != nil { - return nil, fmt.Errorf("%s is reachable at %q, and %q is not a port", node, n.Endpoint, port) - } - return []catalogue.Listening{{ - Port: number, Protocol: "udp", From: catalogue.FromEverywhere, - // From everywhere, and deliberately: a node at another site is not on the private - // network until this port lets it on, so restricting this to the mesh would be a - // rule that can never be satisfied by the thing it exists for. - Why: "the private network — a node at another site has no other way in", - }}, nil - } - return nil, nil -} diff --git a/internal/overlay/generator_test.go b/internal/overlay/generator_test.go deleted file mode 100644 index 569322bb..00000000 --- a/internal/overlay/generator_test.go +++ /dev/null @@ -1,30 +0,0 @@ -package overlay - -import ( - "testing" -) - -// novox/hq ADR 0222, issue 190: the runtime's file and service are the runtime's module's. The -// private network writes nothing into either — being on it still grants the right to pull from the -// mesh's store in the clear (ADR 0082), and the runtime's module states that trust itself. -func TestTheNetworkWritesNothingOfTheRuntimes(t *testing.T) { - nodes := []Node{ - {Name: "anchor", Site: "lab", Hub: true, Endpoint: "192.0.2.10:51820", Key: "k1", Address: "10.42.0.1"}, - {Name: "node2", Site: "lab", Key: "k2", Address: "10.42.0.2"}, - } - g, err := From(nodes, "10.42.0.0/16", "") - if err != nil { - t.Fatal(err) - } - for _, node := range []string{"anchor", "node2"} { - resources, part, err := g.Resources(node) - if err != nil || !part { - t.Fatalf("%s: resources: %v part=%v", node, err, part) - } - for _, r := range resources { - if r["path"] == "/etc/docker/daemon.json" || r["unit"] == "docker.service" { - t.Errorf("%s: the private network declares the runtime's %v", node, r) - } - } - } -} diff --git a/internal/overlay/graph.go b/internal/overlay/graph.go deleted file mode 100644 index 4be1a430..00000000 --- a/internal/overlay/graph.go +++ /dev/null @@ -1,231 +0,0 @@ -// Package overlay computes the private network every node runs on. -// -// novox/hq 08-connectivity. This is control-plane work by definition: a peer list is derived from -// every node at once, and no node has that. A node computes nothing about the mesh — it generates -// a keypair, publishes the public half, and receives the rest. -// -// The shape is a hub, with direct peering between nodes at the same site. Not a full mesh, and -// the reason is a property of WireGuard rather than a preference: there is no failover. A more -// specific route to a dead endpoint blackholes; it does not fall back to the general one. So a -// node gets exactly one path to any peer, because two would mean one of them silently swallowing -// traffic. -package overlay - -import ( - "errors" - "fmt" - "sort" - "strings" -) - -// Node is one machine's place on the network, as the mesh holds it. -type Node struct { - Name string - Key string - Endpoint string - Site string - Hub bool - Address string - - // Carried are the peers of the tunnel this node took over (novox/hq ADR 0105): machines the - // mesh has no record of, each known by the public key and the address the found tunnel routed - // to it. Only a hub has any. They stay in its peer list until a node enrols with that key — - // from then on the node is the peer. - Carried []Carried - // TakesOver names the found tunnel this node's private network replaces: its unit is stopped - // and disabled, never flushed, and its configuration kept, before the mesh's interface comes - // up with the found key. Nil on a node that raises the mesh's interface beside whatever it has. - TakesOver *TakeOver -} - -// Carried is one peer of an adopted tunnel that has not enrolled: a peer of the tunnel, not a -// node of the mesh. -type Carried struct { - Key string - Address string -} - -// TakeOver is the found tunnel a node's private network takes over, as the host is told it. -type TakeOver struct { - Interface string - Unit string - Config string - // MTU is the found tunnel's, when it set one — emitted so a tuned path keeps its MTU. - MTU int - // Port is the port the found tunnel listened on. The mesh's interface must listen on it too, - // even on a node that is not dialable from the hub: a home node's LAN peers dial it there - // (novox/hq: a taken tunnel brings its port). Listening is "a peer dials me here"; it is not - // "the hub can dial me", which is Reachable — the two were conflated. - Port int -} - -// 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 { - short := key - if len(short) > 8 { - short = short[:8] + "…" - } - return "a peer of the tunnel (" + short + ")" -} - -// Reachable reports whether other nodes can dial this one. Declared, never inferred. -func (n Node) Reachable() bool { return strings.TrimSpace(n.Endpoint) != "" } - -// Peer is one entry in a node's peer list. -type Peer struct { - Name string - Key string - // Endpoint is empty when this peer cannot be dialled — it must dial us instead. - Endpoint string - // Allowed is what traffic goes down this tunnel. A single address for a direct peer; the - // whole overlay for the hub, which is what makes it the route of last resort. - Allowed string - // Keepalive matters only on the side behind NAT: a node that cannot be dialled has to keep - // the path open from its end, or the peer's first packet arrives at a mapping that has - // already expired. - Keepalive bool - // Why this peer is in the list, for a person reading a generated file and wondering. - Why string -} - -// Graph is every node's peer list. -type Graph map[string][]Peer - -// ErrNoHub means nobody has said which node is the hub. -// -// Its own error rather than an empty graph: a mesh with no hub has no path between sites, and -// answering with "no peers" would look like a working mesh where nothing can reach anything. -var ErrNoHub = errors.New("this mesh has no hub, so there is no path between sites") - -// Compute derives every node's peer list. -// -// Nodes without a key or an address are skipped rather than refused: a node that has enrolled and -// not yet been given a place on the network is an ordinary in-between state, and failing the whole -// graph because one node is half-configured would mean no node gets a network. -func Compute(nodes []Node, overlayCIDR string) (Graph, error) { - var hub *Node - usable := make([]Node, 0, len(nodes)) - for i := range nodes { - n := nodes[i] - if n.Key == "" || n.Address == "" { - continue - } - usable = append(usable, n) - if n.Hub { - hub = &usable[len(usable)-1] - } - } - if len(usable) == 0 { - return Graph{}, nil - } - if hub == nil { - return nil, ErrNoHub - } - if !hub.Reachable() { - return nil, fmt.Errorf( - "%s is the hub and has no endpoint, so nothing can dial it. The hub is the one node "+ - "that must be reachable from wherever the others are", hub.Name) - } - - graph := Graph{} - for _, self := range usable { - var peers []Peer - - // A node may share a site with the hub, and then the hub is one peer rather than two. - // Written before the loop because it changes what that loop may emit: WireGuard takes one - // entry per public key, so a hub appearing twice is a configuration it refuses — and the - // mesh would have produced it silently. The lab found this on the first two machines that - // shared a site with their hub. - hubIsHere := !self.Hub && self.Site != "" && self.Site == hub.Site - - for _, other := range usable { - if other.Name == self.Name || (hubIsHere && other.Name == hub.Name) { - continue - } - // Two nodes at the same site peer directly — but only if one of them can be - // dialled. If neither can, nobody opens the path, and the direct route is more - // specific than the hub's, so it wins and blackholes. That is this design's own - // stated hazard arriving in it: *a more specific route to a dead endpoint - // blackholes; it does not fall back to the general one.* - // - // Found in the lab with two machines at one site behind no reachable address, which - // is the ordinary shape of a home: they were given each other as peers, neither - // could start, and they could not reach each other at all while both reached the hub - // perfectly. - if self.Site != "" && self.Site == other.Site && (self.Reachable() || other.Reachable()) { - peers = append(peers, Peer{ - Name: other.Name, Key: other.Key, - Endpoint: other.Endpoint, - Allowed: other.Address + "/32", - Keepalive: !self.Reachable(), - Why: "at the same site", - }) - } - } - - if !self.Hub { - // Everything else goes through the hub, including a node that roams. AllowedIPs is - // the whole overlay, so this is the route of last resort — and because direct peers - // above are single addresses, they win on specificity without either being ambiguous. - why := "the hub — everything not at this site" - if hubIsHere { - // One entry doing both jobs: the direct path to a machine that happens to be - // here, and the route to everywhere else. Splitting them would need two entries - // for one key, which is the thing being avoided. - why = "the hub, which is also at this site — everything goes here" - } - peers = append(peers, Peer{ - Name: hub.Name, Key: hub.Key, - Endpoint: hub.Endpoint, - Allowed: overlayCIDR, - Keepalive: !self.Reachable(), - Why: why, - }) - } else { - // The hub holds every node that does not share a site with it, because those nodes - // route through it and it must know where to send the replies. Ones it cannot dial - // will dial it. - enrolled := map[string]bool{} - for _, other := range usable { - enrolled[other.Key] = true - if other.Name == self.Name || (self.Site != "" && self.Site == other.Site) { - continue - } - peers = append(peers, Peer{ - Name: other.Name, Key: other.Key, - Endpoint: other.Endpoint, - Allowed: other.Address + "/32", - Why: "routes through this hub", - }) - } - // And every peer of the tunnel it took over that has not enrolled (novox/hq ADR - // 0105): the same key and the same address the found tunnel had for it, so the - // machine behind it cannot tell the tunnel changed hands. No endpoint — it dials in, - // as it always did. Once a node enrols with that key, the node's entry above is the - // peer, and WireGuard takes one entry per key. - for _, c := range self.Carried { - if enrolled[c.Key] { - continue - } - peers = append(peers, Peer{ - Name: CarriedName(c.Key), Key: c.Key, - 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", - }) - } - } - - sort.Slice(peers, func(i, j int) bool { return peers[i].Name < peers[j].Name }) - graph[self.Name] = peers - } - return graph, nil -} diff --git a/internal/overlay/graph_test.go b/internal/overlay/graph_test.go deleted file mode 100644 index 5cfe6ea4..00000000 --- a/internal/overlay/graph_test.go +++ /dev/null @@ -1,342 +0,0 @@ -package overlay - -import ( - "errors" - "strings" - "testing" -) - -const cidr = "10.42.0.0/16" - -func at(name, site, address, endpoint string, hub bool) Node { - return Node{Name: name, Key: "key-" + name, Site: site, Address: address, - Endpoint: endpoint, Hub: hub} -} - -func peersOf(t *testing.T, g Graph, node string) map[string]Peer { - t.Helper() - out := map[string]Peer{} - for _, p := range g[node] { - out[p.Name] = p - } - return out -} - -func TestEveryNodeReachesTheHub(t *testing.T) { - // The property that makes this a network at all. Without it a node has no route to anything - // it does not share a site with. - g, err := Compute([]Node{ - at("anchor", "datacentre", "10.42.0.1", "198.51.100.1:51820", true), - at("laptop", "", "10.42.0.2", "", false), - at("home", "house", "10.42.0.3", "", false), - }, cidr) - if err != nil { - t.Fatal(err) - } - for _, node := range []string{"laptop", "home"} { - hub, ok := peersOf(t, g, node)["anchor"] - if !ok { - t.Fatalf("%s has no route to the hub", node) - } - if hub.Allowed != cidr { - t.Errorf("%s routes %s through the hub; it must be the whole overlay or the hub is "+ - "not a route of last resort", node, hub.Allowed) - } - } -} - -func TestNodesAtOneSitePeerDirectly(t *testing.T) { - // Machines that share a site have a path that does not need the hub, and using it keeps their - // traffic off a link that may be on another continent. - g, err := Compute([]Node{ - at("anchor", "datacentre", "10.42.0.1", "198.51.100.1:51820", true), - at("desk", "house", "10.42.0.2", "192.0.2.2:51820", false), - at("server", "house", "10.42.0.3", "192.0.2.3:51820", false), - }, cidr) - if err != nil { - t.Fatal(err) - } - - direct, ok := peersOf(t, g, "desk")["server"] - if !ok { - t.Fatal("two machines at one site do not peer directly") - } - if direct.Allowed != "10.42.0.3/32" { - t.Errorf("the direct peer allows %s; a single address is what makes it win on "+ - "specificity over the hub's whole-overlay route", direct.Allowed) - } -} - -func TestARoamingNodeGetsExactlyOnePath(t *testing.T) { - // Hub-only, and not as a simplification. WireGuard has no failover: a more specific route to - // a dead endpoint blackholes rather than falling back, so two paths would mean one of them - // silently swallowing traffic. - g, err := Compute([]Node{ - at("anchor", "datacentre", "10.42.0.1", "198.51.100.1:51820", true), - at("laptop", "", "10.42.0.2", "", false), - at("other", "", "10.42.0.3", "", false), - }, cidr) - if err != nil { - t.Fatal(err) - } - if got := len(g["laptop"]); got != 1 { - t.Fatalf("a roaming node has %d peers; it gets exactly one path", got) - } - if g["laptop"][0].Name != "anchor" { - t.Errorf("a roaming node's single path is %s, not the hub", g["laptop"][0].Name) - } -} - -func TestTwoRoamingNodesDoNotPeerWithEachOther(t *testing.T) { - // An empty site is not a site. Nodes that roam have no shared location, and treating "" as - // one would have every roaming machine try to dial every other, none of which can be dialled. - g, err := Compute([]Node{ - at("anchor", "datacentre", "10.42.0.1", "198.51.100.1:51820", true), - at("laptop", "", "10.42.0.2", "", false), - at("phone", "", "10.42.0.3", "", false), - }, cidr) - if err != nil { - t.Fatal(err) - } - if _, ok := peersOf(t, g, "laptop")["phone"]; ok { - t.Error("two nodes with no site peered as though they shared one") - } -} - -func TestOnlyTheSideThatCannotBeDialledKeepsThePathOpen(t *testing.T) { - // Keepalive matters exactly once: on the node behind NAT. Without it the peer's first packet - // arrives at a mapping that has already expired. On the reachable side it is pointless - // traffic for ever. - g, err := Compute([]Node{ - at("anchor", "datacentre", "10.42.0.1", "198.51.100.1:51820", true), - at("laptop", "", "10.42.0.2", "", false), - }, cidr) - if err != nil { - t.Fatal(err) - } - if !peersOf(t, g, "laptop")["anchor"].Keepalive { - t.Error("a node that cannot be dialled does not keep its path open") - } - if peersOf(t, g, "anchor")["laptop"].Keepalive { - t.Error("a reachable node sends keepalives it does not need") - } - - // And between two direct peers at one site, where whether to keep the path open depends on - // which end you are. Checked here because the hub's own peer list happens not to set the - // field at all, so asserting only against the hub tests an absence rather than the rule. - same, err := Compute([]Node{ - at("anchor", "datacentre", "10.42.0.1", "198.51.100.1:51820", true), - at("desk", "house", "10.42.0.2", "192.0.2.2:51820", false), - at("server", "house", "10.42.0.3", "", false), - }, cidr) - if err != nil { - t.Fatal(err) - } - if !peersOf(t, same, "server")["desk"].Keepalive { - t.Error("the peer that cannot be dialled does not keep the path open") - } - if peersOf(t, same, "desk")["server"].Keepalive { - t.Error("the peer that can be dialled sends keepalives it does not need") - } -} - -func TestTheHubKnowsEveryoneItRoutesFor(t *testing.T) { - // The replies have to get back. A hub that does not hold a peer cannot answer it. - g, err := Compute([]Node{ - at("anchor", "datacentre", "10.42.0.1", "198.51.100.1:51820", true), - at("laptop", "", "10.42.0.2", "", false), - at("home", "house", "10.42.0.3", "", false), - }, cidr) - if err != nil { - t.Fatal(err) - } - hub := peersOf(t, g, "anchor") - for _, want := range []string{"laptop", "home"} { - if _, ok := hub[want]; !ok { - t.Errorf("the hub does not hold %s, so replies to it have nowhere to go", want) - } - } -} - -func TestAMeshWithNoHubIsRefused(t *testing.T) { - // Not an empty graph. A mesh with no hub has no path between sites, and answering "no peers" - // would look like a working network in which nothing can reach anything — which is how the - // old arrangement failed, silently, when nobody knew the address convention. - _, err := Compute([]Node{ - at("a", "", "10.42.0.1", "", false), - at("b", "", "10.42.0.2", "", false), - }, cidr) - if !errors.Is(err, ErrNoHub) { - t.Fatalf("a mesh with no hub gave %v", err) - } -} - -func TestAnUnreachableHubIsRefused(t *testing.T) { - // The hub is the one node that must be dialable from wherever the others are. Left - // unchecked, every node would be given a peer it can never reach and the mesh would look - // configured and be silent. - _, err := Compute([]Node{ - at("anchor", "datacentre", "10.42.0.1", "", true), - at("laptop", "", "10.42.0.2", "", false), - }, cidr) - if err == nil { - t.Fatal("a hub with no endpoint was accepted") - } - if !strings.Contains(err.Error(), "must be reachable") { - t.Errorf("the refusal does not say why: %v", err) - } -} - -func TestANodeWithNoPlaceYetIsSkippedRatherThanFatal(t *testing.T) { - // A node that has enrolled and not yet been given an address is an ordinary in-between state. - // Failing the whole graph over it would mean no node gets a network because one is half done. - g, err := Compute([]Node{ - at("anchor", "datacentre", "10.42.0.1", "198.51.100.1:51820", true), - at("laptop", "", "10.42.0.2", "", false), - {Name: "newcomer", Key: "", Address: ""}, - }, cidr) - if err != nil { - t.Fatal(err) - } - if _, ok := g["newcomer"]; ok { - t.Error("a node with no key or address was given a peer list") - } - if _, ok := peersOf(t, g, "laptop")["newcomer"]; ok { - t.Error("a node with no key was put in somebody's peer list") - } -} - -func TestNobodyPeersWithThemselves(t *testing.T) { - g, err := Compute([]Node{ - at("anchor", "house", "10.42.0.1", "198.51.100.1:51820", true), - at("desk", "house", "10.42.0.2", "192.0.2.2:51820", false), - }, cidr) - if err != nil { - t.Fatal(err) - } - for node, peers := range g { - for _, p := range peers { - if p.Name == node { - t.Errorf("%s peers with itself", node) - } - } - } -} - -func TestAHubAtYourOwnSiteAppearsOnceNotTwice(t *testing.T) { - // WireGuard takes one entry per public key. A hub that shares a site with a spoke was being - // emitted twice — once as a direct peer and once as the route of last resort — producing a - // configuration the interface refuses, from a mesh that thought it had succeeded. - // - // Found in the lab on the first two machines that shared a site with their hub, which is the - // ordinary case for a small mesh and was in none of the tests above. - g, err := Compute([]Node{ - at("anchor", "lab", "10.42.0.1", "192.0.2.10:51820", true), - at("laptop", "lab", "10.42.0.2", "", false), - }, cidr) - if err != nil { - t.Fatal(err) - } - - seen := map[string]int{} - for _, p := range g["laptop"] { - seen[p.Key]++ - } - for key, count := range seen { - if count > 1 { - t.Errorf("the key %s appears %d times; WireGuard takes one entry per key", key, count) - } - } - - if len(g["laptop"]) != 1 { - t.Fatalf("laptop has %d peers; the hub at its own site is one peer, not two", len(g["laptop"])) - } - // And that single entry has to carry everything, or the node keeps a direct path to the hub - // and loses its route to everywhere else. - if got := g["laptop"][0].Allowed; got != cidr { - t.Errorf("the single entry allows %s; it is both the direct path and the route of last "+ - "resort, so it carries the whole overlay", got) - } -} - -func TestTwoUnreachableNodesAtOneSiteDoNotPeerDirectly(t *testing.T) { - // Nobody would open the path, and the direct route is more specific than the hub's — so it - // wins and blackholes. Both nodes would reach the hub perfectly and be unable to reach each - // other, which is the worst arrangement available: it looks configured and is not. - // - // This is the design's own warning arriving in its implementation, and the lab found it with - // two machines at one site behind no reachable address — the ordinary shape of a house. - g, err := Compute([]Node{ - at("anchor", "datacentre", "10.42.0.1", "198.51.100.1:51820", true), - at("desk", "house", "10.42.0.2", "", false), - at("server", "house", "10.42.0.3", "", false), - }, cidr) - if err != nil { - t.Fatal(err) - } - - if _, ok := peersOf(t, g, "desk")["server"]; ok { - t.Error("two nodes that neither can be dialled were peered directly; neither could open " + - "the path and the route is more specific than the hub's, so it blackholes") - } - // And they must still be able to reach each other — through the hub. - if hub := peersOf(t, g, "desk")["anchor"]; hub.Allowed != cidr { - t.Errorf("desk's route to everything else allows %s", hub.Allowed) - } -} - -func TestOneReachableNodeIsEnoughToPeerDirectly(t *testing.T) { - // The other side of it: if either end can be dialled, the path can be opened, and using it - // keeps their traffic off a hub that may be on another continent. - g, err := Compute([]Node{ - at("anchor", "datacentre", "10.42.0.1", "198.51.100.1:51820", true), - at("desk", "house", "10.42.0.2", "192.0.2.2:51820", false), - at("server", "house", "10.42.0.3", "", false), - }, cidr) - if err != nil { - t.Fatal(err) - } - if _, ok := peersOf(t, g, "server")["desk"]; !ok { - t.Error("a node did not peer with a reachable neighbour at its own site") - } - if _, ok := peersOf(t, g, "desk")["server"]; !ok { - t.Error("the reachable node did not hold its unreachable neighbour, so replies have " + - "nowhere to go") - } -} - -// novox/hq ADR 0105: a hub that took over the predecessor's tunnel carries every peer that tunnel -// had, under the key and at the address the peer knows, until a node enrols with that key. -func TestTheHubCarriesTheTunnelsPeersUntilTheyEnrol(t *testing.T) { - hub := at("anchor", "hosting", "192.0.2.1", "198.51.100.1:51900", true) - hub.Carried = []Carried{ - {Key: "key-home", Address: "192.0.2.2"}, - {Key: "key-workstation", Address: "192.0.2.3"}, - } - // The machine behind key-home enrolled: it is a node now, at the address it kept. - home := at("home", "house", "192.0.2.2", "", false) - home.Key = "key-home" - - g, err := Compute([]Node{hub, home}, "192.0.2.0/24") - if err != nil { - t.Fatal(err) - } - peers := peersOf(t, g, "anchor") - carried, ok := peers[CarriedName("key-workstation")] - if !ok { - t.Fatalf("the hub does not carry the peer that has not enrolled: %+v", g["anchor"]) - } - if carried.Allowed != "192.0.2.3/32" || carried.Endpoint != "" || carried.Key != "key-workstation" { - t.Errorf("a carried peer is not the tunnel's own entry — same key, its one address, no endpoint: %+v", carried) - } - if _, twice := peers[CarriedName("key-home")]; twice { - t.Error("a peer that enrolled is carried as well as listed as a node: WireGuard takes one entry per key") - } - if node, ok := peers["home"]; !ok || node.Key != "key-home" || node.Allowed != "192.0.2.2/32" { - t.Errorf("the enrolled peer is not the node it became: %+v", node) - } - // Carried peers are the hub's business only: a spoke routes everything through the hub. - if _, leaked := peersOf(t, g, "home")[CarriedName("key-workstation")]; leaked { - t.Error("a carried peer appeared in a spoke's peer list") - } -} diff --git a/internal/overlay/names.go b/internal/overlay/names.go deleted file mode 100644 index 870b81dd..00000000 --- a/internal/overlay/names.go +++ /dev/null @@ -1,39 +0,0 @@ -package overlay - -import ( - "os" - "regexp" - "strings" -) - -// Names are how one node reaches another by name rather than by address. -// -// novox/hq 08-connectivity: what the host receives is the resolver's configuration, as files, -// listing every peer's internal name and overlay address. Computed centrally for the same reason -// the peer graph is — it needs every node at once. - -// SuffixVar lets a mesh choose what its internal names end in. -const SuffixVar = "MESH_INTERNAL_SUFFIX" - -// DefaultSuffix is `.internal`, which IANA reserved for exactly this in 2024. A name under it can -// never collide with a public one, so an internal name that leaks into a public resolver fails -// rather than reaching a stranger's machine. -const DefaultSuffix = "internal" - -// Suffix is what internal names end in. -func Suffix() string { - if v := strings.TrimSpace(os.Getenv(SuffixVar)); v != "" { - return strings.TrimPrefix(v, ".") - } - return DefaultSuffix -} - -// nodeName is what a node may be called, so that it can also be a hostname. -var nodeName = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$`) - -// InternalName is a node's name inside the mesh. -func InternalName(node string) string { return node + "." + Suffix() } - -// **What remains of a larger file.** The rest wrote /etc/hosts, which the controller no longer -// writes at all (novox/hq ADR 0199): the mesh's resolver answers these names. The naming stays here, because several things compose a node's internal name and one of them -// writing the suffix differently would be a name nothing answers to. diff --git a/internal/overlay/opens_test.go b/internal/overlay/opens_test.go deleted file mode 100644 index 06f4c93d..00000000 --- a/internal/overlay/opens_test.go +++ /dev/null @@ -1,113 +0,0 @@ -package overlay - -import ( - "strings" - "testing" - - "github.com/novox/mesh-controller/internal/catalogue" -) - -func networkOf(t *testing.T, nodes []Node) *Generator { - t.Helper() - made, err := From(nodes, "10.42.0.0/16", "/var/lib/mesh-host/overlay.key") - if err != nil { - t.Fatal(err) - } - return made -} - -// A hub is dialled by every node at other sites, and needs its port open. A machine that is not a -// hub dials out and needs nothing open at all. -// -// They are the same module, which is why a static field in a manifest cannot say it — and the -// machine it gets wrong is the one facing the public internet, which is the machine that most -// needs filtering. -func TestOnlyAMachineThatCanBeDialledOpensTheOverlaysPort(t *testing.T) { - network := networkOf(t, []Node{ - {Name: "anchor", Key: "a", Endpoint: "198.51.100.10:51820", Site: "one", Hub: true}, - {Name: "laptop", Key: "b", Site: "one"}, - }) - - opens, err := network.Listens("anchor") - if err != nil { - t.Fatal(err) - } - if len(opens) != 1 { - t.Fatalf("the machine every other one dials opens %d ports", len(opens)) - } - if opens[0].Port != 51820 || opens[0].At() != "udp" { - t.Fatalf("the port is not the one the interface listens on: %+v", opens[0]) - } - // From everywhere, and deliberately: a node at another site is not on the private network - // until this port lets it on, so restricting it to the mesh would be a rule that can never be - // satisfied by the thing it exists for. - if opens[0].From != catalogue.FromEverywhere { - t.Fatalf("the way onto the private network is restricted to the private network: %+v", opens[0]) - } - if opens[0].Why == "" { - t.Fatal("a port is opened and nothing says why, which is what makes a rule set unreadable") - } - - // And the machine nothing dials opens nothing. Not harmless to get wrong: a rule with no - // reason is one somebody later has to work out the reason for. - quiet, err := network.Listens("laptop") - if err != nil { - t.Fatal(err) - } - if len(quiet) != 0 { - t.Fatalf("a machine nothing dials opened %d port(s)", len(quiet)) - } -} - -// The port comes from the endpoint, which is where the interface takes its ListenPort from. One -// source, so a rule set cannot open a port the interface is not on. -func TestTheOpenedPortIsTheOneTheInterfaceListensOn(t *testing.T) { - network := networkOf(t, []Node{ - {Name: "anchor", Key: "a", Endpoint: "198.51.100.10:60000", Site: "one", Hub: true}, - }) - opens, err := network.Listens("anchor") - if err != nil { - t.Fatal(err) - } - if len(opens) != 1 || opens[0].Port != 60000 { - t.Fatalf("the rule set would open a port the interface is not on: %+v", opens) - } - - written, err := Declaration(Node{Name: "anchor", Key: "a", Address: "10.42.0.1", - Endpoint: "198.51.100.10:60000", Hub: true}, nil, "/k") - if err != nil { - t.Fatal(err) - } - if !strings.Contains(string(written), "ListenPort = 60000") { - t.Fatalf("the interface and the rule set disagree about the port:\n%s", written) - } -} - -// An endpoint with no port is refused rather than treated as a machine that opens nothing. -// -// A generator that cannot say what a machine opens is not one that says it opens nothing, and -// closing a port on the evidence of a failure to look is how a machine is severed by a fault -// somewhere else entirely. -func TestAnEndpointWithNoPortIsRefusedRatherThanTakenAsSilence(t *testing.T) { - network := networkOf(t, []Node{ - {Name: "anchor", Key: "a", Endpoint: "198.51.100.10", Site: "one", Hub: true}, - }) - if _, err := network.Listens("anchor"); err == nil { - t.Fatal("a machine whose port could not be read was treated as opening nothing") - } -} - -// A machine the network has never heard of opens nothing, and that is an answer rather than an -// error: a node assigned the module before it is placed is in exactly that state. -func TestAMachineNotOnTheNetworkOpensNothing(t *testing.T) { - network := networkOf(t, []Node{ - {Name: "anchor", Key: "a", Endpoint: "198.51.100.10:51820", Site: "one", Hub: true}, - }) - opens, err := network.Listens("a-stranger") - if err != nil { - t.Fatal(err) - } - if len(opens) != 0 { - t.Fatalf("a machine not on the network opened %d port(s)", len(opens)) - } -} diff --git a/internal/overlay/seat_test.go b/internal/overlay/seat_test.go deleted file mode 100644 index 33512e70..00000000 --- a/internal/overlay/seat_test.go +++ /dev/null @@ -1,29 +0,0 @@ -package overlay - -import ( - "encoding/json" - "testing" - - "github.com/novox/mesh-controller/internal/catalogue" -) - -// The private network's claim is a seat the mesh defines (novox/hq ADR 0110). -// -// Checked here because this is where the manifest is composed: it ships with the control plane and -// has no module.json for a test reading the catalogue to find. Parsed with the real parser, so a -// set that forgot this seat refuses the control plane's own module here rather than on a machine. -func TestThePrivateNetworksClaimIsASeatTheMeshDefines(t *testing.T) { - for _, composed := range []map[string]any{Manifest()} { - raw, err := json.Marshal(composed) - if err != nil { - t.Fatal(err) - } - if _, err := catalogue.ParseManifest(raw); err != nil { - t.Errorf("%s, composed by the control plane, is refused: %v", composed["module"], err) - } - } - seat, defined := catalogue.SeatNamed(TheNetwork) - if !defined || seat.Scope != catalogue.ScopeNode { - t.Fatalf("%s is not a node seat the mesh defines: %+v", TheNetwork, seat) - } -}