Add the home gateway config and tunnel source addresses

- wireguard.<n>.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 <noreply@anthropic.com>
This commit is contained in:
2026-10-28 16:53:14 +01:00
co-authored by Claude Sonnet 5.5
parent c395a43a78
commit 406196adce
8 changed files with 322 additions and 5 deletions
+5 -4
View File
@@ -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.<n>.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.<n>.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-<name>` 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)
+213
View File
@@ -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;
};
}
+14
View File
@@ -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 -- <flake-attr> <user@host>`: 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
+22
View File
@@ -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.";
+13
View File
@@ -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;
+13 -1
View File
@@ -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;
};
+35
View File
@@ -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"
];
+7
View File
@@ -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")