From 406196adce894799048362bd982b234e9edcd79f Mon Sep 17 00:00:00 2001 From: Nils Stinnesbeck Date: Wed, 28 Oct 2026 16:53:14 +0100 Subject: [PATCH] Add the home gateway config and tunnel source addresses - wireguard..sourceAddresses: source for router-originated traffic into a tunnel without an address of its own (PreferredSource on the routes); checked at build time. - examples/home: antigua's routing role ported to the router module (PPPoE with IPv6 inside the session, LAN with ULA and reservation, local DNS and forward zones, the freifunk/family/mobile tunnels with explicit zone rules, ddclient following the PPP device). nixosConfigurations.home, packages.installer-home and a flake check that builds it. - PLAN.md: the remaining antigua gaps and the open question about services that stay behind the router. Co-Authored-By: Claude Sonnet 5.5 --- PLAN.md | 9 +- examples/home/default.nix | 213 ++++++++++++++++++++++++++++++++++ flake.nix | 14 +++ modules/router/assertions.nix | 22 ++++ modules/router/options.nix | 13 +++ modules/router/wireguard.nix | 14 ++- tests/assertions.nix | 35 ++++++ tests/wireguard.nix | 7 ++ 8 files changed, 322 insertions(+), 5 deletions(-) create mode 100644 examples/home/default.nix diff --git a/PLAN.md b/PLAN.md index da9e337..19e3ba3 100644 --- a/PLAN.md +++ b/PLAN.md @@ -152,10 +152,11 @@ Done: PPPoE on VLAN 1001 over the shared NIC (`eno1`, LAN untagged), DNS records Open (in rough order): 1. ~~**IPv6 over the PPP session**~~ **done**: `wan.ipv6.transport = "ppp"` (default `native`) enables IPv6CP in pppd, RA + DHCPv6-PD via networkd on the PPP device, PD to the LAN, DHCPv6 replies accepted on the PPP device and the IPv6 MSS clamped to MTU-60 (1432). Allowed on a shared link (single NIC). Tested in `onestick` (ISP with radvd and a minimal DHCPv6-PD responder, since Kea cannot serve point-to-point links); mutation-tested (MSS clamp, DHCPv6 input rule). `ipv6cp-use-ipaddr` is set as on antigua. 2. ~~**ULA on the LAN**~~ **done**: `lans..ipv6.address` (a ULA /64, e.g. `fd2c:cdef:cde9::1/64`) is assigned, announced via SLAAC (explicit `[IPv6Prefix]`, because networkd announces only the delegated prefix when PD is on) and advertised as RDNSS; Unbound listens on it. Build-time checks: valid IPv6 CIDR, /64, fc00::/7. Tested in `onestick` (client SLAAC ULA, ping and DNS over the ULA, RA captured on the wire) and assertion cases; mutation-tested. -3. **Tunnel trust and addressing**: antigua trusts `family`, `freifunk`, `mobile-clients` fully and gives two of them no address; check the schema covers this (`allowManagement`, `dns`, `internet` per tunnel; `addresses` may be empty?). -4. **PPP device name**: `wan` on antigua, `pppoe-wan` here; `ddclient` (Cloudflare, uses the interface address) must follow, or make the name configurable. -5. **Secrets**: the PPPoE credentials are plaintext in antigua's repo/store (rotate the password); here they are read from `/var/lib/router/pppoe-password`. -6. DDNS (ddclient) is not part of the module yet; configure it in the host config for now. +3. ~~**Tunnel trust and addressing**~~ **done**: tunnels may have no address; the new `wireguard..sourceAddresses` sets the source (`PreferredSource`) for router-originated traffic into an addressless tunnel (e.g. DNS to Troisdorf; tested, mutation-tested). Antigua's blanket `trustedInterfaces` is spelled out as per-tunnel `allowManagement`/`dns` plus zone rules in `examples/home`. The default device name `wg-` must fit 15 characters (caught by an assertion: `mobile-clients` became `mobile`). +4. ~~**PPP device name**~~ **done**: `ddclient` in `examples/home` uses `config.router.wan.ipv4.interface` (`pppoe-wan`) instead of a hard-coded name. DDNS stays in the host config (not part of the module). +5. **Credentials**: the PPPoE password is assigned by the ISP (cannot be rotated) and is plaintext in antigua's repo/store; never copy it into a repo again. Here it is read from `/var/lib/router/pppoe-password` (delivered with `router-install --secrets`). The PPPoE username in `examples/home` is a placeholder. +6. **Services that stay on antigua** (k3s, Gitea, Syncthing, WoW, Traefik) were reachable from the WAN because antigua *was* the router. On the new router they need explicit port forwards to the host's address (`firewall.rules` with `from = "wan"`); antigua's address on the LAN also changes (k3s nodeIP and the `antigua.mch` record point at 192.168.168.254 today). **Open: needs the host's new address and the list of public ports.** +7. `examples/home` (`nixosConfigurations.home`, `packages.installer-home`) ports antigua's routing role; its toplevel builds as the `home` check. Not VM-testable as a whole (needs the real ISP); its pieces are covered by the component tests. ## Installer and image (in progress) diff --git a/examples/home/default.nix b/examples/home/default.nix new file mode 100644 index 0000000..52f317c --- /dev/null +++ b/examples/home/default.nix @@ -0,0 +1,213 @@ +# The home gateway that replaces antigua's routing role (ported from antigua's +# hand-written networkd/pppd/dnsmasq/WireGuard configuration). +# +# ISP -- switch port --+-- enp2s0 (untagged) LAN 192.168.168.0/24 (+ ULA, + delegated /64) +# +-- enp2s0.1001 PPPoE session: IPv4 and IPv6 (IPv6CP) +# +# Secrets are NOT in this repository. Put them in the directory handed to +# `router-install --secrets`; it lands in /var/lib/router on the box: +# +# pppoe-password the PPPoE password (from the ISP) +# cloudflare-ddns-token Cloudflare API token for ddclient +# wireguard/freifunk.key private keys (the ones antigua uses today keep the +# wireguard/family.key peers working without any change on their side; +# wireguard/family.psk never run both boxes with the same keys at once) +# wireguard/mobile-clients.key +# wireguard/mobile-clients.psk +# +# Before installing: fill in the PPPoE username below. +{ config, lib, ... }: +let + secrets = "/var/lib/router"; + k8s = { + v4 = "10.43.162.50"; + v6 = "fd32:66ac:e01f::f294"; + }; +in +{ + imports = [ ../../modules/installer/layout.nix ]; + + networking.hostName = "gateway"; + time.timeZone = "Europe/Berlin"; + system.stateVersion = "26.11"; + + users.users.root.openssh.authorizedKeys.keys = [ + "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHqyMLgAhEdnrRrd2CeYbphGDMPq/cBDjFJ4q4ItZusn nils@Nilss-Mac-mini.mch.stinnesbeck.com" + "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBFxbNNk87tvMxruuKvrryK7iAdf4xgS4xFqjKejNAI6 nils@nixtop" + ]; + + router = { + enable = true; + + wan = { + interface = "enp2s0"; + ipv4 = { + type = "pppoe"; + pppoe = { + vlan = 1001; + # From antigua's networking.nix (pppd `user`); kept out of this file. + username = "CHANGE-ME"; + passwordFile = "${secrets}/pppoe-password"; + }; + }; + # IPv6 runs inside the PPP session, with one delegated /64. + ipv6 = { + enable = true; + transport = "ppp"; + }; + }; + + lans.lan = { + interface = "enp2s0"; + internet = true; + ipv4.address = "192.168.168.254"; + ipv6 = { + pd = true; + address = "fd2c:cdef:cde9::1/64"; + }; + dhcp = { + pool = { + start = "192.168.168.100"; + end = "192.168.168.200"; + }; + reservations.truenas = { + mac = "a8:a1:59:0b:4b:97"; + address = "192.168.168.251"; + }; + }; + }; + + dns = { + domain = "mch.stinnesbeck.com"; + forwarders = [ + "1.1.1.1" + "2606:4700:4700::1111" + ]; + hosts = { + gateway = [ "192.168.168.254" ]; + "photos.stinnesbeck.com" = [ + k8s.v4 + k8s.v6 + ]; + "git.stinnesbeck.com" = [ + k8s.v4 + k8s.v6 + ]; + }; + overrides."k8s.stinnesbeck.com" = [ + k8s.v4 + k8s.v6 + ]; + forwardZones."tdf.stinnesbeck.com" = [ "10.1.0.254" ]; + }; + + wireguard = { + # Provider-style dial-out: only the treasure-maps.com hosts go through + # it (antigua accepts 0.0.0.0/0 but routes just these), masqueraded. The + # kill switch keeps them from leaking out of the WAN if the tunnel is down. + freifunk = { + privateKeyFile = "${secrets}/wireguard/freifunk.key"; + zone = "freifunk"; + masquerade = true; + addresses = [ + "10.255.1.8/32" + "fd80:3ea2:e399:203a::8/128" + ]; + peers.supernode = { + publicKey = "5B/YTaDPVWVApUyHshJp899iXXlBy8rBqJUpYvKo+1s="; + endpoint = "5.9.220.114:42001"; + persistentKeepalive = 15; + allowedIPs = [ + "185.90.196.65/32" + "185.90.196.97/32" + "185.90.196.129/32" + ]; + }; + }; + + # Site-to-site with Troisdorf; no transfer network. The site may use the + # router (SSH, DNS), and the router reaches it from its LAN addresses. + family = { + privateKeyFile = "${secrets}/wireguard/family.key"; + listenPort = 36432; + zone = "family"; + allowManagement = true; + dns = true; + sourceAddresses = [ + "192.168.168.254" + "fd2c:cdef:cde9::1" + ]; + peers.troisdorf = { + publicKey = "BsQODhcHnEN4tXn60t72iejZvgYvVNt5BDVK+69+RgM="; + endpoint = "fw-tdf.stinnesbeck.com:36432"; + persistentKeepalive = 25; + presharedKeyFile = "${secrets}/wireguard/family.psk"; + allowedIPs = [ + "10.1.0.0/24" + "fd73:f65e:6b64::/48" + ]; + }; + }; + + # Road warrior. + mobile = { + privateKeyFile = "${secrets}/wireguard/mobile-clients.key"; + listenPort = 36433; + zone = "mobile"; + allowManagement = true; + dns = true; + peers.laptop = { + publicKey = "Jts5u8zIs28v6RIEYCCufDDiWXf+gbElT63wUFWQxX4="; + persistentKeepalive = 25; + presharedKeyFile = "${secrets}/wireguard/mobile-clients.psk"; + allowedIPs = [ "10.2.0.2/32" ]; + }; + }; + }; + + # antigua trusted these interfaces completely and forwarded between all of + # them. Here it is spelled out: the LAN and Troisdorf reach each other, the + # LAN reaches the freifunk hosts, road warriors reach the LAN. Nothing + # initiates from freifunk. + firewall.rules = [ + { + from = "lan"; + to = "family"; + } + { + from = "family"; + to = "lan"; + } + { + from = "lan"; + to = "freifunk"; + } + { + from = "mobile"; + to = "lan"; + } + ]; + }; + + # Dynamic DNS from the PPP device's addresses. + services.ddclient = { + enable = true; + interval = "5min"; + protocol = "cloudflare"; + username = "token"; + passwordFile = "${secrets}/cloudflare-ddns-token"; + zone = "stinnesbeck.com"; + domains = [ + "fw-mch.stinnesbeck.com" + "car.k8s.stinnesbeck.com" + "photos.stinnesbeck.com" + "git.stinnesbeck.com" + "link.stinnesbeck.com" + "pw.k8s.stinnesbeck.com" + ]; + usev4 = "ifv4, ifv4=${config.router.wan.ipv4.interface}"; + usev6 = "ifv6, ifv6=${config.router.wan.ipv4.interface}"; + ssl = true; + verbose = true; + }; +} diff --git a/flake.nix b/flake.nix index fe5aaea..baebecd 100644 --- a/flake.nix +++ b/flake.nix @@ -40,6 +40,10 @@ ]; }; + packages.x86_64-linux.installer-home = + (self.lib.mkInstaller { configuration = self.nixosConfigurations.home; }) + .config.system.build.isoImage; + packages.x86_64-linux.installer-example = (self.lib.mkInstaller { configuration = self.nixosConfigurations.example; }) .config.system.build.isoImage; @@ -53,6 +57,15 @@ ]; }; + # The home gateway replacing antigua's router role (see examples/home). + nixosConfigurations.home = nixpkgs.lib.nixosSystem { + system = "x86_64-linux"; + modules = [ + self.nixosModules.router + ./examples/home + ]; + }; + # `nix run .#deploy -- `: activate a new # configuration with commit-confirm; see scripts/deploy.sh. apps.x86_64-linux.deploy = @@ -82,6 +95,7 @@ // { # The example must keep building as a complete system. example = self.nixosConfigurations.example.config.system.build.toplevel; + home = self.nixosConfigurations.home.config.system.build.toplevel; }; # `nix run .#lab`: boot the basic test topology in tmux, one console pane diff --git a/modules/router/assertions.nix b/modules/router/assertions.nix index a921032..c4cd2ce 100644 --- a/modules/router/assertions.nix +++ b/modules/router/assertions.nix @@ -380,6 +380,28 @@ let assertion = lib.stringLength w.interface <= 15; message = "router: ${where}: device name `${w.interface}` is longer than 15 characters."; } + { + assertion = lib.all anyIp w.sourceAddresses; + message = "router: ${where}.sourceAddresses must be IPv4/IPv6 addresses."; + } + { + assertion = + lib.length (lib.filter isV6 w.sourceAddresses) <= 1 + && lib.length (lib.filter (s: !isV6 s) w.sourceAddresses) <= 1; + message = "router: ${where}.sourceAddresses may hold at most one address per family."; + } + { + assertion = lib.all ( + s: + isV6 s + || !(validIp s) + || lib.elem s ( + map (l: l.ipv4.address) lans + ++ map (a: builtins.head (lib.splitString "/" a)) (lib.concatMap (x: x.addresses) wgs) + ) + ) w.sourceAddresses; + message = "router: ${where}.sourceAddresses: an IPv4 source must be the router's address on a LAN or a tunnel."; + } { assertion = lib.all validCidr w.addresses; message = "router: ${where}.addresses must be IPv4/IPv6 addresses in CIDR notation."; diff --git a/modules/router/options.nix b/modules/router/options.nix index 170c8c9..d1cf502 100644 --- a/modules/router/options.nix +++ b/modules/router/options.nix @@ -296,6 +296,19 @@ in empty means no transfer network (fine for site-to-site). ''; }; + sourceAddresses = mkOption { + type = types.listOf types.str; + default = [ ]; + example = [ "192.168.10.1" ]; + description = '' + Source address (at most one per family) for traffic the router + itself sends into this tunnel, e.g. DNS queries to the other + site. Needed when the tunnel has no address of its own, so the + other side sees a source it routes back instead of whatever the + kernel picks. IPv4 ones must be an address the router holds on + a LAN or a tunnel. + ''; + }; zone = mkOption { type = types.str; default = name; diff --git a/modules/router/wireguard.nix b/modules/router/wireguard.nix index 5399aff..b74d6aa 100644 --- a/modules/router/wireguard.nix +++ b/modules/router/wireguard.nix @@ -64,7 +64,19 @@ in 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); + routes = lib.concatMap ( + p: + map ( + ip: + let + sameFamily = lib.filter (s: lib.hasInfix ":" s == lib.hasInfix ":" ip) wg.sourceAddresses; + in + { + Destination = ip; + } + // lib.optionalAttrs (sameFamily != [ ]) { PreferredSource = builtins.head sameFamily; } + ) p.allowedIPs + ) (lib.attrValues wg.peers); } ) cfg.wireguard; }; diff --git a/tests/assertions.nix b/tests/assertions.nix index 7cc3984..1b97fa2 100644 --- a/tests/assertions.nix +++ b/tests/assertions.nix @@ -999,6 +999,40 @@ let router.lans.main = lan { ipv6.address = "192.168.1.1/24"; }; expect = [ "not an IPv6 address in CIDR" ]; }; + wgSourceOk = { + router = { + lans.main = lan { }; + wireguard.s2s = wg { sourceAddresses = [ "192.168.10.1" ]; }; + }; + expect = [ ]; + }; + wgSourceTwoV4 = { + router = { + lans.main = lan { }; + wireguard.s2s = wg { + addresses = [ "10.100.0.1/24" ]; + sourceAddresses = [ + "192.168.10.1" + "10.100.0.1" + ]; + }; + }; + expect = [ "at most one address per family" ]; + }; + wgSourceForeign = { + router = { + lans.main = lan { }; + wireguard.s2s = wg { sourceAddresses = [ "192.168.99.9" ]; }; + }; + expect = [ "must be the router's address on a LAN or a tunnel" ]; + }; + wgSourceGarbage = { + router = { + lans.main = lan { }; + wireguard.s2s = wg { sourceAddresses = [ "nonsense" ]; }; + }; + expect = [ "sourceAddresses must be IPv4/IPv6 addresses" ]; + }; nonOverlappingLans = { router.lans = { a = lan { }; @@ -1049,6 +1083,7 @@ let "sharedLinkValid" "pppTransportValid" "ulaValid" + "wgSourceOk" "dnsValid" ]; diff --git a/tests/wireguard.nix b/tests/wireguard.nix index 2194f2c..695ad4f 100644 --- a/tests/wireguard.nix +++ b/tests/wireguard.nix @@ -226,6 +226,8 @@ pkgs.testers.runNixOSTest { # No address on the tunnel: the site queries the LAN address and # is allowed because it routes 192.168.50.0/24 through the tunnel. dns = true; + # Router-originated traffic to the site uses the LAN address. + sourceAddresses = [ "192.168.10.1" ]; peers.siteb = { publicKey = key.siteb.pub; endpoint = "203.0.113.60:51821"; @@ -386,6 +388,11 @@ pkgs.testers.runNixOSTest { 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("site-to-site: router-originated traffic uses the configured source"): + # Without it the kernel would pick an address by itself (the tunnel has none). + router.succeed("ip route get 192.168.50.1 | grep -q 'src 192.168.10.1'") + router.succeed("ping -c 1 -W 2 192.168.50.1") + with subtest("site-to-site: the remote site can use the router's resolver"): siteb.succeed("dig +short +time=2 +tries=1 -b 192.168.50.1 @192.168.10.1 example.test | grep -qx 203.0.113.1")