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
+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;
};
}