diff --git a/PLAN.md b/PLAN.md index fd355b5..a23e296 100644 --- a/PLAN.md +++ b/PLAN.md @@ -62,6 +62,8 @@ examples/ # sample configs ## Status +**WireGuard done**: `wireguard` VM test (5 VMs: ISP/provider, router, laptop, remote site, LAN host) covers handshakes, road-warrior DNS/SSH over IPv4 and IPv6, zone rules between tunnels and LAN, site-to-site routing without NAT or transfer net, provider tunnel with masquerade and IPv6 transfer net, and the kill switch (kill switch and masquerade mutation-tested). Not covered: preshared keys, `mtu`, tunnels with several peers, key rotation. + **VLANs + zones done**: `vlans` VM test (5 VMs: ISP, router, untagged `main`, VLAN 20 `iot`, VLAN 30 `cams`) covers VLAN devices, per-VLAN DHCP/gateway, opt-in internet, DNS vs SSH per LAN, intra-zone forwarding across VLANs, one-directional inter-zone rules and the destination restriction (each mutation-tested). Not covered yet: IPv6 on VLAN LANs (only the PD LAN has IPv6), port forwards from the WAN. **PPPoE done**: `pppoe` VM test = the same topology with a fake ISP running `pppoe-server` (VLAN 7, CHAP, password containing a quote) — covers session, NAT, MTU, MSS clamp (mutation-tested) and WAN-deny. `basic` remains the DHCPv4 variant. @@ -77,7 +79,7 @@ Learned along the way: - In NixOS tests `fail(...)` also passes on "command not found": install probe tools explicitly and add a positive control per negative check. - Unbound answers `.test` locally (RFC 6761) and validates DNSSEC; tests using fake domains need both `local-zone ... transparent` and `domain-insecure` (both verified necessary; set in the test, not the module). - `modules/router/lib.nix` has IPv4 helpers (parse/format/netmask/cidr/overlap). `ipv4.subnet` is now derived from `address` + `prefixLength` (read-only). Assertions also cover pool bounds, pool containing the router address, and overlapping LANs. IPv6 helpers (prefix arithmetic for PD subnet-IDs) are still to do. -- Checks: `basic`, `pppoe` and `vlans` (VM tests), `lib` (helper unit tests), `assertions` (broken configs must fail with exact messages). Not yet covered: WireGuard, port forwards, WAN-originated rules. +- Checks: `basic`, `pppoe`, `vlans` and `wireguard` (VM tests), `lib` (helper unit tests), `assertions` (broken configs must fail with exact messages). Not yet covered: port forwards, WAN-originated rules, NPTv6. ## IPv6 design (confirmed) @@ -103,6 +105,15 @@ Learned along the way: - Router services stay per LAN: DNS and DHCP always, SSH only with `allowManagement`. - Kea subnet IDs are derived from the network address, so adding or removing LANs does not shift IDs (and orphan persistent leases). +## WireGuard (confirmed) + +- **Use cases**: road warrior (listening, dual-stack), site-to-site (no transfer network), and a provider VPN the router dials into (dual-stack transfer net, masquerade). All via `router.wireguard.`. +- **Firewall**: a tunnel is a zone member like a LAN (`zone`, `internet`, `allowManagement`, plus `dns`, `masquerade`, `killSwitch`), so `router.firewall.rules` cover it. Safer defaults than LANs: management, DNS and internet are off. A tunnel's peers never see each other (no same-device forwarding). +- **Routing**: by destination only — a peer's `allowedIPs` become routes (no default-route tunnels; `/0` is rejected). Provider-style "policy routing" is exactly this; source-based selectors (`ip rule`) are not implemented. +- **Kill switch** (default on): forwarded packets for a tunnel's `allowedIPs` are dropped if they would leave via an uplink (placed before the established-accept, so flows that were in the tunnel are cut too). +- **Addresses** are explicit (`addresses` list, empty for site-to-site); nothing generated. No IPv6 internet for road warriors (the delegated /64 belongs to a LAN; NPTv6 not implemented). +- **Keys**: `privateKeyFile` (and `presharedKeyFile`) outside the Nix store, handed to the unprivileged systemd-networkd via `LoadCredential`, so files can stay root-only 0600. Deployment must provide them. + ## Open items - **Web UI** (stats and possibly configuration) — to be discussed later. Considerations: read-only stats first is cheap; config editing must write declarative config (Nix/JSON) that feeds the module, not mutate the live system. diff --git a/modules/router/assertions.nix b/modules/router/assertions.nix index b38f2f8..d8e8a3f 100644 --- a/modules/router/assertions.nix +++ b/modules/router/assertions.nix @@ -60,24 +60,52 @@ let ] ); - zoneNames = lib.unique (map (l: l.zone) lans); + wgs = lib.attrValues cfg.wireguard; + zoneNames = lib.unique (map (l: l.zone) lans ++ map (w: w.zone) wgs); + + isV6 = lib.hasInfix ":"; + v4Cidr = s: lib.hasInfix "/" s && ipv4.parseNet s != null; + v6Cidr = + s: + let + m = builtins.match "([0-9a-fA-F:.]+)/([0-9]{1,3})" s; + in + isV6 s && m != null && lib.toInt (builtins.elemAt m 1) <= 128; + validCidr = s: v4Cidr s || v6Cidr s; + + lanNets = map (l: { + address = ipv4.parse l.ipv4.address; + inherit (l.ipv4) prefixLength; + }) (lib.filter (l: validIp l.ipv4.address) lans); + + # IPv4 networks a tunnel brings into its zone (own addresses and what peers may send). + wgV4Nets = + w: + lib.filter (n: n != null) ( + map ipv4.parseNet ( + lib.filter (n: !isV6 n && v4Cidr n) ( + w.addresses ++ lib.concatMap (p: p.allowedIPs) (lib.attrValues w.peers) + ) + ) + ); + zoneV4Nets = + zone: + map (l: { + address = ipv4.parse l.ipv4.address; + inherit (l.ipv4) prefixLength; + }) (lib.filter (l: l.zone == zone && validIp l.ipv4.address) lans) + ++ lib.concatMap wgV4Nets (lib.filter (w: w.zone == zone) wgs); ruleAssertions = r: let where = "router.firewall.rules (${r.from} -> ${r.to})"; dest = if r.destination == null then null else ipv4.parseNet r.destination; - destLans = lib.filter (l: l.zone == r.to && validIp l.ipv4.address) lans; inZone = dest != null - && lib.any ( - l: - dest.prefixLength >= l.ipv4.prefixLength - && ipv4.inSubnet { - address = ipv4.parse l.ipv4.address; - inherit (l.ipv4) prefixLength; - } dest.address - ) destLans; + && lib.any (n: dest.prefixLength >= n.prefixLength && ipv4.inSubnet n dest.address) ( + zoneV4Nets r.to + ); in [ { @@ -104,7 +132,7 @@ let } { assertion = dest == null || !(lib.elem r.to zoneNames) || inZone; - message = "router: ${where}: destination `${r.destination}` is not inside any LAN of zone `${r.to}`."; + message = "router: ${where}: destination `${r.destination}` is not inside any network of zone `${r.to}`."; } { assertion = dest == null || ipv4.networkOf dest.address dest.prefixLength == dest.address; @@ -112,6 +140,61 @@ let } ]; + overlapsLan = n: lib.any (l: ipv4.overlaps n l) lanNets; + + wgAssertions = + name: w: + let + where = "router.wireguard.${name}"; + peers = lib.attrValues w.peers; + allowed = lib.concatMap (p: p.allowedIPs) peers; + v4Allowed = lib.filter (n: v4Cidr n) allowed; + v4Own = lib.filter v4Cidr w.addresses; + asNet = n: ipv4.parseNet n; + in + [ + { + assertion = w.peers != { }; + message = "router: ${where} has no peers."; + } + { + assertion = lib.stringLength w.interface <= 15; + message = "router: ${where}: device name `${w.interface}` is longer than 15 characters."; + } + { + assertion = lib.all validCidr w.addresses; + message = "router: ${where}.addresses must be IPv4/IPv6 addresses in CIDR notation."; + } + { + assertion = lib.all validCidr allowed; + message = "router: ${where}: every peer allowedIPs entry must be an IPv4/IPv6 CIDR."; + } + { + assertion = !(lib.any (n: lib.hasSuffix "/0" n) allowed); + message = "router: ${where}: default-route tunnels (allowedIPs ending in /0) are not supported; list the destinations instead."; + } + { + assertion = lib.length allowed == lib.length (lib.unique allowed); + message = "router: ${where}: an allowedIPs entry appears more than once (WireGuard would give it to one peer only)."; + } + { + assertion = lib.all (n: !(overlapsLan (asNet n))) ( + lib.filter (n: !lib.hasSuffix "/0" n) (v4Own ++ v4Allowed) + ); + message = "router: ${where}: a tunnel address or allowedIPs entry overlaps one of the LAN subnets."; + } + { + assertion = lib.all (p: builtins.match "[A-Za-z0-9+/]{43}=" p.publicKey != null) peers; + message = "router: ${where}: a peer publicKey is not a valid WireGuard public key."; + } + { + assertion = lib.all ( + p: p.endpoint == null || builtins.match ".+:[0-9]{1,5}" p.endpoint != null + ) peers; + message = "router: ${where}: a peer endpoint must look like host:port or [ipv6]:port."; + } + ]; + overlapPairs = lib.concatLists ( lib.imap0 ( i: a: @@ -163,6 +246,26 @@ in ] ++ lib.concatLists (lib.mapAttrsToList perLan lansByName) ++ lib.concatMap ruleAssertions cfg.firewall.rules + ++ lib.concatLists (lib.mapAttrsToList wgAssertions cfg.wireguard) + ++ [ + { + assertion = + let + ports = lib.filter (p: p != null) (map (w: w.listenPort) wgs); + in + lib.length ports == lib.length (lib.unique ports); + message = "router: two WireGuard tunnels listen on the same UDP port."; + } + { + assertion = + let + own = map (w: w.interface) wgs; + others = map (l: l.device) lans ++ [ cfg.wan.interface ]; + in + lib.length own == lib.length (lib.unique own) && !(lib.any (d: lib.elem d others) own); + message = "router: a WireGuard device name clashes with a LAN device or the WAN interface."; + } + ] ++ map ( { a, b }: let diff --git a/modules/router/default.nix b/modules/router/default.nix index 083886e..e24e90d 100644 --- a/modules/router/default.nix +++ b/modules/router/default.nix @@ -4,6 +4,7 @@ ./assertions.nix ./networking.nix ./pppoe.nix + ./wireguard.nix ./firewall.nix ./dhcp.nix ./dns.nix diff --git a/modules/router/dns.nix b/modules/router/dns.nix index 2f68607..43d4b9f 100644 --- a/modules/router/dns.nix +++ b/modules/router/dns.nix @@ -2,6 +2,21 @@ let cfg = config.router; lans = lib.attrValues cfg.lans; + ipv4 = (import ./lib.nix { inherit lib; }).ipv4; + + # Tunnels that opted into DNS: listen on each of their addresses. + dnsAddrs = lib.concatMap (w: w.addresses) (lib.filter (w: w.dns) (lib.attrValues cfg.wireguard)); + addrOf = a: builtins.head (lib.splitString "/" a); + # IPv4 gets the proper network address; IPv6 netblocks are masked by Unbound. + netOf = + a: + let + parts = lib.splitString "/" a; + in + if lib.hasInfix ":" a then + a + else + ipv4.cidr (builtins.head parts) (lib.toInt (builtins.elemAt parts 1)); in { config = lib.mkIf cfg.enable { @@ -9,13 +24,14 @@ in enable = true; settings = { server = { - interface = [ "127.0.0.1" ] ++ map (l: l.ipv4.address) lans; + interface = [ "127.0.0.1" ] ++ map (l: l.ipv4.address) lans ++ map addrOf dnsAddrs; # LAN addresses are assigned by networkd, possibly after Unbound starts. ip-freebind = true; access-control = [ "127.0.0.0/8 allow" ] - ++ map (l: "${l.ipv4.subnet} allow") lans; + ++ map (l: "${l.ipv4.subnet} allow") lans + ++ map (a: "${netOf a} allow") dnsAddrs; }; } // lib.optionalAttrs (cfg.dns.forwarders != [ ]) { diff --git a/modules/router/firewall.nix b/modules/router/firewall.nix index 4c237cd..9cfdfaf 100644 --- a/modules/router/firewall.nix +++ b/modules/router/firewall.nix @@ -4,19 +4,59 @@ let lans = lib.attrValues cfg.lans; set = ifaces: "{ ${lib.concatMapStringsSep ", " (i: ''"${i}"'') ifaces} }"; - lanIfaces = map (l: l.device) lans; - mgmtIfaces = map (l: l.device) (lib.filter (l: l.allowManagement) lans); - internetIfaces = map (l: l.device) (lib.filter (l: l.internet) lans); + wgs = lib.attrValues cfg.wireguard; + isV6 = lib.hasInfix ":"; - # zone name -> devices of its member LANs - zones = lib.mapAttrs (_: ls: map (l: l.device) ls) (lib.groupBy (l: l.zone) lans); + # Everything that is a firewall zone member: LANs and tunnels. + members = + map (l: { + inherit (l) + device + zone + internet + allowManagement + ; + }) lans + ++ map (w: { + device = w.interface; + inherit (w) zone internet allowManagement; + }) wgs; + devicesOf = pred: map (m: m.device) (lib.filter pred members); + + lanIfaces = map (l: l.device) lans; + memberIfaces = devicesOf (_: true); + dnsIfaces = lanIfaces ++ map (w: w.interface) (lib.filter (w: w.dns) wgs); + mgmtIfaces = devicesOf (m: m.allowManagement); + internetIfaces = devicesOf (m: m.internet); + listenPorts = lib.unique (map (w: w.listenPort) (lib.filter (w: w.listenPort != null) wgs)); + masqueradeIfaces = map (w: w.interface) (lib.filter (w: w.masquerade) wgs); + + # Destinations a kill-switched tunnel must never leak to an uplink. + killSwitched = lib.filter (w: w.killSwitch) wgs; + killNets = lib.concatMap (w: lib.concatMap (p: p.allowedIPs) (lib.attrValues w.peers)) killSwitched; + killRules = + let + v4 = lib.filter (n: !isV6 n) killNets; + v6 = lib.filter isV6 killNets; + list = ns: "{ ${lib.concatStringsSep ", " ns} }"; + in + lib.optional (v4 != [ ]) "oifname ${set uplinks} ip daddr ${list v4} drop" + ++ lib.optional (v6 != [ ]) "oifname ${set uplinks} ip6 daddr ${list v6} drop"; + + # zone name -> devices of its members + zones = lib.mapAttrs (_: ms: map (m: m.device) ms) (lib.groupBy (m: m.zone) members); zoneDevices = zone: zones.${zone} or [ ]; - # LANs sharing a zone may forward to each other (a single LAN is plain L2). + # Members of one zone may forward to each other, but never back out of the + # device they came in on (a tunnel's peers do not see each other). intraZone = lib.concatLists ( lib.mapAttrsToList ( zone: devs: - lib.optional (lib.length devs > 1) "iifname ${set devs} oifname ${set devs} accept # zone ${zone}" + lib.optionals (lib.length devs > 1) ( + map ( + d: "iifname ${set [ d ]} oifname ${set (lib.filter (x: x != d) devs)} accept # zone ${zone}" + ) devs + ) ) zones ); @@ -69,14 +109,19 @@ in destination-unreachable, packet-too-big, time-exceeded, parameter-problem, nd-router-solicit, nd-router-advert, nd-neighbor-solicit, nd-neighbor-advert } accept - iifname ${set lanIfaces} icmp type echo-request accept - iifname ${set lanIfaces} icmpv6 type echo-request accept + iifname ${set memberIfaces} icmp type echo-request accept + iifname ${set memberIfaces} icmpv6 type echo-request accept ${lib.optionalString (!pppoe) ''iifname "${cfg.wan.interface}" udp dport 68 accept''} ${lib.optionalString cfg.wan.ipv6.enable ''iifname "${cfg.wan.interface}" udp dport 546 accept''} - iifname ${set lanIfaces} udp dport 53 accept - iifname ${set lanIfaces} tcp dport 53 accept + iifname ${set dnsIfaces} udp dport 53 accept + iifname ${set dnsIfaces} tcp dport 53 accept + ${lib.optionalString (listenPorts != [ ]) + "iifname ${set uplinks} udp dport { ${ + lib.concatMapStringsSep ", " toString listenPorts + } } accept # wireguard" + } ${lib.optionalString (dhcpIfaces != [ ]) "iifname ${set dhcpIfaces} udp dport 67 accept"} ${lib.optionalString (mgmtIfaces != [ ]) "iifname ${set mgmtIfaces} tcp dport 22 accept"} } @@ -84,6 +129,8 @@ in chain forward { type filter hook forward priority filter; policy drop; + ${lib.concatStringsSep "\n " killRules} + ct state established,related accept ct state invalid drop @@ -110,6 +157,9 @@ in chain postrouting { type nat hook postrouting priority srcnat; policy accept; oifname "${uplink4}" masquerade + ${lib.optionalString ( + masqueradeIfaces != [ ] + ) "oifname ${set masqueradeIfaces} masquerade # tunnels"} } ''; }; diff --git a/modules/router/options.nix b/modules/router/options.nix index 0f2d481..f1c0c32 100644 --- a/modules/router/options.nix +++ b/modules/router/options.nix @@ -186,6 +186,132 @@ in ); }; + wireguard = mkOption { + default = { }; + description = '' + WireGuard tunnels, keyed by name. A tunnel is a firewall member like a + LAN (it has a `zone`, `internet`, `allowManagement`), so + `router.firewall.rules` also cover traffic to and from it. Peers' + `allowedIPs` are the destinations routed into the tunnel; routing is + by destination only (no default-route tunnels). + ''; + type = types.attrsOf ( + types.submodule ( + { name, ... }: + { + options = { + interface = mkOption { + type = types.str; + default = "wg-${name}"; + description = "Name of the WireGuard device."; + }; + privateKeyFile = mkOption { + type = types.pathWith { + absolute = true; + inStore = false; + }; + example = "/var/lib/secrets/wg-rw.key"; + description = '' + File with the private key, outside the Nix store. It is + handed to systemd-networkd as a systemd credential, so it + can be root-only (mode 0600) and is read when networkd starts. + ''; + }; + listenPort = mkOption { + type = types.nullOr types.port; + default = null; + description = "UDP port to listen on (opened on the uplinks); null for a dial-out-only tunnel."; + }; + mtu = mkOption { + type = types.nullOr (types.ints.between 576 9000); + default = null; + description = "Tunnel MTU; null keeps the kernel default (1420)."; + }; + addresses = mkOption { + type = types.listOf types.str; + default = [ ]; + example = [ + "10.100.0.1/24" + "fd42:100::1/64" + ]; + description = '' + Addresses (CIDR, IPv4 and/or IPv6) on the tunnel interface; + empty means no transfer network (fine for site-to-site). + ''; + }; + zone = mkOption { + type = types.str; + default = name; + description = "Firewall zone; shared with LANs or other tunnels if you want a common policy."; + }; + internet = mkOption { + type = types.bool; + default = false; + description = "Whether traffic from this tunnel may reach the internet via the uplink."; + }; + allowManagement = mkOption { + type = types.bool; + default = false; + description = "Whether SSH on the router is reachable through this tunnel."; + }; + dns = mkOption { + type = types.bool; + default = false; + description = "Whether the router's resolver answers on the tunnel's IPv4 address."; + }; + masquerade = mkOption { + type = types.bool; + default = false; + description = "Source-NAT IPv4 traffic leaving through this tunnel to the tunnel address."; + }; + killSwitch = mkOption { + type = types.bool; + default = true; + description = '' + Drop forwarded traffic for this tunnel's peers' `allowedIPs` + when it would leave through an uplink (for example because + the tunnel device is down), instead of letting it leak. + ''; + }; + peers = mkOption { + default = { }; + type = types.attrsOf ( + types.submodule { + options = { + publicKey = mkOption { type = types.str; }; + presharedKeyFile = mkOption { + type = types.nullOr ( + types.pathWith { + absolute = true; + inStore = false; + } + ); + default = null; + }; + endpoint = mkOption { + type = types.nullOr types.str; + default = null; + example = "vpn.example.org:51820"; + }; + allowedIPs = mkOption { + type = types.listOf types.str; + default = [ ]; + example = [ "192.168.50.0/24" ]; + }; + persistentKeepalive = mkOption { + type = types.nullOr (types.ints.between 1 65535); + default = null; + }; + }; + } + ); + }; + }; + } + ) + ); + }; + firewall.rules = mkOption { default = [ ]; description = '' diff --git a/modules/router/wireguard.nix b/modules/router/wireguard.nix new file mode 100644 index 0000000..5399aff --- /dev/null +++ b/modules/router/wireguard.nix @@ -0,0 +1,71 @@ +{ config, lib, ... }: +let + cfg = config.router; + + # systemd-networkd runs unprivileged and could not read root-only key files. + # LoadCredential= makes systemd read them as root and hand networkd a copy + # in its credentials directory, so the files can stay 0600 root. + credDir = "/run/credentials/systemd-networkd.service"; + keyCred = name: "wg-${name}-key"; + pskCred = name: peer: "wg-${name}-${peer}-psk"; + credentials = lib.concatLists ( + lib.mapAttrsToList ( + name: wg: + [ "${keyCred name}:${toString wg.privateKeyFile}" ] + ++ lib.concatLists ( + lib.mapAttrsToList ( + peer: p: + lib.optional (p.presharedKeyFile != null) "${pskCred name peer}:${toString p.presharedKeyFile}" + ) wg.peers + ) + ) cfg.wireguard + ); +in +{ + config = lib.mkIf (cfg.enable && cfg.wireguard != { }) { + systemd.network.netdevs = lib.mapAttrs' ( + name: wg: + lib.nameValuePair "30-wg-${name}" { + netdevConfig = { + Kind = "wireguard"; + Name = wg.interface; + } + // lib.optionalAttrs (wg.mtu != null) { MTUBytes = wg.mtu; }; + wireguardConfig = { + PrivateKeyFile = "${credDir}/${keyCred name}"; + } + // lib.optionalAttrs (wg.listenPort != null) { ListenPort = wg.listenPort; }; + wireguardPeers = lib.mapAttrsToList ( + peer: p: + { + PublicKey = p.publicKey; + AllowedIPs = p.allowedIPs; + } + // lib.optionalAttrs (p.endpoint != null) { Endpoint = p.endpoint; } + // lib.optionalAttrs (p.persistentKeepalive != null) { + PersistentKeepalive = p.persistentKeepalive; + } + // lib.optionalAttrs (p.presharedKeyFile != null) { + PresharedKeyFile = "${credDir}/${pskCred name peer}"; + } + ) wg.peers; + } + ) cfg.wireguard; + + systemd.services.systemd-networkd.serviceConfig.LoadCredential = credentials; + + systemd.network.networks = lib.mapAttrs' ( + name: wg: + lib.nameValuePair "30-wg-${name}" { + matchConfig.Name = wg.interface; + address = wg.addresses; + networkConfig = { + ConfigureWithoutCarrier = true; + IPv6AcceptRA = false; + }; + # Cryptokey routing: a peer's allowedIPs are the destinations sent into the tunnel. + routes = lib.concatMap (p: map (ip: { Destination = ip; }) p.allowedIPs) (lib.attrValues wg.peers); + } + ) cfg.wireguard; + }; +} diff --git a/tests/assertions.nix b/tests/assertions.nix index a05da13..92f38b9 100644 --- a/tests/assertions.nix +++ b/tests/assertions.nix @@ -49,6 +49,18 @@ let } overrides ); + # A throwaway public key, valid in format only. + pub = "7MkFXOxlCsmXm98uv0txvYzx5Ce80mkg2mDsswY4FyI="; + wg = + overrides: + lib.recursiveUpdate { + privateKeyFile = "/var/lib/secrets/wg.key"; + peers.p1 = { + publicKey = pub; + allowedIPs = [ "10.100.0.2/32" ]; + }; + } overrides; + failed = router: map (a: a.message) (lib.filter (a: !a.assertion) (evalRouter router).assertions); cases = { @@ -328,7 +340,7 @@ let } ]; }; - expect = [ "is not inside any LAN of zone `main`" ]; + expect = [ "is not inside any network of zone `main`" ]; }; ruleDestinationHostBits = { router = { @@ -346,6 +358,154 @@ let }; expect = [ "has host bits set" ]; }; + wgValid = { + router = { + wan.interface = "eth0"; + lans.main = lan { }; + wireguard = { + rw = wg { + listenPort = 51820; + addresses = [ + "10.100.0.1/24" + "fd42:100::1/64" + ]; + dns = true; + peers.p1.allowedIPs = [ + "10.100.0.2/32" + "fd42:100::2/128" + ]; + }; + s2s = wg { + listenPort = 51821; + zone = "remote"; + peers.p1 = { + endpoint = "203.0.113.60:51821"; + allowedIPs = [ "192.168.50.0/24" ]; + persistentKeepalive = 25; + }; + }; + ff = wg { + zone = "freifunk"; + addresses = [ + "10.200.0.2/32" + "fd42:200::2/128" + ]; + masquerade = true; + peers.p1 = { + endpoint = "[2001:db8::1]:51822"; + allowedIPs = [ + "198.18.0.0/24" + "fd00:ff::/32" + ]; + }; + }; + }; + firewall.rules = [ + { + from = "main"; + to = "remote"; + destination = "192.168.50.7"; + } + { + from = "main"; + to = "freifunk"; + } + ]; + }; + expect = [ ]; + }; + wgNoPeers = { + router = { + lans.main = lan { }; + wireguard.rw = wg { peers = lib.mkForce { }; }; + }; + expect = [ "has no peers" ]; + }; + wgBadAddress = { + router = { + lans.main = lan { }; + wireguard.rw = wg { addresses = [ "10.100.0.1" ]; }; + }; + expect = [ "addresses must be IPv4/IPv6 addresses in CIDR notation" ]; + }; + wgDefaultRoute = { + router = { + lans.main = lan { }; + wireguard.rw = wg { peers.p1.allowedIPs = [ "0.0.0.0/0" ]; }; + }; + expect = [ "default-route tunnels" ]; + }; + wgDuplicateAllowedIps = { + router = { + lans.main = lan { }; + wireguard.rw = wg { + peers.p2 = { + publicKey = pub; + allowedIPs = [ "10.100.0.2/32" ]; + }; + }; + }; + expect = [ "appears more than once" ]; + }; + wgOverlapsLan = { + router = { + lans.main = lan { }; + wireguard.rw = wg { peers.p1.allowedIPs = [ "192.168.10.0/25" ]; }; + }; + expect = [ "overlaps one of the LAN subnets" ]; + }; + wgBadKey = { + router = { + lans.main = lan { }; + wireguard.rw = wg { peers.p1.publicKey = "not-a-key"; }; + }; + expect = [ "not a valid WireGuard public key" ]; + }; + wgBadEndpoint = { + router = { + lans.main = lan { }; + wireguard.rw = wg { peers.p1.endpoint = "vpn.example.org"; }; + }; + expect = [ "endpoint must look like host:port" ]; + }; + wgSamePort = { + router = { + lans.main = lan { }; + wireguard = { + a = wg { listenPort = 51820; }; + b = wg { listenPort = 51820; }; + }; + }; + expect = [ "listen on the same UDP port" ]; + }; + wgDeviceClash = { + router = { + lans.main = lan { }; + wireguard.rw = wg { interface = "eth1"; }; + }; + expect = [ "clashes with a LAN device or the WAN interface" ]; + }; + wgNameTooLong = { + router = { + lans.main = lan { }; + wireguard.a-very-long-tunnel-name = wg { }; + }; + expect = [ "longer than 15 characters" ]; + }; + wgRuleDestinationOutside = { + router = { + lans.main = lan { }; + wireguard.s2s = wg { peers.p1.allowedIPs = [ "192.168.50.0/24" ]; }; + firewall.rules = [ + { + from = "main"; + to = "s2s"; + destination = "192.168.60.1"; + } + ]; + }; + expect = [ "is not inside any network of zone `s2s`" ]; + }; nonOverlappingLans = { router.lans = { a = lan { }; @@ -391,6 +551,7 @@ let "pppoeValid" "vlansOnOneTrunk" "ruleValid" + "wgValid" ]; subnet = diff --git a/tests/common.nix b/tests/common.nix index 177a9cc..13eda8e 100644 --- a/tests/common.nix +++ b/tests/common.nix @@ -10,6 +10,7 @@ netcat-openbsd curl tcpdump + wireguard-tools ]; }; diff --git a/tests/default.nix b/tests/default.nix index 9fdb43c..dab2c5a 100644 --- a/tests/default.nix +++ b/tests/default.nix @@ -6,6 +6,7 @@ wan4 = "pppoe"; }; vlans = import ./vlans.nix { inherit self pkgs; }; + wireguard = import ./wireguard.nix { inherit self pkgs; }; lib = import ./lib.nix { inherit pkgs; }; assertions = import ./assertions.nix { inherit self pkgs; }; } diff --git a/tests/wireguard.nix b/tests/wireguard.nix new file mode 100644 index 0000000..80352f5 --- /dev/null +++ b/tests/wireguard.nix @@ -0,0 +1,408 @@ +# WireGuard: road warrior, site-to-site and a provider ("Freifunk-style") +# tunnel, all on one router. +# +# laptop -----+ +--- wg-rw (dual-stack, zone vpn) +# siteb ------+-- vlan 1 -- router -- vlan 2 -- main LAN (zone trusted) +# isp/provider+ +--- wg-s2s (no transfer net, zone remote) +# +--- wg-ff (dual-stack, zone freifunk) +# +# rules: vpn -> trusted, trusted -> remote, vpn -> remote, trusted -> freifunk +# +# The keys below are throwaway test keys, generated for this test only. +{ self, pkgs }: +let + inherit (pkgs) lib; + common = import ./common.nix; + + key = { + router-rw = { + priv = "wHjLTos8CeZxnCPWPKlZjlzDuer2uyhhNbXR6BYYkXY="; + pub = "7BBr4sUxdMI9OlHcEUY+sg5eh8fbeo2nZzSXEI7IJQQ="; + }; + router-s2s = { + priv = "CM1gl4vlKo7iULJ07wykWxrHke4YTahZq+LCakFmT0g="; + pub = "8/XG5fihWpnHQWQ/cKEjCBZvtk+DmBK/05gR26/Wfzo="; + }; + router-ff = { + priv = "2FFVahDzQTlzIpHmX95WeliWpSFKGVlfvJ6iC9iqA1Q="; + pub = "lgTbki7gCrRFQa1WmV2IVlocWDMh5Fta0S2+DfVF93k="; + }; + laptop = { + priv = "uEWQDRJkpi7JFThKSdLASqd+Z8+2buzzEdcehNXU9Uc="; + pub = "ru65s6+UROJ6CDfU15MS83Wlq1jYOJ5Leus1+3FkWmo="; + }; + siteb = { + priv = "WPqvH1WTD2nqq/+Ja474J79PAEUNomSVNvUvuQIAw1Q="; + pub = "VPHx2uwfYY8BVeZRMUHRBAyFJ+CUnulnlkvzibBGSXo="; + }; + isp = { + priv = "sNot2jWhW+MJKBVScvm56FiA0Ekt/DfrFfKYXpK5RUQ="; + pub = "7MkFXOxlCsmXm98uv0txvYzx5Ce80mkg2mDsswY4FyI="; + }; + }; + + # Plain (non-router) hosts: static address on the shared segment plus one + # WireGuard device configured straight through networkd. + segmentHost = + { + address, + wgName, + wgKey, + listenPort ? null, + wgAddresses ? [ ], + peer, + }: + { lib, ... }: + { + virtualisation.vlans = [ 1 ]; + networking.useNetworkd = true; + networking.useDHCP = false; + networking.firewall.enable = false; + networking.interfaces.eth1.ipv4.addresses = lib.mkForce [ ]; + + environment.etc."wg/${wgName}.key" = { + text = wgKey.priv; + mode = "0400"; + # These hosts use plain networkd, which reads the key itself. + user = "systemd-network"; + group = "systemd-network"; + }; + systemd.network.networks."10-seg" = { + matchConfig.Name = "eth1"; + address = [ "${address}/24" ]; + }; + systemd.network.netdevs."30-${wgName}" = { + netdevConfig = { + Kind = "wireguard"; + Name = wgName; + }; + wireguardConfig = { + PrivateKeyFile = "/etc/wg/${wgName}.key"; + } + // lib.optionalAttrs (listenPort != null) { ListenPort = listenPort; }; + wireguardPeers = [ peer ]; + }; + systemd.network.networks."30-${wgName}" = { + matchConfig.Name = wgName; + address = wgAddresses; + routes = map (ip: { Destination = ip; }) peer.AllowedIPs; + }; + }; + + dummy = name: addresses: { + systemd.network.netdevs."20-${name}".netdevConfig = { + Kind = "dummy"; + Name = name; + }; + systemd.network.networks."20-${name}" = { + matchConfig.Name = name; + address = addresses; + }; + }; + + echo = { + services.nginx = { + enable = true; + virtualHosts.default = { + default = true; + locations."/".return = ''200 "$remote_addr"''; + }; + }; + }; +in +pkgs.testers.runNixOSTest { + name = "router-wireguard"; + + defaults.imports = [ common.tools ]; + interactive.defaults = common.interactiveDefaults; + + nodes = { + # ISP, DHCP server for the router, and the VPN provider: the tunnel leads + # to 198.18.0.1 / fd00:ff::1, which are also reachable via the WAN on this + # very host (so a leak past the kill switch would succeed). + isp = { + imports = [ + (segmentHost { + address = "203.0.113.1"; + wgName = "wg-ff"; + wgKey = key.isp; + listenPort = 51822; + wgAddresses = [ + "10.200.0.1/24" + "fd42:200::1/64" + ]; + peer = { + PublicKey = key.router-ff.pub; + AllowedIPs = [ + "10.200.0.2/32" + "fd42:200::2/128" + ]; + }; + }) + (dummy "ff0" [ + "198.18.0.1/24" + "fd00:ff::1/128" + ]) + echo + ]; + services.dnsmasq = { + enable = true; + settings = { + interface = "eth1"; + bind-interfaces = true; + dhcp-range = "203.0.113.100,203.0.113.100,255.255.255.0,1h"; + dhcp-option = [ "option:router,203.0.113.1" ]; + address = "/example.test/203.0.113.1"; + }; + }; + }; + + router = + { lib, ... }: + { + imports = [ self.nixosModules.router ]; + virtualisation.vlans = [ + 1 + 2 + ]; + networking.interfaces.eth1.ipv4.addresses = lib.mkForce [ ]; + networking.interfaces.eth2.ipv4.addresses = lib.mkForce [ ]; + + environment.etc = lib.listToAttrs ( + map + (n: { + name = "wg/${n}.key"; + value = { + text = key."router-${n}".priv; + mode = "0600"; + }; + }) + [ + "rw" + "s2s" + "ff" + ] + ); + + router = { + enable = true; + wan.interface = "eth1"; + lans.main = { + interface = "eth2"; + zone = "trusted"; + internet = true; + ipv4.address = "192.168.10.1"; + dhcp.pool = { + start = "192.168.10.100"; + end = "192.168.10.199"; + }; + }; + dns.forwarders = [ "203.0.113.1" ]; + + wireguard = { + rw = { + privateKeyFile = "/etc/wg/rw.key"; + listenPort = 51820; + addresses = [ + "10.100.0.1/24" + "fd42:100::1/64" + ]; + zone = "vpn"; + allowManagement = true; + dns = true; + peers.laptop = { + publicKey = key.laptop.pub; + allowedIPs = [ + "10.100.0.2/32" + "fd42:100::2/128" + ]; + }; + }; + # Site-to-site: no transfer network, routing by allowedIPs alone. + s2s = { + privateKeyFile = "/etc/wg/s2s.key"; + listenPort = 51821; + zone = "remote"; + peers.siteb = { + publicKey = key.siteb.pub; + endpoint = "203.0.113.60:51821"; + allowedIPs = [ "192.168.50.0/24" ]; + persistentKeepalive = 5; + }; + }; + # Provider tunnel: only the listed destinations go through it. + ff = { + privateKeyFile = "/etc/wg/ff.key"; + zone = "freifunk"; + masquerade = true; + addresses = [ + "10.200.0.2/32" + "fd42:200::2/128" + ]; + peers.provider = { + publicKey = key.isp.pub; + endpoint = "203.0.113.1:51822"; + allowedIPs = [ + # The provider's own transfer addresses, plus what it serves. + "10.200.0.1/32" + "fd42:200::1/128" + "198.18.0.0/24" + "fd00:ff::/32" + ]; + persistentKeepalive = 5; + }; + }; + }; + + firewall.rules = [ + { + from = "vpn"; + to = "trusted"; + } + { + from = "trusted"; + to = "remote"; + } + { + from = "vpn"; + to = "remote"; + } + { + from = "trusted"; + to = "freifunk"; + } + ]; + }; + + # See basic.nix: relax Unbound for the made-up test domain only. + services.unbound.settings.server = { + local-zone = [ "test. transparent" ]; + domain-insecure = [ "example.test" ]; + }; + }; + + laptop = segmentHost { + address = "203.0.113.50"; + wgName = "wg-rw"; + wgKey = key.laptop; + wgAddresses = [ + "10.100.0.2/24" + "fd42:100::2/64" + ]; + peer = { + PublicKey = key.router-rw.pub; + Endpoint = "203.0.113.100:51820"; + PersistentKeepalive = 5; + AllowedIPs = [ + "10.100.0.0/24" + "192.168.10.0/24" + "192.168.50.0/24" + "fd42:100::/64" + ]; + }; + }; + + siteb = { + imports = [ + (segmentHost { + address = "203.0.113.60"; + wgName = "wg-s2s"; + wgKey = key.siteb; + listenPort = 51821; + peer = { + PublicKey = key.router-s2s.pub; + Endpoint = "203.0.113.100:51821"; + PersistentKeepalive = 5; + AllowedIPs = [ + "192.168.10.0/24" + "10.100.0.0/24" + ]; + }; + }) + (dummy "lan0" [ "192.168.50.1/24" ]) + echo + ]; + }; + + main = + { lib, ... }: + { + virtualisation.vlans = [ 2 ]; + networking.useNetworkd = true; + networking.useDHCP = false; + networking.firewall.enable = false; + networking.interfaces.eth1.ipv4.addresses = lib.mkForce [ ]; + systemd.network.networks."20-lan" = { + matchConfig.Name = "eth1"; + networkConfig.DHCP = "ipv4"; + }; + }; + }; + + testScript = '' + start_all() + + isp.wait_for_unit("dnsmasq.service") + isp.wait_for_unit("nginx.service") + siteb.wait_for_unit("nginx.service") + + router.wait_for_unit("network-online.target") + router.wait_until_succeeds("ip -4 addr show dev eth1 | grep -q 203.0.113.100") + router.wait_for_unit("nftables.service") + router.wait_for_unit("kea-dhcp4-server.service") + router.wait_for_unit("unbound.service") + main.wait_until_succeeds("ip -4 addr show dev eth1 | grep -q 192.168.10.100") + + with subtest("all three tunnels complete a handshake"): + for dev in ("wg-rw", "wg-s2s", "wg-ff"): + router.wait_until_succeeds(f"wg show {dev} latest-handshakes | awk '$2 > 0' | grep -q .") + + with subtest("road warrior: dual-stack tunnel to the router, DNS and SSH over it"): + laptop.succeed("ping -c 1 -W 2 10.100.0.1") + laptop.succeed("ping -6 -c 1 -W 2 fd42:100::1") + laptop.succeed("dig +short +time=2 +tries=1 @10.100.0.1 example.test | grep -qx 203.0.113.1") + laptop.succeed("dig +short +time=2 +tries=1 @fd42:100::1 example.test | grep -qx 203.0.113.1") + laptop.succeed("nc -z -w 2 10.100.0.1 22") + + with subtest("road warrior reaches the LAN, but not the other way round"): + laptop.succeed("ping -c 1 -W 2 192.168.10.100") + # Positive control: the LAN host can reach the router's own LAN address. + main.succeed("ping -c 1 -W 2 192.168.10.1") + main.fail("ping -c 1 -W 2 10.100.0.2") + + with subtest("site-to-site: routed without NAT and without a transfer network"): + assert router.succeed("ip -4 addr show dev wg-s2s | grep -c inet || true").strip() == "0" + main.succeed("ping -c 1 -W 2 192.168.50.1") + out = main.succeed("curl -sf --max-time 5 http://192.168.50.1/") + assert out.strip() == "192.168.10.100", f"expected the LAN address to be preserved, got {out!r}" + out = laptop.succeed("curl -sf --max-time 5 http://192.168.50.1/") + assert out.strip() == "10.100.0.2", f"expected the tunnel address to be preserved, got {out!r}" + + with subtest("site-to-site: the remote site cannot initiate towards the LAN"): + # Positive control: it can reach the router itself over the tunnel. + siteb.succeed("ping -c 1 -W 2 -I 192.168.50.1 192.168.10.1") + siteb.fail("ping -c 1 -W 2 -I 192.168.50.1 192.168.10.100") + + with subtest("provider tunnel: only the listed destinations, masqueraded"): + out = main.succeed("curl -sf --max-time 5 http://198.18.0.1/") + assert out.strip() == "10.200.0.2", f"expected masquerade to 10.200.0.2, got {out!r}" + # Everything else still leaves via the WAN. + out = main.succeed("curl -sf --max-time 5 http://203.0.113.1/") + assert out.strip() == "203.0.113.100", f"expected the WAN address, got {out!r}" + # IPv6 over the dual-stack transfer net (router-originated). + router.succeed("ping -6 -c 1 -W 2 fd42:200::1") + out = router.succeed("curl -sf -g --max-time 5 'http://[fd00:ff::1]/'") + assert out.strip() == "fd42:200::2", f"expected the IPv6 transfer address, got {out!r}" + + with subtest("zones apply to tunnels: the road warrior may not use the provider tunnel"): + laptop.fail("curl -sf --max-time 3 http://198.18.0.1/") + + with subtest("kill switch: no leak to the WAN when the provider tunnel is down"): + router.succeed("ip link set dev wg-ff down") + # The route is gone, so the destination would now follow the default route. + router.succeed("ip route get 198.18.0.1 | grep -q 'dev eth1'") + main.fail("curl -sf --max-time 3 http://198.18.0.1/") + # Ordinary destinations are not affected. + main.succeed("curl -sf --max-time 5 http://203.0.113.1/") + ''; +}