diff --git a/modules/mesh-connectivity-contracts/go/seats/theprivatenetwork/contract.go b/modules/mesh-connectivity-contracts/go/seats/theprivatenetwork/contract.go new file mode 100644 index 0000000..edf8a36 --- /dev/null +++ b/modules/mesh-connectivity-contracts/go/seats/theprivatenetwork/contract.go @@ -0,0 +1,10 @@ +// Code generated by seatgen from the seat definitions; DO NOT EDIT. + +// Package theprivatenetwork is the contract of the seat "the-private-network", defined whole (seats/the-private-network/seat.json): The seat of the private network (ADR 0110): the one network the mesh runs over, which a machine can have only one of; the mesh provides its holder itself and the provision carries no fields. +package theprivatenetwork + +// Seat is the seat's name. +const Seat = "the-private-network" + +// PrivateNetworkForm is the form of the provision private-network. +type PrivateNetworkForm = map[string]any diff --git a/modules/mesh-connectivity-contracts/go/seats/theprivatenetwork/go.mod b/modules/mesh-connectivity-contracts/go/seats/theprivatenetwork/go.mod new file mode 100644 index 0000000..f0aedd2 --- /dev/null +++ b/modules/mesh-connectivity-contracts/go/seats/theprivatenetwork/go.mod @@ -0,0 +1,3 @@ +module git.novox.be/novox/mesh-networking/modules/mesh-connectivity-contracts/go/seats/theprivatenetwork + +go 1.26.0 diff --git a/modules/mesh-connectivity-contracts/module.json b/modules/mesh-connectivity-contracts/module.json new file mode 100644 index 0000000..ceea17e --- /dev/null +++ b/modules/mesh-connectivity-contracts/module.json @@ -0,0 +1,29 @@ +{ + "module": "mesh-connectivity-contracts", + "version": "1", + "build": { + "artifacts": [ + { + "name": "go-theprivatenetwork", + "kind": "package", + "language": "go", + "paths": [ + "go/seats/theprivatenetwork" + ], + "series": "0.1", + "generated-from": "." + }, + { + "name": "ts-theprivatenetwork", + "kind": "package", + "language": "typescript", + "paths": [ + "ts/seats/theprivatenetwork" + ], + "series": "0.1", + "generated-from": "." + } + ] + }, + "resources": [] +} diff --git a/modules/mesh-connectivity-contracts/seats/mesh-dns-resolver/seat.json b/modules/mesh-connectivity-contracts/seats/mesh-dns-resolver/seat.json new file mode 100644 index 0000000..0868271 --- /dev/null +++ b/modules/mesh-connectivity-contracts/seats/mesh-dns-resolver/seat.json @@ -0,0 +1,10 @@ +{ + "format": 1, + "seat": "mesh-dns-resolver", + "scope": "mesh", + "grain": "seat-list", + "domain": "Connectivity", + "decision": "novox/hq ADR 0194, ADR 0223", + "delivers": "wildcard-resolution", + "replicated": true +} diff --git a/modules/mesh-connectivity-contracts/seats/node-hostname/seat.json b/modules/mesh-connectivity-contracts/seats/node-hostname/seat.json new file mode 100644 index 0000000..327c4d5 --- /dev/null +++ b/modules/mesh-connectivity-contracts/seats/node-hostname/seat.json @@ -0,0 +1,74 @@ +{ + "format": 1, + "seat": "node-hostname", + "scope": "node", + "grain": "seat-list", + "domain": "Connectivity", + "decision": "novox/hq ADR 0199, ADR 0223", + "verbs": [ + { + "name": "entries", + "description": "Every line of this machine's /etc/hosts, each marked whose it is: the operator's, or the block of the module or tool that writes it.", + "replaces": [ + "cat /etc/hosts", + "getent hosts" + ], + "input": { + "properties": {}, + "type": "object" + } + }, + { + "name": "add", + "description": "Add one address and its names to the operator's lines of this machine's /etc/hosts — a name for this machine's own programs, not the mesh's.", + "replaces": [ + "edit /etc/hosts", + "HOSTALIASES" + ], + "input": { + "properties": { + "address": { + "description": "the IPv4 or IPv6 address", + "type": "string" + }, + "names": { + "description": "the names for it, separated by spaces", + "type": "string" + } + }, + "required": [ + "address", + "names" + ], + "type": "object" + } + }, + { + "name": "remove", + "description": "Remove one name, or every line of one address, from the operator's lines of this machine's /etc/hosts. A line a module writes is refused, naming the module.", + "replaces": [ + "sed -i /etc/hosts" + ], + "input": { + "properties": { + "name": { + "description": "a host name, or an address to remove every line of", + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + } + } + ], + "paths": [ + { + "path": "/etc/hosts" + }, + { + "path": "/etc/hostname" + } + ] +} diff --git a/modules/mesh-connectivity-contracts/seats/node-intrusion-prevention/seat.json b/modules/mesh-connectivity-contracts/seats/node-intrusion-prevention/seat.json new file mode 100644 index 0000000..b2bc355 --- /dev/null +++ b/modules/mesh-connectivity-contracts/seats/node-intrusion-prevention/seat.json @@ -0,0 +1,90 @@ +{ + "format": 1, + "seat": "node-intrusion-prevention", + "scope": "node", + "grain": "seat-list", + "domain": "Connectivity", + "decision": "novox/hq ADR 0121", + "verbs": [ + { + "name": "status", + "description": "Every jail on this machine with how many it is watching and holding now, and the totals since the jail started; one jail's detail when named.", + "replaces": [ + "fail2ban-client status" + ], + "input": { + "properties": { + "jail": { + "description": "one jail (optional)", + "type": "string" + } + }, + "type": "object" + } + }, + { + "name": "banned", + "description": "Every address banned on this machine right now, with the jail that holds it and when the ban ends.", + "replaces": [ + "fail2ban-client banned" + ], + "input": { + "properties": { + "jail": { + "description": "one jail (optional)", + "type": "string" + } + }, + "type": "object" + } + }, + { + "name": "ban", + "description": "Ban one address in one jail now, for the jail's ban time — an operator's act on the live ban list, which the mesh never writes itself.", + "replaces": [ + "fail2ban-client set banip" + ], + "input": { + "properties": { + "ip": { + "description": "the address", + "type": "string" + }, + "jail": { + "description": "the jail to hold it", + "type": "string" + } + }, + "required": [ + "ip", + "jail" + ], + "type": "object" + } + }, + { + "name": "unban", + "description": "Let one address go, from one jail or from every jail when none is named.", + "replaces": [ + "fail2ban-client unban", + "fail2ban-client set unbanip" + ], + "input": { + "properties": { + "ip": { + "description": "the address", + "type": "string" + }, + "jail": { + "description": "one jail (optional)", + "type": "string" + } + }, + "required": [ + "ip" + ], + "type": "object" + } + } + ] +} diff --git a/modules/mesh-connectivity-contracts/seats/node-packet-filter/seat.json b/modules/mesh-connectivity-contracts/seats/node-packet-filter/seat.json new file mode 100644 index 0000000..005b50c --- /dev/null +++ b/modules/mesh-connectivity-contracts/seats/node-packet-filter/seat.json @@ -0,0 +1,63 @@ +{ + "format": 1, + "seat": "node-packet-filter", + "scope": "node", + "grain": "seat-list", + "domain": "Connectivity", + "decision": "novox/hq ADR 0121", + "verbs": [ + { + "name": "rules", + "description": "The packet filter as this machine enforces it now: the nftables ruleset and, where the tool exists, the legacy filter's listings. Narrowed to one table or chain when asked.", + "replaces": [ + "nft list ruleset", + "iptables -L", + "iptables-save" + ], + "input": { + "properties": { + "chain": { + "description": "one chain of that table (optional)", + "type": "string" + }, + "table": { + "description": "one nftables table, as `family name` (optional)", + "type": "string" + } + }, + "type": "object" + } + }, + { + "name": "reload", + "description": "Load the mesh's own filter again from the file the mesh writes, and answer with the mesh's table as loaded.", + "replaces": [ + "nft -f" + ], + "input": { + "properties": {}, + "type": "object" + } + }, + { + "name": "remove", + "description": "Remove one rule set the mesh did not write, named exactly as the host reports it (novox/hq ADR 0168) — `chain X (iptables-legacy)` or `table ip6 filter, chain DOCKER-USER`. Refuses the mesh's tables, the runtime's own chains, a built-in chain and an active found firewall's chains. An operator's act, by name, never a flush.", + "replaces": [ + "nft delete", + "iptables -X" + ], + "input": { + "properties": { + "where": { + "description": "the rule set, as `node show` lists it", + "type": "string" + } + }, + "required": [ + "where" + ], + "type": "object" + } + } + ] +} diff --git a/modules/mesh-connectivity-contracts/seats/node-resolver/seat.json b/modules/mesh-connectivity-contracts/seats/node-resolver/seat.json new file mode 100644 index 0000000..be8a91f --- /dev/null +++ b/modules/mesh-connectivity-contracts/seats/node-resolver/seat.json @@ -0,0 +1,78 @@ +{ + "format": 1, + "seat": "node-resolver", + "scope": "node", + "grain": "seat-list", + "domain": "Connectivity", + "decision": "novox/hq ADR 0247", + "verbs": [ + { + "name": "routes", + "description": "What this machine's own resolver sends where: the mesh's resolvers, which answer every name not routed elsewhere, and each link given servers of its own with the domains routed to them. Also the resolver file's outside writes it kept, newest first: when, who wrote it as far as the file says, whether a module took it, and when the resolver's own file was put back.", + "replaces": [ + "resolvectl status", + "resolvectl dns", + "resolvectl domain" + ], + "input": { + "properties": {}, + "type": "object" + } + }, + { + "name": "route", + "description": "Send these domains, and every name under them, to these servers over this link — and only them: the link is never the machine's default route for names, and the mesh's own domain is refused. Replaces whatever the link was given before. A link that goes away takes its route with it.", + "replaces": [ + "resolvectl dns", + "resolvectl domain", + "resolvectl default-route" + ], + "input": { + "properties": { + "domains": { + "description": "the domains to route there, separated by spaces or commas", + "type": "string" + }, + "link": { + "description": "the network link the servers are reached over, by name (a VPN's tunnel interface)", + "type": "string" + }, + "servers": { + "description": "the servers' addresses, separated by spaces or commas", + "type": "string" + } + }, + "required": [ + "link", + "domains", + "servers" + ], + "type": "object" + } + }, + { + "name": "unroute", + "description": "Take one link's route away: its domains go back to the mesh's resolvers. Nothing changes when the link has none.", + "replaces": [ + "resolvectl revert" + ], + "input": { + "properties": { + "link": { + "description": "the network link, by name", + "type": "string" + } + }, + "required": [ + "link" + ], + "type": "object" + } + } + ], + "paths": [ + { + "path": "/etc/resolv.conf" + } + ] +} diff --git a/modules/mesh-connectivity-contracts/seats/node-uplink/seat.json b/modules/mesh-connectivity-contracts/seats/node-uplink/seat.json new file mode 100644 index 0000000..cd59ee5 --- /dev/null +++ b/modules/mesh-connectivity-contracts/seats/node-uplink/seat.json @@ -0,0 +1,34 @@ +{ + "format": 1, + "seat": "node-uplink", + "scope": "node", + "grain": "seat-list", + "domain": "Connectivity", + "decision": "novox/hq ADR 0117", + "verbs": [ + { + "name": "resolvers", + "description": "The machine's resolver file as it is now: the resolvers, search domains and options it lists, whether it is the file the mesh declares, and when it is not, who wrote it as far as the machine shows — its header, a backup a VPN client left beside it, a link in its place, a known writer running.", + "optional": true, + "input": { + "properties": {}, + "type": "object" + } + }, + { + "name": "links", + "description": "Every network link on the machine: its state, its addresses, whether the default route leaves through it, and the resolvers and search domains the network manager knows for it — a VPN's tunnel included.", + "optional": true, + "input": { + "properties": {}, + "type": "object" + } + } + ], + "paths": [ + { + "path": "/etc/resolv.conf", + "steps-back-for": "node-resolver" + } + ] +} diff --git a/modules/mesh-connectivity-contracts/seats/the-private-network/seat.json b/modules/mesh-connectivity-contracts/seats/the-private-network/seat.json new file mode 100644 index 0000000..bb27936 --- /dev/null +++ b/modules/mesh-connectivity-contracts/seats/the-private-network/seat.json @@ -0,0 +1,19 @@ +{ + "format": 1, + "seat": "the-private-network", + "scope": "node", + "grain": "whole", + "domain": "Connectivity", + "decision": "novox/hq ADR 0110, ADR 0634, ADR 0635", + "description": "The seat of the private network (ADR 0110): the one network the mesh runs over, which a machine can have only one of; the mesh provides its holder itself and the provision carries no fields.", + "verbs": [], + "provisions": [ + { + "name": "private-network", + "form": { + "type": "object", + "description": "the provision has no fields: it is the machine's reach to the others" + } + } + ] +} diff --git a/modules/mesh-connectivity-contracts/ts/seats/theprivatenetwork/package-lock.json b/modules/mesh-connectivity-contracts/ts/seats/theprivatenetwork/package-lock.json new file mode 100644 index 0000000..c8b64fa --- /dev/null +++ b/modules/mesh-connectivity-contracts/ts/seats/theprivatenetwork/package-lock.json @@ -0,0 +1,12 @@ +{ + "lockfileVersion": 3, + "name": "@novox/mesh-connectivity-contracts-the-private-network", + "packages": { + "": { + "name": "@novox/mesh-connectivity-contracts-the-private-network", + "version": "0.1.0" + } + }, + "requires": true, + "version": "0.1.0" +} diff --git a/modules/mesh-connectivity-contracts/ts/seats/theprivatenetwork/package.json b/modules/mesh-connectivity-contracts/ts/seats/theprivatenetwork/package.json new file mode 100644 index 0000000..2425c77 --- /dev/null +++ b/modules/mesh-connectivity-contracts/ts/seats/theprivatenetwork/package.json @@ -0,0 +1,13 @@ +{ + "description": "The contract of the seat the-private-network, generated from its definition.", + "exports": { + ".": "./src/index.ts" + }, + "files": [ + "src" + ], + "name": "@novox/mesh-connectivity-contracts-the-private-network", + "type": "module", + "types": "./src/index.ts", + "version": "0.1.0" +} diff --git a/modules/mesh-connectivity-contracts/ts/seats/theprivatenetwork/src/index.ts b/modules/mesh-connectivity-contracts/ts/seats/theprivatenetwork/src/index.ts new file mode 100644 index 0000000..d04f2cd --- /dev/null +++ b/modules/mesh-connectivity-contracts/ts/seats/theprivatenetwork/src/index.ts @@ -0,0 +1,9 @@ +// Code generated by seatgen from the seat definitions; DO NOT EDIT. + +// The contract of the seat "the-private-network", defined whole (seats/the-private-network/seat.json): The seat of the private network (ADR 0110): the one network the mesh runs over, which a machine can have only one of; the mesh provides its holder itself and the provision carries no fields. + +export const seat = "the-private-network"; + +/** is the form of the provision private-network. */ +export type PrivateNetworkForm = Record; + diff --git a/modules/mesh-connectivity-contracts/ts/seats/theprivatenetwork/tsconfig.json b/modules/mesh-connectivity-contracts/ts/seats/theprivatenetwork/tsconfig.json new file mode 100644 index 0000000..60274c6 --- /dev/null +++ b/modules/mesh-connectivity-contracts/ts/seats/theprivatenetwork/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "noEmit": true, + "strict": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src/**/*.ts"] +} diff --git a/modules/mesh-wireguard/go/go.mod b/modules/mesh-wireguard/go/go.mod new file mode 100644 index 0000000..4b97752 --- /dev/null +++ b/modules/mesh-wireguard/go/go.mod @@ -0,0 +1,8 @@ +module git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go + +go 1.26.0 + +require ( + git.novox.be/novox/mesh-networking/modules/mesh-connectivity-contracts/go/seats/theprivatenetwork v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000 +) diff --git a/modules/mesh-wireguard/go/go.sum b/modules/mesh-wireguard/go/go.sum new file mode 100644 index 0000000..e69de29 diff --git a/modules/mesh-wireguard/go/overlay/declaration.go b/modules/mesh-wireguard/go/overlay/declaration.go new file mode 100644 index 0000000..b248bdd --- /dev/null +++ b/modules/mesh-wireguard/go/overlay/declaration.go @@ -0,0 +1,187 @@ +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. + +// 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/modules/mesh-wireguard/go/overlay/declaration_test.go b/modules/mesh-wireguard/go/overlay/declaration_test.go new file mode 100644 index 0000000..af54dcd --- /dev/null +++ b/modules/mesh-wireguard/go/overlay/declaration_test.go @@ -0,0 +1,305 @@ +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) + } +} + +// A path tuned to a smaller MTU (1380 here) survives the takeover: the mesh interface comes up with the same MTU, or +// transfers hang silently (novox/hq: a taken tunnel carries its MTU). A tunnel that set none gets no MTU line. +func TestATakenTunnelCarriesItsMTU(t *testing.T) { + for _, c := range []struct { + mtu int + want string + }{{1380, "MTU = 1380"}, {0, ""}} { + config, _ := declarationFor(t, Node{ + Name: "shanks", Key: "SPOKE", Address: "10.10.0.3", + TakesOver: &TakeOver{Interface: "wg0", Port: 51820, MTU: c.mtu}, + }, nil) + if c.want != "" && !strings.Contains(config, c.want) || c.want == "" && strings.Contains(config, "MTU") { + t.Errorf("a tunnel of MTU %d gives:\n%s", c.mtu, config) + } + } +} diff --git a/modules/mesh-wireguard/go/overlay/doc.go b/modules/mesh-wireguard/go/overlay/doc.go new file mode 100644 index 0000000..019d709 --- /dev/null +++ b/modules/mesh-wireguard/go/overlay/doc.go @@ -0,0 +1,5 @@ +// Package overlay is the private network: its names, its manifest, the peer graph and the generator of each node's +// network configuration. It is the own package of the module mesh-wireguard, which holds the seat the-private-network +// (novox/hq ADR 0636 §6); the node-engine imports it to compose the module for a node, and the layout of the value the +// seat provides is the seat's contract in mesh-connectivity-contracts (ADR 0634 §1). +package overlay diff --git a/modules/mesh-wireguard/go/overlay/generator.go b/modules/mesh-wireguard/go/overlay/generator.go new file mode 100644 index 0000000..eb56a43 --- /dev/null +++ b/modules/mesh-wireguard/go/overlay/generator.go @@ -0,0 +1,151 @@ +package overlay + +import ( + "encoding/json" + "fmt" + "strconv" + + "git.novox.be/novox/mesh-sdk/go/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. + +// **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 } + +// 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/modules/mesh-wireguard/go/overlay/generator_test.go b/modules/mesh-wireguard/go/overlay/generator_test.go new file mode 100644 index 0000000..569322b --- /dev/null +++ b/modules/mesh-wireguard/go/overlay/generator_test.go @@ -0,0 +1,30 @@ +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/modules/mesh-wireguard/go/overlay/graph.go b/modules/mesh-wireguard/go/overlay/graph.go new file mode 100644 index 0000000..4be1a43 --- /dev/null +++ b/modules/mesh-wireguard/go/overlay/graph.go @@ -0,0 +1,231 @@ +// 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/modules/mesh-wireguard/go/overlay/graph_test.go b/modules/mesh-wireguard/go/overlay/graph_test.go new file mode 100644 index 0000000..eb8c4e1 --- /dev/null +++ b/modules/mesh-wireguard/go/overlay/graph_test.go @@ -0,0 +1,333 @@ +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 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 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) + } + // And nobody peers with themselves, here or in a site of two dialable nodes. + other, 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 _, graph := range []Graph{g, other} { + for node, peers := range graph { + for _, p := range peers { + if p.Name == node { + t.Errorf("%s peers with itself", node) + } + } + } + } +} + +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") + } +} + +// A mesh whose hub cannot carry the paths between sites is refused, never answered as an empty graph: with no hub there +// is no path between sites, and a hub with no endpoint is a peer every node is given and none can reach. Either would +// look configured and be silent, which is how the old arrangement failed when nobody knew the address convention. +func TestAHubThatCannotCarryThePathsIsRefused(t *testing.T) { + for _, c := range []struct { + name string + nodes []Node + says func(error) bool + }{ + {"no hub", []Node{at("a", "", "10.42.0.1", "", false), at("b", "", "10.42.0.2", "", false)}, + func(err error) bool { return errors.Is(err, ErrNoHub) }}, + {"a hub with no endpoint", []Node{at("anchor", "datacentre", "10.42.0.1", "", true), at("laptop", "", "10.42.0.2", "", false)}, + func(err error) bool { return err != nil && strings.Contains(err.Error(), "must be reachable") }}, + } { + if _, err := Compute(c.nodes, cidr); !c.says(err) { + t.Errorf("%s: %v", c.name, err) + } + } +} diff --git a/modules/mesh-wireguard/go/overlay/names.go b/modules/mesh-wireguard/go/overlay/names.go new file mode 100644 index 0000000..870b81d --- /dev/null +++ b/modules/mesh-wireguard/go/overlay/names.go @@ -0,0 +1,39 @@ +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/modules/mesh-wireguard/go/overlay/opens_test.go b/modules/mesh-wireguard/go/overlay/opens_test.go new file mode 100644 index 0000000..7abf1bd --- /dev/null +++ b/modules/mesh-wireguard/go/overlay/opens_test.go @@ -0,0 +1,113 @@ +package overlay + +import ( + "strings" + "testing" + + "git.novox.be/novox/mesh-sdk/go/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/modules/mesh-wireguard/go/overlay/overlay.go b/modules/mesh-wireguard/go/overlay/overlay.go new file mode 100644 index 0000000..70aaaf2 --- /dev/null +++ b/modules/mesh-wireguard/go/overlay/overlay.go @@ -0,0 +1,86 @@ +package overlay + +import theprivatenetwork "git.novox.be/novox/mesh-networking/modules/mesh-connectivity-contracts/go/seats/theprivatenetwork" + +// 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 = theprivatenetwork.Seat + +// **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. + +// 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, + // A provision says its credential's form (the manifest validator, DLV credential forms): the network and the + // addresses are used with no login of a consumer's own. + "provides": []map[string]any{ + {"name": Requirement, "credential": map[string]any{"form": "none"}}, + {"name": Addressing, "credential": map[string]any{"form": "none"}}, + }, + "claims": []map[string]any{{"name": TheNetwork, "scope": "node"}}, + } +} + +// 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" +) diff --git a/modules/mesh-wireguard/go/overlay/provided.go b/modules/mesh-wireguard/go/overlay/provided.go new file mode 100644 index 0000000..d3d8730 --- /dev/null +++ b/modules/mesh-wireguard/go/overlay/provided.go @@ -0,0 +1,37 @@ +package overlay + +import ( + "encoding/json" + "fmt" + + "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// The modules the mesh provides itself (issue 1126): no repository holds them, so the indexer reads none; their +// manifests are built in code, here, and always win over any other. Today the private network alone, built by nobody +// and run inside each node's own composition. One predicate for every part that judges or composes an assignment: the +// assignment's check, the seat's holder, the composer's input and the grants part. + +// ProvidedModules is every module the mesh provides itself, sorted. +func ProvidedModules() []string { return []string{Name} } + +// ProvidedByMesh says whether the mesh provides module itself, so an assignment may name it with no repository holding it. +func ProvidedByMesh(module string) bool { return module == Name } + +// ProvidedManifest is a provided module's manifest, as its readers parse it, and its raw JSON; an error for a module the +// mesh does not provide or a manifest its own reader refuses. +func ProvidedManifest(module string) (catalogue.Manifest, []byte, error) { + if !ProvidedByMesh(module) { + return catalogue.Manifest{}, nil, fmt.Errorf("overlay: the mesh provides no module %s", module) + } + raw, err := json.Marshal(Manifest()) + if err != nil { + return catalogue.Manifest{}, nil, err + } + // Decoded as every reader decodes a manifest (novox/hq ADR 0578 §3); its judgement is the indexer's. + m, err := catalogue.Decode(raw) + if err != nil { + return catalogue.Manifest{}, raw, fmt.Errorf("overlay: the manifest the mesh provides for %s does not decode: %w", module, err) + } + return m, raw, nil +} diff --git a/modules/mesh-wireguard/go/overlay/provided_test.go b/modules/mesh-wireguard/go/overlay/provided_test.go new file mode 100644 index 0000000..a16589d --- /dev/null +++ b/modules/mesh-wireguard/go/overlay/provided_test.go @@ -0,0 +1,29 @@ +package overlay + +import ( + "encoding/json" + "testing" +) + +func TestTheMeshProvidesThePrivateNetworkAloneAndItsManifestIsTheModules(t *testing.T) { + useTheSeat(t) + if got := ProvidedModules(); len(got) != 1 || got[0] != Name { + t.Fatalf("ProvidedModules = %v", got) + } + if !ProvidedByMesh(Name) || ProvidedByMesh("web") { + t.Error("the mesh provides mesh-wireguard and nothing else") + } + m, raw, err := ProvidedManifest(Name) + if err != nil { + t.Fatal(err) + } + if want, _ := json.Marshal(Manifest()); string(raw) != string(want) { + t.Errorf("provides\n%s\nthe manifest says\n%s", raw, want) + } + if m.Module != Name { + t.Errorf("the manifest is of %q", m.Module) + } + if _, _, err := ProvidedManifest("web"); err == nil { + t.Error("a module the mesh does not provide has a manifest") + } +} diff --git a/modules/mesh-wireguard/go/overlay/seat_test.go b/modules/mesh-wireguard/go/overlay/seat_test.go new file mode 100644 index 0000000..ce2811c --- /dev/null +++ b/modules/mesh-wireguard/go/overlay/seat_test.go @@ -0,0 +1,76 @@ +package overlay + +import ( + "encoding/json" + "os" + "reflect" + "testing" + + "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/seatdef" +) + +// The seat's definition is mesh-connectivity-contracts' (ADR 0636 §6); this module's manifest claims it, and the +// manifest is judged against that definition as every reader of the seat list judges it (ADR 0635 §1). +func useTheSeat(t *testing.T) { + t.Helper() + raw, err := os.ReadFile("../../../mesh-connectivity-contracts/seats/the-private-network/seat.json") + if err != nil { + t.Fatal(err) + } + def, err := seatdef.Parse(raw) + if err != nil { + t.Fatal(err) + } + catalogue.UseSeats(catalogue.SeatsFromDefinitions([]seatdef.Definition{def})) +} + +// The private network's claim is a seat the mesh defines (novox/hq ADR 0110), and the manifest the module ships is +// the manifest the node-engine folds in: module.json says what Manifest() says, beside its build. +func TestThePrivateNetworksClaimIsASeatTheMeshDefinesAndModuleJSONSaysTheSame(t *testing.T) { + useTheSeat(t) + raw, err := json.Marshal(Manifest()) + if err != nil { + t.Fatal(err) + } + if _, err := catalogue.Decode(raw); err != nil { + t.Fatalf("%s does not decode: %v", Name, 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) + } + file, err := os.ReadFile("../../module.json") + if err != nil { + t.Fatal(err) + } + var onDisk, composed map[string]any + if err := json.Unmarshal(file, &onDisk); err != nil { + t.Fatal(err) + } + json.Unmarshal(raw, &composed) + delete(onDisk, "build") + if !reflect.DeepEqual(onDisk, composed) { + t.Errorf("module.json beside its build is %v, Manifest() is %v", onDisk, composed) + } +} + +// Each provision the module offers says its credential's form, as the manifest validator requires of every offer. +func TestEveryProvisionTheModuleOffersSaysItsCredentialForm(t *testing.T) { + raw, err := json.Marshal(Manifest()) + if err != nil { + t.Fatal(err) + } + m, err := catalogue.Decode(raw) + if err != nil { + t.Fatal(err) + } + if len(m.Provides) != 2 { + t.Fatalf("the module provides %+v: the network and the addresses", m.Provides) + } + for _, o := range m.Provides { + if o.Credential == nil || o.Credential.Form != "none" { + t.Errorf("%s offers credential %+v: the form is none", o.Name, o.Credential) + } + } +} diff --git a/modules/mesh-wireguard/module.json b/modules/mesh-wireguard/module.json new file mode 100644 index 0000000..6cce421 --- /dev/null +++ b/modules/mesh-wireguard/module.json @@ -0,0 +1,38 @@ +{ + "module": "mesh-wireguard", + "version": "1", + "computed": "mesh-wireguard", + "provides": [ + { + "name": "private-network", + "credential": { + "form": "none" + } + }, + { + "name": "mesh-addressing", + "credential": { + "form": "none" + } + } + ], + "claims": [ + { + "name": "the-private-network", + "scope": "node" + } + ], + "build": { + "artifacts": [ + { + "name": "go-overlay", + "kind": "package", + "language": "go", + "paths": [ + "go" + ], + "series": "0.1" + } + ] + } +} diff --git a/modules/route-proxy/README.md b/modules/route-proxy/README.md index e8144c7..c3acb94 100644 --- a/modules/route-proxy/README.md +++ b/modules/route-proxy/README.md @@ -42,13 +42,13 @@ migration scaffolding still listed there. |---|---|---| | `provides` | `route` (scope `mesh`, answered by the helper) | a module on any node may require it | | `serves.route` | `{}` | a route hands back a name, not a credential — the consumer already knows the name it asked for | -| `reads` | `mesh-provisioner.plans`, `mesh-provisioner.provisions`, `mesh-delivery-assigner.placement` | the plans are where the routes are; the helper reads both; the placement says who is inside | +| `reads` | `mesh-provisioner.plans`, `mesh-provisioner.provisions`, `mesh-delivery-assigner.assignments` | the plans are where the routes are; the helper reads both; the assignments say who is inside | | `emits` | `route.served`, `route.withdrawn`, `route.refused` | what it serves, said as it changes (the contract's `Event`) | | `listens` | `80` and `443`, both `from: anywhere` | the one machine with a public opening; the firewall opens these for free (ADR 0045) | Until the bus answers, the proxy routes nothing and says so on its log; it refuses to start without its broker credential (`MESH_BROKER_FILE`). Who is inside the private network, and so may be served an internal-only name, is -every node's address on it as the seat mesh-placement decides it (mesh-sdk `delivery.InsideAddresses`); until that +every node's address on it as the seat mesh-module-assigner decides it (mesh-sdk `delivery.InsideAddresses`); until that is read, an internal-only name is served to this machine alone. ## Which issuer: Let's Encrypt, stated here (novox/hq ADR 0226) diff --git a/modules/route-proxy/cmd/route-provider/main.go b/modules/route-proxy/cmd/route-provider/main.go index abdf46e..3e294ca 100644 --- a/modules/route-proxy/cmd/route-provider/main.go +++ b/modules/route-proxy/cmd/route-provider/main.go @@ -11,8 +11,8 @@ import ( "os" "time" + "git.novox.be/novox/mesh-contracts/provisioning/go/clients/meshprovisionerclient/provider" stdio "git.novox.be/novox/mesh-sdk/go" - "git.novox.be/novox/mesh-sdk/go/provider" "route-proxy/contract" ) @@ -26,7 +26,7 @@ func main() { if node == "" { say("MESH_NODE is not set; answering no consumers") } else { - p := newProvider(node, provider.StateBus(ModuleName), say) + p := newProvider(node, provider.StateBus(ModuleName, provisionerModule), say) go func() { // The helper's watches go through the runtime's channel, which Serve holds: one asked before it does is // refused ("before Serve"), Run returns and the helper stays stopped until the process restarts. @@ -43,3 +43,7 @@ func main() { os.Exit(1) } } + +// provisionerModule is the holder of the seat mesh-provisioner, named from this module's manifest (its `reads` of plans and +// provisions; TestTheProvisionerIsTheManifests). +const provisionerModule = "mesh-provisioner" diff --git a/modules/route-proxy/cmd/route-provider/provider.go b/modules/route-proxy/cmd/route-provider/provider.go index 4c2b6ae..efb6761 100644 --- a/modules/route-proxy/cmd/route-provider/provider.go +++ b/modules/route-proxy/cmd/route-provider/provider.go @@ -5,13 +5,13 @@ package main // name from the entry in this module's bucket, the one its machine's client fills its files from. // // **It composes nothing.** A route's public name is the label its consumer chose joined to the node's public domain, -// which only placement holds, so the seat mesh-provisioner composes it and carries it in the +// which only the assignments hold, so the seat mesh-provisioner composes it and carries it in the // consumer's plan entry (`names`); the helper copies it into the entry's values unchanged. There is no credential for // a route and nothing to make. The proxy itself serves the same plans' entries (cmd/route-proxy plans.go), the one // source of routes (issue 774, research 072 row 34). import ( - "git.novox.be/novox/mesh-sdk/go/provider" + "git.novox.be/novox/mesh-contracts/provisioning/go/clients/meshprovisionerclient/provider" "route-proxy/contract" ) @@ -27,7 +27,7 @@ const ( func newProvider(node string, bus provider.Bus, logf func(string, ...any)) *provider.Provider { return provider.New(provider.Options{ Node: node, - Module: ModuleName, + Module: ModuleName, Provisioner: provisionerModule, Offers: map[string]provider.Offer{ProvisionName: { Credential: provider.NoCredential, Serves: map[string]any{}, diff --git a/modules/route-proxy/cmd/route-provider/provider_test.go b/modules/route-proxy/cmd/route-provider/provider_test.go index 34b143b..9f261ce 100644 --- a/modules/route-proxy/cmd/route-provider/provider_test.go +++ b/modules/route-proxy/cmd/route-provider/provider_test.go @@ -9,9 +9,9 @@ import ( "sync" "testing" + "git.novox.be/novox/mesh-contracts/provisioning/go/clients/meshprovisionerclient" + "git.novox.be/novox/mesh-contracts/provisioning/go/clients/meshprovisionerclient/provider" "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/provider" - "git.novox.be/novox/mesh-sdk/go/provisions" ) // bucketBus is the helper's bus over in-memory buckets. @@ -56,23 +56,23 @@ func TestAConsumerOfARouteIsToldItsNamesFromItsPlanEntry(t *testing.T) { "gitea": {"name": "git.example.org", "internal-name": "git.novox.internal", "name-web": "git.example.org"}, "grafana": {"name": "grafana.example.org"}, } { - plan, err := provisions.EncodePlan(provisions.Plan{Node: "g14", Module: module, Plan: catalogue.Plan{ + plan, err := meshprovisionerclient.EncodePlan(meshprovisionerclient.Plan{Node: "g14", Module: module, Plan: catalogue.Plan{ Provisions: []catalogue.PlanProvision{{Provision: ProvisionName, Provider: "novox", ProviderModule: ModuleName, At: "10.7.0.1", From: "novox", As: "mesh_g14_" + module, Names: names}}}}) if err != nil { t.Fatal(err) } key := "g14." + module - if err := bus.Put(context.Background(), provisions.PlansBucket, key, plan); err != nil { + if err := bus.Put(context.Background(), meshprovisionerclient.PlansBucketOf(provisionerModule), key, plan); err != nil { t.Fatal(err) } - asked, err := json.Marshal(provisions.Provision{V: provisions.Version, Provision: ProvisionName, - Consumer: provisions.End{Node: "g14", Module: module}, Provider: provisions.End{Node: "novox", Module: ModuleName}, - State: provisions.Asked}) + asked, err := json.Marshal(meshprovisionerclient.Provision{V: meshprovisionerclient.Version, Provision: ProvisionName, + Consumer: meshprovisionerclient.End{Node: "g14", Module: module}, Provider: meshprovisionerclient.End{Node: "novox", Module: ModuleName}, + State: meshprovisionerclient.Asked}) if err != nil { t.Fatal(err) } - if err := bus.Put(context.Background(), provisions.ProvisionsBucket, key+"."+ProvisionName, asked); err != nil { + if err := bus.Put(context.Background(), meshprovisionerclient.ProvisionsBucketOf(provisionerModule), key+"."+ProvisionName, asked); err != nil { t.Fatal(err) } } @@ -97,7 +97,7 @@ func TestAConsumerOfARouteIsToldItsNamesFromItsPlanEntry(t *testing.T) { t.Errorf("%s says %s %v, want %s", key, k, e.Values[k], v) } } - if e.State != provisions.Provided || e.VaultName != "" || e.User != "" || e.Refused != "" { + if e.State != meshprovisionerclient.Provided || e.VaultName != "" || e.User != "" || e.Refused != "" { t.Errorf("the entry %s is %+v", key, e) } } diff --git a/modules/route-proxy/cmd/route-provider/provisioner_names_test.go b/modules/route-proxy/cmd/route-provider/provisioner_names_test.go new file mode 100644 index 0000000..9a3325a --- /dev/null +++ b/modules/route-proxy/cmd/route-provider/provisioner_names_test.go @@ -0,0 +1,28 @@ +package main + +import ( + "encoding/json" + "os" + "slices" + "testing" +) + +// The module holding the seat mesh-provisioner, which this provider reads the plans and provisions of, is the one its manifest's +// `reads` name: no client names a holder (novox/hq ADR 0625 §4). +func TestTheProvisionerIsTheManifests(t *testing.T) { + raw, err := os.ReadFile("../../module.json") + if err != nil { + t.Fatal(err) + } + var m struct { + Reads []string `json:"reads"` + } + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + for _, state := range []string{"plans", "provisions"} { + if !slices.Contains(m.Reads, provisionerModule+"."+state) { + t.Errorf("the provider reads %s.%s, its manifest's reads are %v", provisionerModule, state, m.Reads) + } + } +} diff --git a/modules/route-proxy/cmd/route-proxy/bareaddress_test.go b/modules/route-proxy/cmd/route-proxy/bareaddress_test.go new file mode 100644 index 0000000..f27c010 --- /dev/null +++ b/modules/route-proxy/cmd/route-proxy/bareaddress_test.go @@ -0,0 +1,18 @@ +package main + +import "testing" + +// A message that says where the proxy could not connect never carries the account's password. +func TestABusAddressIsSaidWithoutItsCredential(t *testing.T) { + for in, want := range map[string]string{ + "nats://route-proxy:secret@127.0.0.1:4222": "nats://127.0.0.1:4222", + "route-proxy:secret@127.0.0.1:4222": "127.0.0.1:4222", + "nats://u:a@b@127.0.0.1:4222": "nats://127.0.0.1:4222", // the password holds an at-sign + "nats://127.0.0.1:4222": "nats://127.0.0.1:4222", + "127.0.0.1:4222": "127.0.0.1:4222", + } { + if got := bareAddress(in); got != want { + t.Errorf("bareAddress(%q) = %q, want %q", in, got, want) + } + } +} diff --git a/modules/route-proxy/cmd/route-proxy/bus.go b/modules/route-proxy/cmd/route-proxy/bus.go index 2bd3160..f36a2ef 100644 --- a/modules/route-proxy/cmd/route-proxy/bus.go +++ b/modules/route-proxy/cmd/route-proxy/bus.go @@ -10,16 +10,21 @@ import ( "github.com/nats-io/nats.go" + "git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient" stdio "git.novox.be/novox/mesh-sdk/go" "git.novox.be/novox/mesh-sdk/go/announce" - "git.novox.be/novox/mesh-sdk/go/delivery" - "git.novox.be/novox/mesh-sdk/go/membership" "git.novox.be/novox/mesh-sdk/go/process" - "git.novox.be/novox/mesh-sdk/go/provisions" "route-proxy/contract" ) +// assignmentsRead is the seat mesh-module-assigner's state assignments as this module's manifest reads it (`reads`); no seat's +// client names a holder (novox/hq ADR 0625 §4), so the name is kept here and held to the manifest by TestTheReadIsTheManifests. +const assignmentsRead = "mesh-delivery-assigner.assignments" + +// plansRead is the seat mesh-provisioner's state plans as this module's manifest reads it (`reads`; TestTheReadIsTheManifests). +const plansRead = "mesh-provisioner.plans" + // keptStates is every state route-proxy keeps, as both its halves write them: the routes and the refusals this // proxy writes, readable by others, and the bound entries the provider helper writes. func keptStates() []announce.State { @@ -35,11 +40,11 @@ func keptStates() []announce.State { // **The routes are the seat mesh-provisioner's plans, alone** (issue 774, research 072 row 34): every plan's entry // naming this proxy as a route's provider, followed, so a route added or withdrawn reaches a running proxy without a // restart, and said on the bus as it changes (contract.EventServed, EventWithdrawn, EventRefused). **Who is inside** -// is every node's address on the private network as the seat mesh-placement decides it (mesh-sdk -// delivery.InsideAddresses), which is who may be served an internal name; until it is read, an internal name is +// is every node's address on the private network as the seat mesh-module-assigner decides it (the module assigner's +// client, InsideAddresses), which is who may be served an internal name; until it is read, an internal name is // served to this machine alone. -// follow connects with the credential in path, follows the plans and the placement, and retries the connection for +// follow connects with the credential in path, follows the plans and the assignments, and retries the connection for // as long as it takes: until the bus answers, the proxy routes nothing, and says so. func follow(path string, held *table) { for { @@ -59,7 +64,7 @@ func followOnce(path string, held *table) error { } conn, err := process.Connect(account, nats.Name(account.Node+"."+account.Module)) if err != nil { - return fmt.Errorf("connecting to the bus at %s: %w", membership.BareAddress(account.URL), err) + return fmt.Errorf("connecting to the bus at %s: %w", bareAddress(account.URL), err) } // What this proxy keeps, said on the bus (novox/hq ADR 0495; issue 774): the names it serves and the names it // refused, readable by any module granted a read of them; the provider helper's entries, read by the machines' @@ -75,10 +80,10 @@ func followOnce(path string, held *table) error { } } // Who is inside first, so the first table is served to the machines it is meant for. - if err := followInside(conn.Read(delivery.PlacementRead), held); err != nil { - log.Printf("cannot follow %s, so an internal name is served to this machine alone: %v", delivery.PlacementRead, err) + if err := followInside(conn.Read(assignmentsRead), held); err != nil { + log.Printf("cannot follow %s, so an internal name is served to this machine alone: %v", assignmentsRead, err) } - if err := followPlans(conn.Read(provisions.PlansRead), newPlanned(account.Node, say), held, say); err != nil { + if err := followPlans(conn.Read(plansRead), newPlanned(account.Node, say), held, say); err != nil { conn.Close() return err } @@ -105,7 +110,7 @@ func followPlans(plans planWatch, given *planned, held *table, say func(string, return nil }) if err != nil { - return fmt.Errorf("watching %s: %w", provisions.PlansRead, err) + return fmt.Errorf("watching %s: %w", plansRead, err) } read.Store(true) serve(given, held, say) @@ -159,25 +164,25 @@ func sortedNames(m map[string]contract.Event) []string { return out } -// followInside follows the seat mesh-placement's state and serves an internal name to every node's address on the -// private network it decides, each time it changes (mesh-sdk delivery.InsideAddresses; research 072 rows 33 and 34: +// followInside follows the seat mesh-module-assigner's state and serves an internal name to every node's address on the +// private network it decides, each time it changes (the module assigner client's InsideAddresses; research 072 rows 33 and 34: // the membership that carried them went with the assignments stream). A key that does not read keeps who is inside // as it was. -func followInside(placement planWatch, held *table) error { - keys := delivery.NewPlacementKeys() +func followInside(assignments planWatch, held *table) error { + keys := meshmoduleassignerclient.NewKeys() var read atomic.Bool apply := func() { - inside, err := sourcesOf(delivery.InsideAddresses(keys.All())) + inside, err := sourcesOf(meshmoduleassignerclient.InsideAddresses(keys.All())) if err != nil { - log.Printf("the placement says an address that is not one, so who is inside stays as it was: %v", err) + log.Printf("the assignments say an address that is not one, so who is inside stays as it was: %v", err) return } held.setInside(inside) log.Printf("internal names are served to %d machine(s)", len(inside)) } - err := placement.Watch("", func(c stdio.StateChange) error { + err := assignments.Watch("", func(c stdio.StateChange) error { if _, err := keys.Put(c.Key, c.Value, c.Revision, c.Op == "delete" || c.Op == "purge"); err != nil { - log.Printf("the placement's key %s does not read, so who is inside stays as it was: %v", c.Key, err) + log.Printf("the assignments key %s does not read, so who is inside stays as it was: %v", c.Key, err) return nil } if read.Load() { @@ -192,3 +197,19 @@ func followInside(placement planWatch, held *table) error { apply() return nil } + +// bareAddress is a bus address without the credential it may carry, `[scheme://]user:password@host:port`, for a message +// that says where the proxy could not connect: the password is never written in a log. It splits on the last at-sign, +// so a password holding one stays out whole. (Its only reader is this proxy: it was the SDK's membership package's, +// which keeps the runtime's read of its own membership and nothing else.) +func bareAddress(address string) string { + at := strings.LastIndex(address, "@") + if at < 0 { + return address + } + scheme := "" + if i := strings.Index(address[:at], "://"); i >= 0 { + scheme = address[:i+3] + } + return scheme + address[at+1:] +} diff --git a/modules/route-proxy/cmd/route-proxy/names_test.go b/modules/route-proxy/cmd/route-proxy/names_test.go new file mode 100644 index 0000000..a42b9ab --- /dev/null +++ b/modules/route-proxy/cmd/route-proxy/names_test.go @@ -0,0 +1,27 @@ +package main + +import ( + "encoding/json" + "os" + "slices" + "testing" +) + +// The state the proxy follows to know who is inside is one its manifest reads. +func TestTheReadIsTheManifests(t *testing.T) { + raw, err := os.ReadFile("../../module.json") + if err != nil { + t.Fatal(err) + } + var m struct { + Reads []string `json:"reads"` + } + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + for _, read := range []string{assignmentsRead, plansRead} { + if !slices.Contains(m.Reads, read) { + t.Errorf("the proxy follows %s, its manifest reads %v", read, m.Reads) + } + } +} diff --git a/modules/route-proxy/cmd/route-proxy/plans.go b/modules/route-proxy/cmd/route-proxy/plans.go index b2e91fa..c749af4 100644 --- a/modules/route-proxy/cmd/route-proxy/plans.go +++ b/modules/route-proxy/cmd/route-proxy/plans.go @@ -6,7 +6,7 @@ package main // **A route is a provision, in route-proxy's own contract.** A module that must be reachable requires `route`; the // seat mesh-provisioner composes its plan on its node, and the plan's entry for the route names this proxy as its provider and // carries what the module contributes, composed (its names joined to the node's domains, the machine port its -// placement assigned its endpoint, never the software's own), and where the module's machine is on the private +// the assignments gave its endpoint, never the software's own), and where the module's machine is on the private // network, as one opaque payload the seat never reads (mesh-sdk catalogue.PlanProvision.Contributed). The proxy decodes each contribution with its contract (contract.DecodeRequest) // and serves every entry naming it. There is no file and no second source: a route the plans do not name is not // served, and a route the contract refuses is said refused (contract.EventRefused), never served half. @@ -17,8 +17,8 @@ import ( "sort" "sync" + "git.novox.be/novox/mesh-contracts/provisioning/go/clients/meshprovisionerclient" "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/provisions" "route-proxy/contract" ) @@ -90,7 +90,7 @@ func (p *planned) contributions() []contribution { // what the plan names and the proxy does not serve, each with why: an entry carrying nothing to serve, a payload or a // route the contract does not read, or a consumer elsewhere with no address on the private network. func routesOfPlan(node, key string, value []byte) ([]contribution, []contract.Event, error) { - plan, err := provisions.DecodePlan(value) + plan, err := meshprovisionerclient.DecodePlan(value) if err != nil { return nil, nil, err } diff --git a/modules/route-proxy/cmd/route-proxy/reach_test.go b/modules/route-proxy/cmd/route-proxy/reach_test.go index a6960e1..d373331 100644 --- a/modules/route-proxy/cmd/route-proxy/reach_test.go +++ b/modules/route-proxy/cmd/route-proxy/reach_test.go @@ -15,8 +15,9 @@ import ( "strings" "testing" + "git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient" + seat "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner" stdio "git.novox.be/novox/mesh-sdk/go" - "git.novox.be/novox/mesh-sdk/go/delivery" "route-proxy/contract" ) @@ -207,8 +208,8 @@ func contributed(at string, each ...string) string { // loopback on this machine and at the plan's address on another; an entry routing elsewhere, carrying nothing, naming // no address or a route the contract refuses is not served, and is said refused. A change is served as it comes and // said served or withdrawn, a plan that cannot be read keeps what it gave, and a deleted one withdraws it. The -// placement says only who is inside: an internal-only name is served to the machines it decides addresses for. -func TestThePlansAreServedAndThePlacementSaysWhoIsInside(t *testing.T) { +// the assignments say only who is inside: an internal-only name is served to the machines it decides addresses for. +func TestThePlansAreServedAndTheAssignmentsSayWhoIsInside(t *testing.T) { const route = `{"provision":"route","provider":"anchor","provider-module":"route-proxy",` gitea := route + contributed("10.10.0.7", "web", `{"name":"git.example","internal-name":"git.anchor.internal","port":3000}`, @@ -303,29 +304,29 @@ func TestThePlansAreServedAndThePlacementSaysWhoIsInside(t *testing.T) { held = behind(t) if code, _ := askFrom(held, "admin.anchor.internal", "10.10.0.7:1"); code != http.StatusNotFound { - t.Errorf("before the placement is read, a machine of the mesh was served an internal-only name: %d", code) + t.Errorf("before the assignments are read, a machine of the mesh was served an internal-only name: %d", code) } - laptop, err := delivery.PlacementValues(delivery.Placement{Node: "laptop", Decided: delivery.NodeDecided{Address: "10.10.0.7"}}) + laptop, err := meshmoduleassignerclient.AssignmentsValues(seat.Assignments{V: meshmoduleassignerclient.Version, Node: "laptop", Decided: seat.NodeDecided{Address: "10.10.0.7"}}) if err != nil { t.Fatal(err) } - placement := &fakePlans{current: map[string]string{}} + assignments := &fakePlans{current: map[string]string{}} for k, v := range laptop { - placement.current[k] = string(v) + assignments.current[k] = string(v) } - if err := followInside(placement, held); err != nil { + if err := followInside(assignments, held); err != nil { t.Fatal(err) } if code, _ := askFrom(held, "admin.anchor.internal", "10.10.0.7:1"); code == http.StatusNotFound { - t.Error("a machine the placement decides an address for was refused the internal-only route") + t.Error("a machine the assignments decide an address for was refused the internal-only route") } if code, _ := askFrom(held, "admin.anchor.internal", "10.10.0.8:1"); code != http.StatusNotFound { - t.Error("a machine the placement does not name was served the internal-only route") + t.Error("a machine the assignments do not name was served the internal-only route") } - if err := placement.handle(stdio.StateChange{Key: "laptop.node", Op: "put", Value: json.RawMessage(`{`)}); err != nil { + if err := assignments.handle(stdio.StateChange{Key: "laptop.node", Op: "put", Value: json.RawMessage(`{`)}); err != nil { t.Fatal(err) } if code, _ := askFrom(held, "admin.anchor.internal", "10.10.0.7:1"); code == http.StatusNotFound { - t.Error("an unreadable placement key changed who is inside") + t.Error("an unreadable assignments key changed who is inside") } } diff --git a/modules/route-proxy/cmd/route-proxy/routes_test.go b/modules/route-proxy/cmd/route-proxy/routes_test.go index c92b9fc..cf6b894 100644 --- a/modules/route-proxy/cmd/route-proxy/routes_test.go +++ b/modules/route-proxy/cmd/route-proxy/routes_test.go @@ -10,8 +10,8 @@ import ( "strings" "testing" + "git.novox.be/novox/mesh-contracts/provisioning/go/clients/meshprovisionerclient" "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/provisions" "route-proxy/contract" ) @@ -158,7 +158,7 @@ func TestARouteWithNoInternalNameGetsNoAlias(t *testing.T) { // 3000, published at 20000, and grafana's 3000 on another machine, published at 20012, answered 502): over loopback // when the plan is the one of this proxy's own node, which is the only thing that works without a private network, and // at the address the entry gives for its machine otherwise. The plans here are written as the seat mesh-provisioner -// carries them; that the port is the placement's and not the software's own is the composition's, proved by the +// carries them; that the port is the assignments' and not the software's own is the composition's, proved by the // node-engine's composer, not here. func TestAPlanEntryIsDialledAtTheMachinePortItCarries(t *testing.T) { for _, c := range []struct { @@ -178,7 +178,7 @@ func TestAPlanEntryIsDialledAtTheMachinePortItCarries(t *testing.T) { if err != nil { t.Fatalf("%s: %v", c.name, err) } - value, err := provisions.EncodePlan(provisions.Plan{Node: c.node, Module: c.module, Plan: catalogue.Plan{ + value, err := meshprovisionerclient.EncodePlan(meshprovisionerclient.Plan{Node: c.node, Module: c.module, Plan: catalogue.Plan{ Provisions: []catalogue.PlanProvision{{Provision: contract.Provision, Provider: "anchor", ProviderModule: contract.Module, Contributed: contributed}}}}) if err != nil { diff --git a/modules/route-proxy/go.mod b/modules/route-proxy/go.mod index de07bd3..db6debc 100644 --- a/modules/route-proxy/go.mod +++ b/modules/route-proxy/go.mod @@ -3,13 +3,16 @@ module route-proxy go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 + git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-contracts/provisioning/go/clients/meshprovisionerclient v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000 github.com/nats-io/nats.go v1.54.0 golang.org/x/crypto v0.57.0 ) require ( - github.com/klauspost/compress v1.20.0 // indirect + github.com/klauspost/compress v1.20.1 // indirect github.com/nats-io/nkeys v0.4.16 // indirect github.com/nats-io/nuid v1.0.1 // indirect golang.org/x/net v0.58.0 // indirect diff --git a/modules/route-proxy/go.sum b/modules/route-proxy/go.sum index cabacad..4480c7d 100644 --- a/modules/route-proxy/go.sum +++ b/modules/route-proxy/go.sum @@ -1,11 +1,9 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= -github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA= -github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= +github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ= +github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= github.com/minio/highwayhash v1.0.4 h1:asJizugGgchQod2ja9NJlGOWq4s7KsAWr5XUc9Clgl4= github.com/minio/highwayhash v1.0.4/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ= github.com/nats-io/jwt/v2 v2.8.1 h1:V0xpGuD/N8Mi+fQNDynXohVvp7ZztevW5io8CUWlPmU= diff --git a/modules/route-proxy/module.json b/modules/route-proxy/module.json index 668ab88..b4055ab 100644 --- a/modules/route-proxy/module.json +++ b/modules/route-proxy/module.json @@ -265,7 +265,7 @@ ] }, "reads": [ - "mesh-delivery-assigner.placement", + "mesh-delivery-assigner.assignments", "mesh-provisioner.plans", "mesh-provisioner.provisions" ]