Files
nixos-router/modules/router/assertions.nix
T
nilsandClaude Sonnet 5.5 30ab1a0ed6 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>
2026-10-27 19:56:45 +01:00

294 lines
10 KiB
Nix

{ config, lib, ... }:
let
cfg = config.router;
ipv4 = (import ./lib.nix { inherit lib; }).ipv4;
lansByName = cfg.lans;
lans = lib.attrValues lansByName;
validIp = s: ipv4.parse s != null;
# Assertions about a single LAN; skipped (reported once) when its addresses
# do not even parse, so we never feed null into the integer helpers.
perLan =
name: lan:
let
where = "router.lans.${name}";
addrOk = validIp lan.ipv4.address;
startOk = validIp lan.dhcp.pool.start;
endOk = validIp lan.dhcp.pool.end;
addr = ipv4.parse lan.ipv4.address;
start = ipv4.parse lan.dhcp.pool.start;
end = ipv4.parse lan.dhcp.pool.end;
net = {
address = addr;
inherit (lan.ipv4) prefixLength;
};
parsed = addrOk && startOk && endOk;
in
[
{
assertion = addrOk;
message = "router: ${where}.ipv4.address `${lan.ipv4.address}` is not a valid IPv4 address.";
}
{
# IFNAMSIZ is 16 including the terminating NUL.
assertion = lib.stringLength lan.device <= 15;
message = "router: ${where}: device name `${lan.device}` is longer than 15 characters.";
}
]
++ lib.optionals lan.dhcp.enable (
[
{
assertion = startOk && endOk;
message = "router: ${where}.dhcp.pool.start/end must be valid IPv4 addresses.";
}
]
++ lib.optionals parsed [
{
assertion = start <= end;
message = "router: ${where}.dhcp.pool starts after it ends.";
}
{
assertion = ipv4.inSubnet net start && ipv4.inSubnet net end;
message = "router: ${where}.dhcp.pool is not inside ${lan.ipv4.subnet}.";
}
{
assertion = !(start <= addr && addr <= end);
message = "router: ${where}.dhcp.pool contains the router's own address ${lan.ipv4.address}.";
}
]
);
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;
inZone =
dest != null
&& lib.any (n: dest.prefixLength >= n.prefixLength && ipv4.inSubnet n dest.address) (
zoneV4Nets r.to
);
in
[
{
assertion = lib.elem r.from zoneNames;
message = "router: ${where}: unknown source zone `${r.from}` (zones: ${lib.concatStringsSep ", " zoneNames}).";
}
{
assertion = lib.elem r.to zoneNames;
message = "router: ${where}: unknown destination zone `${r.to}` (zones: ${lib.concatStringsSep ", " zoneNames}).";
}
{
assertion = r.from != r.to;
message = "router: ${where}: source and destination are the same zone; traffic within a zone is already allowed.";
}
{
assertion = r.ports == [ ] || r.protocol == "tcp" || r.protocol == "udp";
message = "router: ${where}: ports are only valid with protocol tcp or udp.";
}
]
++ lib.optionals (r.destination != null) [
{
assertion = dest != null;
message = "router: ${where}: destination `${r.destination}` is not a valid IPv4 address or CIDR.";
}
{
assertion = dest == null || !(lib.elem r.to zoneNames) || inZone;
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;
message = "router: ${where}: destination `${r.destination}` has host bits set.";
}
];
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:
map (b: {
inherit a b;
}) (lib.drop (i + 1) (lib.attrNames lansByName))
) (lib.attrNames lansByName)
);
in
{
config = lib.mkIf cfg.enable {
assertions = [
{
assertion = lans != [ ];
message = "router: at least one LAN must be defined.";
}
{
assertion = lib.any (l: l.allowManagement) lans;
message = "router: no LAN has allowManagement = true; you would be locked out of the router.";
}
{
assertion = !(lib.elem cfg.wan.interface (map (l: l.interface) lans));
message = "router: the WAN interface `${cfg.wan.interface}` is also used by a LAN.";
}
{
assertion = cfg.wan.ipv4.type != "pppoe" || cfg.wan.ipv4.pppoe.username != "";
message = "router: router.wan.ipv4.pppoe.username must be set for a PPPoE uplink.";
}
{
assertion = cfg.wan.ipv4.type != "pppoe" || cfg.wan.ipv4.pppoe.passwordFile != null;
message = "router: router.wan.ipv4.pppoe.passwordFile must be set for a PPPoE uplink.";
}
{
assertion = lib.length (lib.filter (l: l.ipv6.pd) lans) <= 1;
message = "router: more than one LAN sets ipv6.pd, but the delegated /64 cannot be split.";
}
{
assertion = !(lib.any (l: l.ipv6.pd) lans) || cfg.wan.ipv6.enable;
message = "router: a LAN sets ipv6.pd but router.wan.ipv6.enable is false.";
}
{
assertion =
let
devices = map (l: l.device) lans;
in
lib.length devices == lib.length (lib.unique devices);
message = "router: two LANs share the same device (same interface and VLAN).";
}
]
++ 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
la = lansByName.${a};
lb = lansByName.${b};
in
{
# Only comparable when both addresses parse; the per-LAN assertion reports the rest.
assertion =
!(validIp la.ipv4.address && validIp lb.ipv4.address)
|| !(ipv4.overlaps
{
address = ipv4.parse la.ipv4.address;
inherit (la.ipv4) prefixLength;
}
{
address = ipv4.parse lb.ipv4.address;
inherit (lb.ipv4) prefixLength;
}
);
message = "router: LANs `${a}` (${la.ipv4.subnet}) and `${b}` (${lb.ipv4.subnet}) overlap.";
}
) overlapPairs;
};
}