Files
nixos-router/tests/assertions.nix
T
nilsandClaude Sonnet 5.5 a58600823f Add a generic NAT table: port forward, source NAT, masquerade
router.firewall.nat replaces the dnat/toPort/hairpin fields of the filter
rules. Filter rules gain natOnly, and may use wan with log and rate limit
in the web UI. The Firewall page edits the NAT table and can add the
matching allow rule. Settings schema 1 (migrations/1.jq) converts old
forwards.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
2026-10-30 15:54:29 +01:00

1342 lines
32 KiB
Nix

# Evaluation-time tests: broken configs must fail with exactly the expected
# assertion messages, and a valid one with none.
{ self, pkgs }:
let
inherit (pkgs) lib;
evalRouter =
router:
(import "${pkgs.path}/nixos/lib/eval-config.nix" {
system = "x86_64-linux";
modules = [
self.nixosModules.router
{
boot.loader.grub.enable = false;
fileSystems."/" = {
device = "x";
fsType = "ext4";
};
system.stateVersion = "25.05";
router = lib.recursiveUpdate {
enable = true;
wan.interface = "eth0";
} router;
}
];
}).config;
lan =
overrides:
lib.recursiveUpdate {
interface = "eth1";
ipv4.address = "192.168.10.1";
dhcp.pool = {
start = "192.168.10.100";
end = "192.168.10.199";
};
} overrides;
lan2 =
overrides:
lan (
lib.recursiveUpdate {
interface = "eth2";
ipv4.address = "192.168.20.1";
dhcp.pool = {
start = "192.168.20.100";
end = "192.168.20.199";
};
} 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 = {
valid = {
router.lans.main = lan { };
expect = [ ];
};
noManagement = {
router.lans.main = lan { allowManagement = false; };
expect = [ "locked out" ];
};
wanIsLan = {
router.lans.main = lan { interface = "eth0"; };
expect = [ "WAN interface `eth0` is also used by a LAN" ];
};
sharedInterface = {
router.lans = {
a = lan { };
b = lan {
ipv4.address = "192.168.20.1";
dhcp.pool = {
start = "192.168.20.100";
end = "192.168.20.199";
};
};
};
expect = [ "two LANs share the same device" ];
};
badAddress = {
router.lans.main = lan {
ipv4.address = "192.168.10.300";
dhcp.enable = false;
};
expect = [ "`192.168.10.300` is not a valid IPv4 address" ];
};
poolOutsideSubnet = {
router.lans.main = lan {
dhcp.pool = {
start = "192.168.11.100";
end = "192.168.11.199";
};
};
expect = [ "pool is not inside 192.168.10.0/24" ];
};
poolReversed = {
router.lans.main = lan {
dhcp.pool = {
start = "192.168.10.199";
end = "192.168.10.100";
};
};
expect = [ "starts after it ends" ];
};
poolContainsRouter = {
router.lans.main = lan {
dhcp.pool = {
start = "192.168.10.1";
end = "192.168.10.50";
};
};
expect = [ "contains the router's own address 192.168.10.1" ];
};
overlappingLans = {
router.lans = {
a = lan { };
b = lan {
interface = "eth2";
ipv4.address = "192.168.10.2";
};
};
expect = [ "`a` (192.168.10.0/24) and `b` (192.168.10.0/24) overlap" ];
};
pdOnOneLan = {
router = {
wan.ipv6.enable = true;
lans.main = lan { ipv6.pd = true; };
};
expect = [ ];
};
pdOnTwoLans = {
router = {
wan.ipv6.enable = true;
lans = {
a = lan { ipv6.pd = true; };
b = lan {
interface = "eth2";
ipv4.address = "192.168.20.1";
dhcp.pool = {
start = "192.168.20.100";
end = "192.168.20.199";
};
ipv6.pd = true;
};
};
};
expect = [ "delegated /64 cannot be split" ];
};
pdWithoutWanIpv6 = {
router.lans.main = lan { ipv6.pd = true; };
expect = [ "router.wan.ipv6.enable is false" ];
};
pppoeMissingCredentials = {
router = {
wan.ipv4.type = "pppoe";
lans.main = lan { };
};
expect = [
"pppoe.username must be set"
"pppoe.passwordFile must be set"
];
};
pppoeValid = {
router = {
wan = {
ipv4 = {
type = "pppoe";
pppoe = {
vlan = 7;
username = "user@isp";
passwordFile = "/var/lib/secrets/pppoe";
};
};
ipv6.enable = true;
};
lans.main = lan { ipv6.pd = true; };
};
expect = [ ];
};
vlansOnOneTrunk = {
router.lans = {
main = lan { };
iot = lan2 {
interface = "eth1";
vlan = 20;
zone = "iot";
};
cams = lan {
interface = "eth1";
vlan = 30;
zone = "iot";
ipv4.address = "192.168.30.1";
dhcp.pool = {
start = "192.168.30.100";
end = "192.168.30.199";
};
};
};
expect = [ ];
};
sameVlanTwice = {
router.lans = {
a = lan { vlan = 20; };
b = lan2 {
interface = "eth1";
vlan = 20;
};
};
expect = [ "two LANs share the same device" ];
};
vlanOnWanParent = {
router.lans.main = lan {
interface = "eth0";
vlan = 10;
};
expect = [ "WAN interface `eth0` is also used by a LAN" ];
};
deviceNameTooLong = {
router.lans.main = lan {
interface = "enp1s0f1np1";
vlan = 4000;
};
expect = [ "longer than 15 characters" ];
};
ruleValid = {
router = {
lans = {
main = lan { };
iot = lan2 { zone = "iot"; };
};
firewall.rules = [
{
from = "iot";
to = "main";
protocol = "tcp";
ports = [
445
8080
];
destination = "192.168.10.5";
}
{
from = "main";
to = "iot";
}
];
};
expect = [ ];
};
ruleUnknownZones = {
router = {
lans.main = lan { };
firewall.rules = [
{
from = "nope";
to = "main";
}
{
from = "main";
to = "ghost";
}
];
};
expect = [
"unknown source zone `nope`"
"unknown destination zone `ghost`"
];
};
ruleSameZone = {
router = {
lans = {
main = lan { zone = "t"; };
other = lan2 { zone = "t"; };
};
firewall.rules = [
{
from = "t";
to = "t";
}
];
};
expect = [ "source and destination are the same zone" ];
};
rulePortsWithoutProtocol = {
router = {
lans = {
main = lan { };
iot = lan2 { };
};
firewall.rules = [
{
from = "iot";
to = "main";
protocol = "icmp";
ports = [ 22 ];
}
];
};
expect = [ "ports are only valid with protocol tcp or udp" ];
};
ddnsValid = {
router = {
lans.main = lan { };
ddns = {
enable = true;
domains = [ "home.example.org" ];
passwordFile = "/var/lib/router/ddns-secret";
};
};
expect = [ ];
};
ddnsSecretInTheStore = {
router = {
lans.main = lan { };
ddns = {
enable = true;
domains = [ "home.example.org" ];
passwordFile = "/nix/store/xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx-secret";
};
};
expect = [ "outside the Nix store" ];
};
ddnsWithoutNames = {
router = {
lans.main = lan { };
ddns = {
enable = true;
passwordFile = "/var/lib/router/ddns-secret";
};
};
expect = [ "no domains to update" ];
};
ddnsWithoutAnyFamily = {
router = {
lans.main = lan { };
ddns = {
enable = true;
domains = [ "home.example.org" ];
passwordFile = "/var/lib/router/ddns-secret";
ipv4 = false;
};
};
expect = [ "neither IPv4 nor IPv6" ];
};
ruleSourcePortsWithoutProtocol = {
router = {
lans = {
main = lan { };
iot = lan2 { };
};
firewall.rules = [
{
from = "iot";
to = "main";
sourcePorts = [ 53 ];
}
];
};
expect = [ "sourcePorts are only valid with protocol tcp or udp" ];
};
ruleSourcePortOutOfRange = {
router = {
lans = {
main = lan { };
iot = lan2 { };
};
firewall.rules = [
{
from = "iot";
to = "main";
protocol = "udp";
sourcePorts = [ "2000-1000" ];
}
];
};
expect = [ "out of range" ];
};
ruleToDeclaredEmptyZone = {
router = {
lans.main = lan { };
firewall = {
zones = [ "guests" ];
rules = [
{
from = "main";
to = "guests";
protocol = "tcp";
sourcePorts = [ 1024 ];
}
];
};
};
expect = [ ];
};
ruleDestinationInvalid = {
router = {
lans = {
main = lan { };
iot = lan2 { };
};
firewall.rules = [
{
from = "iot";
to = "main";
destination = "192.168.10.999";
}
];
};
expect = [ "not a valid IPv4 address or CIDR" ];
};
ruleDestinationOutsideZone = {
router = {
lans = {
main = lan { };
iot = lan2 { };
};
firewall.rules = [
{
from = "iot";
to = "main";
destination = "192.168.20.5";
}
];
};
expect = [ "is not inside any network of zone `main`" ];
};
ruleDestinationHostBits = {
router = {
lans = {
main = lan { };
iot = lan2 { };
};
firewall.rules = [
{
from = "iot";
to = "main";
destination = "192.168.10.5/24";
}
];
};
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`" ];
};
wanValid = {
router = {
lans.main = lan { internet = true; };
firewall.rules = [
{
from = "wan";
to = "main";
destination = "192.168.10.5";
protocol = "tcp";
ports = [ 8081 ];
natOnly = true;
sources = [ "198.51.100.0/24" ];
rateLimit = "3/minute";
log = true;
}
{
from = "wan";
to = "main";
destination = "2001:db8:1::5";
protocol = "tcp";
ports = [ "9000-9002" ];
}
];
};
expect = [ ];
};
wanNoDestination = {
router = {
lans.main = lan { };
firewall.rules = [
{
from = "wan";
to = "main";
destination = null;
protocol = "tcp";
ports = [ 8080 ];
}
];
};
expect = [ "must name a destination" ];
};
wanAnyProtocol = {
router = {
lans.main = lan { };
firewall.rules = [
{
from = "wan";
to = "main";
destination = "192.168.10.5";
protocol = "any";
ports = [ ];
}
];
};
expect = [ "must name a protocol" ];
};
wanNoPorts = {
router = {
lans.main = lan { };
firewall.rules = [
{
from = "wan";
to = "main";
destination = "192.168.10.5";
protocol = "tcp";
ports = [ ];
}
];
};
expect = [ "must list the ports" ];
};
toWan = {
router = {
lans.main = lan { };
firewall.rules = [
{
from = "main";
to = "wan";
}
];
};
expect = [ "`wan` cannot be a destination" ];
};
natDnatNeedsHost = {
router = {
lans.main = lan { };
firewall.nat = [
{
type = "dnat";
from = "wan";
to = "main";
protocol = "tcp";
ports = [ 8080 ];
}
];
};
expect = [ "dnat and snat need a toAddress" ];
};
natDnatHostOutsideZone = {
router = {
lans.main = lan { };
firewall.nat = [
{
type = "dnat";
from = "wan";
to = "main";
protocol = "tcp";
ports = [ 8080 ];
toAddress = "10.9.9.9";
}
];
};
expect = [ "is not inside any network of zone `main`" ];
};
natV6Sources = {
router = {
lans.main = lan { };
firewall.nat = [
{
type = "dnat";
from = "wan";
to = "main";
protocol = "tcp";
ports = [ 8080 ];
toAddress = "192.168.10.5";
sources = [ "2001:db8::/32" ];
}
];
};
expect = [ "translation is IPv4 only" ];
};
natToPortNeedsOnePort = {
router = {
lans.main = lan { };
firewall.nat = [
{
type = "dnat";
from = "wan";
to = "main";
protocol = "tcp";
ports = [
8080
8081
];
toAddress = "192.168.10.5";
toPort = 80;
}
];
};
expect = [ "toPort needs exactly one" ];
};
natToPortOnMasquerade = {
router = {
lans = {
main = lan { };
iot = lan2 { };
};
firewall.nat = [
{
type = "masquerade";
from = "iot";
to = "main";
toPort = 80;
}
];
};
expect = [ "toPort and hairpin only apply to port forwards" ];
};
natHairpinFromLan = {
router = {
lans = {
main = lan { internet = true; };
iot = lan2 { };
};
firewall.nat = [
{
type = "dnat";
from = "iot";
to = "main";
protocol = "tcp";
ports = [ 8080 ];
toAddress = "192.168.10.5";
hairpin = true;
}
];
};
expect = [ "hairpin only applies to forwards from `wan`" ];
};
natHairpinNoInternet = {
router = {
lans.main = lan { };
firewall.nat = [
{
type = "dnat";
from = "wan";
to = "main";
protocol = "tcp";
ports = [ 8080 ];
toAddress = "192.168.10.5";
hairpin = true;
}
];
};
expect = [ "hairpin needs at least one" ];
};
natMasqueradeWithAddress = {
router = {
lans.main = lan { };
firewall.nat = [
{
type = "masquerade";
to = "wan";
toAddress = "203.0.113.5";
}
];
};
expect = [ "dnat and snat need a toAddress; masquerade has none" ];
};
natUnknownZones = {
router = {
lans.main = lan { };
firewall.nat = [
{
type = "snat";
from = "ghost";
to = "nowhere";
toAddress = "203.0.113.5";
}
];
};
expect = [
"unknown source zone `ghost`"
"unknown zone `nowhere`"
];
};
natDnatToWan = {
router = {
lans.main = lan { };
firewall.nat = [
{
type = "dnat";
from = "main";
to = "wan";
protocol = "tcp";
ports = [ 80 ];
toAddress = "192.168.10.5";
}
];
};
expect = [ "unknown zone `wan`" ];
};
natValid = {
router = {
lans = {
main = lan { internet = true; };
iot = lan2 { };
};
firewall = {
rules = [
{
from = "wan";
to = "main";
destination = "192.168.10.5";
protocol = "tcp";
ports = [ 8081 ];
natOnly = true;
rateLimit = "3/minute";
log = true;
}
];
nat = [
{
type = "dnat";
from = "wan";
to = "main";
protocol = "tcp";
ports = [ 8080 ];
sources = [ "198.51.100.0/24" ];
toAddress = "192.168.10.5";
toPort = 8081;
hairpin = true;
}
{
type = "dnat";
from = "iot";
to = "main";
protocol = "udp";
ports = [ "5000-5100" ];
destination = "192.168.20.1";
toAddress = "192.168.10.6";
}
{
type = "masquerade";
from = "iot";
to = "main";
}
{
type = "snat";
to = "wan";
sources = [ "192.168.10.0/24" ];
toAddress = "203.0.113.5";
}
];
};
};
expect = [ ];
};
badPortRange = {
router = {
lans.main = lan { };
firewall.rules = [
{
from = "wan";
to = "main";
destination = "192.168.10.5";
protocol = "tcp";
ports = [ "9002-9000" ];
}
];
};
expect = [ "out of range" ];
};
badSource = {
router = {
lans.main = lan { };
firewall.rules = [
{
from = "wan";
to = "main";
destination = "192.168.10.5";
protocol = "tcp";
ports = [ 8080 ];
sources = [ "nonsense" ];
}
];
};
expect = [ "every source must be" ];
};
badV6Destination = {
router = {
lans.main = lan { };
firewall.rules = [
{
from = "wan";
to = "main";
destination = "2001:db8::zz";
protocol = "tcp";
ports = [ 8080 ];
}
];
};
expect = [ "not a valid IPv6" ];
};
sharedLinkValid = {
router = {
wan = {
ipv4 = {
type = "pppoe";
pppoe = {
vlan = 1001;
username = "u";
passwordFile = "/var/lib/pw";
};
};
};
lans.main = lan { interface = "eth0"; };
};
expect = [ ];
};
sharedLinkIpv6 = {
router = {
wan = {
ipv4 = {
type = "pppoe";
pppoe = {
vlan = 1001;
username = "u";
passwordFile = "/var/lib/pw";
};
};
ipv6.enable = true;
};
lans.main = lan { interface = "eth0"; };
};
expect = [ "also used by a LAN" ];
};
sharedLinkUntaggedUplink = {
router = {
wan = {
ipv4.type = "dhcp";
};
lans.main = lan { interface = "eth0"; };
};
expect = [ "also used by a LAN" ];
};
sharedLinkVlanClash = {
router = {
wan = {
ipv4 = {
type = "pppoe";
pppoe = {
vlan = 1001;
username = "u";
passwordFile = "/var/lib/pw";
};
};
};
lans.main = lan {
interface = "eth0";
vlan = 1001;
};
};
expect = [ "used by both the PPPoE uplink and a LAN" ];
};
dnsValid = {
router = {
lans.main = lan {
dhcp.reservations = {
nas = {
mac = "a8:a1:59:0b:4b:97";
address = "192.168.10.20";
};
};
};
dns = {
domain = "home.example.org";
hosts = {
printer = [ "192.168.10.30" ];
"git.example.org" = [
"10.43.0.50"
"fd00::50"
];
};
overrides."k8s.example.org" = [ "10.43.0.50" ];
forwardZones."branch.example.org" = [ "10.1.0.254" ];
};
};
expect = [ ];
};
dnsShortNoDomain = {
router = {
lans.main = lan { dhcp.reservations = { }; };
dns = {
hosts.printer = [ "192.168.10.30" ];
};
};
expect = [ "router.dns.domain must be set" ];
};
dnsBadName = {
router = {
lans.main = lan { dhcp.reservations = { }; };
dns = {
domain = "home.example.org";
hosts."bad name" = [ "192.168.10.30" ];
};
};
expect = [ "not a valid DNS name" ];
};
dnsBadAddress = {
router = {
lans.main = lan { dhcp.reservations = { }; };
dns = {
domain = "home.example.org";
hosts.x = [ "192.168.10.300" ];
};
};
expect = [ "valid IPv4 or IPv6 address" ];
};
dnsBadZone = {
router = {
lans.main = lan { dhcp.reservations = { }; };
dns = {
forwardZones."a.example.org" = [ ];
};
};
expect = [ "valid resolver address" ];
};
dnsHostVsReservation = {
router = {
lans.main = lan {
dhcp.reservations = {
nas = {
mac = "a8:a1:59:0b:4b:97";
address = "192.168.10.20";
};
};
};
dns = {
domain = "home.example.org";
hosts.nas = [ "192.168.10.99" ];
};
};
expect = [ "also a DHCP reservation" ];
};
resBadMac = {
router = {
lans.main = lan {
dhcp.reservations = {
nas = {
mac = "nonsense";
address = "192.168.10.20";
};
};
};
dns = { };
};
expect = [ "is not a MAC address" ];
};
resOutside = {
router = {
lans.main = lan {
dhcp.reservations = {
nas = {
mac = "a8:a1:59:0b:4b:97";
address = "192.168.99.20";
};
};
};
dns = { };
};
expect = [ "is not inside" ];
};
resRouterAddress = {
router = {
lans.main = lan {
dhcp.reservations = {
nas = {
mac = "a8:a1:59:0b:4b:97";
address = "192.168.10.1";
};
};
};
dns = { };
};
expect = [ "router's own address" ];
};
resDuplicates = {
router = {
lans.main = lan {
dhcp.reservations = {
a = {
mac = "a8:a1:59:0b:4b:97";
address = "192.168.10.20";
};
b = {
mac = "A8:A1:59:0B:4B:97";
address = "192.168.10.20";
};
};
};
dns = { };
};
expect = [
"share a MAC address"
"share an address"
];
};
resBadHostname = {
router = {
lans.main = lan {
dhcp.reservations = {
"bad name" = {
mac = "a8:a1:59:0b:4b:97";
address = "192.168.10.20";
};
};
};
dns = { };
};
expect = [ "not a valid hostname" ];
};
pppTransportValid = {
router = {
wan = {
ipv4 = {
type = "pppoe";
pppoe = {
vlan = 1001;
username = "u";
passwordFile = "/var/lib/pw";
};
};
ipv6 = {
enable = true;
transport = "ppp";
};
};
lans.main = lan {
interface = "eth0";
ipv6.pd = true;
};
};
expect = [ ];
};
pppTransportNeedsPppoe = {
router = {
wan = {
ipv4.type = "dhcp";
ipv6 = {
enable = true;
transport = "ppp";
};
};
lans.main = lan { interface = "eth0"; };
};
expect = [
"needs a PPPoE uplink"
"also used by a LAN"
];
};
ulaValid = {
router.lans.main = lan { ipv6.address = "fd2c:cdef:cde9::1/64"; };
expect = [ ];
};
ulaNotUla = {
router.lans.main = lan { ipv6.address = "2001:db8::1/64"; };
expect = [ "unique-local" ];
};
ulaNot64 = {
router.lans.main = lan { ipv6.address = "fd2c:cdef:cde9::1/48"; };
expect = [ "must be a /64" ];
};
ulaGarbage = {
router.lans.main = lan { ipv6.address = "nonsense"; };
expect = [ "not an IPv6 address in CIDR" ];
};
ulaIpv4 = {
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 { };
b = lan {
interface = "eth2";
ipv4.address = "192.168.20.1";
dhcp.pool = {
start = "192.168.20.100";
end = "192.168.20.199";
};
};
};
expect = [ ];
};
};
problems = lib.concatLists (
lib.mapAttrsToList (
name: c:
let
got = failed c.router;
missing = lib.filter (e: !lib.any (m: lib.hasInfix e m) got) c.expect;
in
lib.optional (
missing != [ ] || lib.length got != lib.length c.expect
) "${name}: expected ${builtins.toJSON c.expect}, got ${builtins.toJSON got}"
) cases
);
# Assertions alone do not force every option (e.g. networkd unit checks), so
# fully evaluate the system for the configs that are meant to be valid.
fullEval =
name:
let
r = builtins.tryEval (
builtins.seq (evalRouter cases.${name}.router).system.build.toplevel.drvPath true
);
in
lib.optional (!r.success) "${name}: full system evaluation failed";
fullEvalProblems = lib.concatMap fullEval [
"valid"
"pdOnOneLan"
"pppoeValid"
"vlansOnOneTrunk"
"ruleValid"
"wgValid"
"wanValid"
"sharedLinkValid"
"pppTransportValid"
"ulaValid"
"wgSourceOk"
"dnsValid"
];
subnet =
(evalRouter {
lans.main = lan {
ipv4.prefixLength = 20;
ipv4.address = "172.16.37.1";
dhcp.enable = false;
};
}).router.lans.main.ipv4.subnet;
subnetProblem = lib.optional (
subnet != "172.16.32.0/20"
) "derived subnet: expected 172.16.32.0/20, got ${subnet}";
in
pkgs.runCommand "router-assertion-tests" { } (
if problems ++ subnetProblem ++ fullEvalProblems == [ ] then
"touch $out"
else
''
printf '%s\n' ${lib.escapeShellArgs (problems ++ subnetProblem ++ fullEvalProblems)} >&2
exit 1
''
)