Files
nixos-router/tests/wireguard.nix
nilsandClaude Sonnet 5.5 5572172904 Re-resolve WireGuard peers' endpoint names
systemd-networkd resolves an endpoint name once, when it creates the
device. A peer behind a dynamic DNS name that changes its address was
never found again until the next restart.

A timer (router.wireguardEndpointRefresh, default 1min, null for off)
runs wg set for the peers with a name whose last handshake is older than
135 seconds, as wireguard-tools' reresolve-dns does. Working tunnels are
left alone.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
2026-11-08 18:17:01 +01:00

461 lines
16 KiB
Nix

# 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 [ ];
# Names for peer endpoints, so the test can move them.
networking.hosts = {
"203.0.113.60" = [ "siteb.test" ];
"203.0.113.70" = [ "ghost.test" ];
};
environment.etc = lib.listToAttrs (
map
(n: {
name = "wg/${n}.key";
value = {
text = key."router-${n}".priv;
mode = "0600";
};
})
[
"rw"
"s2s"
"ff"
]
)
// {
# A tunnel to a peer that does not exist: it never has a handshake.
"wg/ghost.key" = {
text = key.router-rw.priv;
mode = "0600";
};
};
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";
# 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 = "siteb.test:51821";
allowedIPs = [ "192.168.50.0/24" ];
persistentKeepalive = 5;
};
};
ghost = {
privateKeyFile = "/etc/wg/ghost.key";
zone = "ghost";
peers.nobody = {
publicKey = key.laptop.pub;
endpoint = "ghost.test:51830";
allowedIPs = [ "192.168.77.0/24" ];
};
};
# 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("peer endpoint names are looked up again, but only for peers without a handshake"):
router.succeed("systemctl is-enabled router-wireguard-reresolve.timer")
router.succeed("wg show wg-ghost endpoints | grep -q 203.0.113.70:51830")
# The names now have other addresses.
router.succeed("rm /etc/hosts; printf '127.0.0.1 localhost\\n203.0.113.71 ghost.test\\n203.0.113.99 siteb.test\\n' > /etc/hosts")
router.succeed("systemctl start router-wireguard-reresolve.service")
router.succeed("wg show wg-ghost endpoints | grep -q 203.0.113.71:51830")
router.succeed("journalctl -u router-wireguard-reresolve --no-pager | grep -q 'endpoint 203.0.113.70:51830 -> 203.0.113.71:51830'")
# The working tunnel keeps its endpoint (it has a fresh handshake).
router.succeed("wg show wg-s2s endpoints | grep -q 203.0.113.60:51821")
# A name that does not resolve leaves the endpoint as it was.
router.succeed("printf '127.0.0.1 localhost\\n' > /etc/hosts")
router.succeed("systemctl start router-wireguard-reresolve.service")
router.succeed("wg show wg-ghost endpoints | grep -q 203.0.113.71:51830")
router.succeed("journalctl -u router-wireguard-reresolve --no-pager | grep -q 'ghost.test:51830'")
router.succeed("rm /etc/hosts; ln -s /etc/static/hosts /etc/hosts")
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("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")
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/")
'';
}