The firewall page now manages zones (assign each LAN or tunnel, add and remove zones) and rules with zone, address and port on both sides plus the protocol. router.firewall.zones declares zones without members and rules take sourcePorts. The temperature chart draws every thermal zone and hwmon sensor; the overview defaults to 15 minutes, 1h stays an option. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
1161 lines
27 KiB
Nix
1161 lines
27 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" ];
|
|
};
|
|
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 = [ 8080 ];
|
|
dnat = true;
|
|
toPort = 8081;
|
|
sources = [ "198.51.100.0/24" ];
|
|
rateLimit = "3/minute";
|
|
log = true;
|
|
hairpin = 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" ];
|
|
};
|
|
dnatNeedsHost = {
|
|
router = {
|
|
lans.main = lan { };
|
|
firewall.rules = [
|
|
{
|
|
from = "wan";
|
|
to = "main";
|
|
destination = "192.168.10.0/24";
|
|
protocol = "tcp";
|
|
ports = [ 8080 ];
|
|
dnat = true;
|
|
}
|
|
];
|
|
};
|
|
expect = [ "dnat needs a single IPv4 host" ];
|
|
};
|
|
dnatV6Sources = {
|
|
router = {
|
|
lans.main = lan { };
|
|
firewall.rules = [
|
|
{
|
|
from = "wan";
|
|
to = "main";
|
|
destination = "192.168.10.5";
|
|
protocol = "tcp";
|
|
ports = [ 8080 ];
|
|
dnat = true;
|
|
sources = [ "2001:db8::/32" ];
|
|
}
|
|
];
|
|
};
|
|
expect = [ "its sources must be IPv4" ];
|
|
};
|
|
toPortWithoutDnat = {
|
|
router = {
|
|
lans.main = lan { };
|
|
firewall.rules = [
|
|
{
|
|
from = "wan";
|
|
to = "main";
|
|
destination = "192.168.10.5";
|
|
protocol = "tcp";
|
|
ports = [ 8080 ];
|
|
toPort = 8081;
|
|
}
|
|
];
|
|
};
|
|
expect = [ "toPort needs dnat" ];
|
|
};
|
|
hairpinNoDnat = {
|
|
router = {
|
|
lans.main = lan { };
|
|
firewall.rules = [
|
|
{
|
|
from = "wan";
|
|
to = "main";
|
|
destination = "192.168.10.5";
|
|
protocol = "tcp";
|
|
ports = [ 8080 ];
|
|
hairpin = true;
|
|
}
|
|
];
|
|
};
|
|
expect = [
|
|
"hairpin only applies to dnat"
|
|
"hairpin needs at least one"
|
|
];
|
|
};
|
|
hairpinNoInternet = {
|
|
router = {
|
|
lans.main = lan { };
|
|
firewall.rules = [
|
|
{
|
|
from = "wan";
|
|
to = "main";
|
|
destination = "192.168.10.5";
|
|
protocol = "tcp";
|
|
ports = [ 8080 ];
|
|
dnat = true;
|
|
hairpin = true;
|
|
}
|
|
];
|
|
};
|
|
expect = [ "hairpin needs at least one" ];
|
|
};
|
|
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
|
|
''
|
|
)
|