Add WireGuard tunnels as firewall zone members

router.wireguard.<name> configures tunnels through networkd: road warrior
(listening, dual-stack), site-to-site (no transfer network) and provider
dial-out with masquerade. A tunnel has a zone like a LAN, so inter-zone rules
apply; management, DNS and internet are opt-in. Peers' allowedIPs become
routes (destination-based routing; default-route tunnels are rejected).

Tunnels have a kill switch (default on): forwarded packets for their
allowedIPs are dropped if they would leave via an uplink, before the
established-accept so flows that used the tunnel are cut too.

Private keys stay outside the Nix store and are passed to the unprivileged
systemd-networkd with LoadCredential, so key files can be root-only.

Assertions cover CIDR/key/endpoint formats, duplicate allowedIPs, overlaps
with LAN subnets, port and device clashes, and rule destinations inside
tunnel networks. A five-VM `wireguard` check covers road warrior, site-to-site,
the provider tunnel with an IPv6 transfer net, zone rules and the kill switch.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-27 19:56:45 +01:00
co-authored by Claude Sonnet 5.5
parent 6015ee2e6d
commit 30ab1a0ed6
11 changed files with 975 additions and 26 deletions
+12 -1
View File
@@ -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.<name>`.
- **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.
+114 -11
View File
@@ -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
+1
View File
@@ -4,6 +4,7 @@
./assertions.nix
./networking.nix
./pppoe.nix
./wireguard.nix
./firewall.nix
./dhcp.nix
./dns.nix
+18 -2
View File
@@ -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 != [ ]) {
+61 -11
View File
@@ -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"}
}
'';
};
+126
View File
@@ -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 = ''
+71
View File
@@ -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;
};
}
+162 -1
View File
@@ -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 =
+1
View File
@@ -10,6 +10,7 @@
netcat-openbsd
curl
tcpdump
wireguard-tools
];
};
+1
View File
@@ -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; };
}
+408
View File
@@ -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/")
'';
}