Web UI: dark/light themes (appliance layout, sidebar, switch checkboxes), theme preference on the Advanced page, Changes page with the pending diff. Interface names: router.interfaces.<name>.mac, lans.<l>.deviceName and pppoe.vlanName; a rename is staged for the next reboot by router-apply. Remote access: an MCP server (POST /mcp) and an OpenAPI REST API (/api/v1, /api/openapi.json) with the same tokens, scopes, limits and audit log; tools to read status, settings, leases, logs (get_logs), and to edit and check the saved settings (never applied). Tokens on the API access page. Firewall: a reserved `local` zone (the router itself) as the destination of rules, so the web UI or SSH can be allowed from the WAN. PLAN.md lists what is still left. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
1595 lines
38 KiB
Nix
1595 lines
38 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" ];
|
|
};
|
|
namesValid = {
|
|
router = {
|
|
interfaces = {
|
|
wan.mac = "52:54:00:12:34:56";
|
|
lan.mac = "52:54:00:12:34:57";
|
|
};
|
|
wan.interface = "wan";
|
|
lans = {
|
|
main = lan { interface = "lan"; };
|
|
dmz = lan2 {
|
|
interface = "lan";
|
|
vlan = 1002;
|
|
deviceName = "dmz";
|
|
};
|
|
};
|
|
};
|
|
expect = [ ];
|
|
};
|
|
nameKernelLike = {
|
|
router.lans.main = lan { };
|
|
router.interfaces = {
|
|
eth7.mac = "52:54:00:12:34:56";
|
|
wlp1s0.mac = "52:54:00:12:34:57";
|
|
};
|
|
expect = [ "do not look like a kernel name" ];
|
|
};
|
|
nameTooLong = {
|
|
router.lans.main = lan { };
|
|
router.interfaces.a-very-long-interface-name.mac = "52:54:00:12:34:56";
|
|
expect = [ "at most 15 characters" ];
|
|
};
|
|
sameMacTwice = {
|
|
router.lans.main = lan { };
|
|
router.interfaces = {
|
|
wan.mac = "52:54:00:12:34:56";
|
|
lan.mac = "52:54:00:12:34:56";
|
|
};
|
|
expect = [ "same MAC address" ];
|
|
};
|
|
vlanNameClash = {
|
|
router = {
|
|
interfaces.dmz.mac = "52:54:00:12:34:56";
|
|
lans.a = lan {
|
|
vlan = 20;
|
|
deviceName = "dmz";
|
|
};
|
|
};
|
|
expect = [ "used twice among `interfaces`" ];
|
|
};
|
|
deviceNameWithoutVlan = {
|
|
router.lans.main = lan { deviceName = "dmz"; };
|
|
expect = [ "deviceName needs a vlan" ];
|
|
};
|
|
pppoeVlanNameWithoutVlan = {
|
|
router.lans.main = lan { };
|
|
router.wan.ipv4.pppoe.vlanName = "isp";
|
|
expect = [ "vlanName needs pppoe.vlan" ];
|
|
};
|
|
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 { };
|
|
dnsProviders.cf = {
|
|
type = "cloudflare";
|
|
secretFile = "/var/lib/router/dns-secrets/cf";
|
|
};
|
|
ddns = {
|
|
enable = true;
|
|
provider = "cf";
|
|
domains = [ "home.example.org" ];
|
|
};
|
|
};
|
|
expect = [ ];
|
|
};
|
|
ddnsSecretInTheStore = {
|
|
router = {
|
|
lans.main = lan { };
|
|
dnsProviders.cf = {
|
|
type = "cloudflare";
|
|
secretFile = "/nix/store/xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx-secret";
|
|
};
|
|
ddns = {
|
|
enable = true;
|
|
provider = "cf";
|
|
domains = [ "home.example.org" ];
|
|
};
|
|
};
|
|
expect = [ "outside the Nix store" ];
|
|
};
|
|
ddnsWithoutProvider = {
|
|
router = {
|
|
lans.main = lan { };
|
|
ddns = {
|
|
enable = true;
|
|
provider = "nothing";
|
|
domains = [ "home.example.org" ];
|
|
};
|
|
};
|
|
expect = [ "must name a connection in router.dnsProviders" ];
|
|
};
|
|
ddnsWithoutNames = {
|
|
router = {
|
|
lans.main = lan { };
|
|
dnsProviders.cf = {
|
|
type = "cloudflare";
|
|
secretFile = "/var/lib/router/dns-secrets/cf";
|
|
};
|
|
ddns = {
|
|
enable = true;
|
|
provider = "cf";
|
|
};
|
|
};
|
|
expect = [ "no domains to update" ];
|
|
};
|
|
ddnsWithoutAnyFamily = {
|
|
router = {
|
|
lans.main = lan { };
|
|
dnsProviders.cf = {
|
|
type = "cloudflare";
|
|
secretFile = "/var/lib/router/dns-secrets/cf";
|
|
};
|
|
ddns = {
|
|
enable = true;
|
|
provider = "cf";
|
|
domains = [ "home.example.org" ];
|
|
ipv4 = false;
|
|
};
|
|
};
|
|
expect = [ "neither IPv4 nor IPv6" ];
|
|
};
|
|
acmeValid = {
|
|
router = {
|
|
lans.main = lan { };
|
|
dnsProviders.cf = {
|
|
type = "cloudflare";
|
|
secretFile = "/var/lib/router/dns-secrets/cf";
|
|
};
|
|
acme = {
|
|
enable = true;
|
|
provider = "cf";
|
|
domains = [
|
|
"router.example.org"
|
|
"*.home.example.org"
|
|
];
|
|
email = "me@example.org";
|
|
acceptTerms = true;
|
|
};
|
|
};
|
|
expect = [ ];
|
|
};
|
|
acmeProviderCannotDoDNS01 = {
|
|
router = {
|
|
lans.main = lan { };
|
|
dnsProviders.dyn = {
|
|
type = "dyndns2";
|
|
secretFile = "/var/lib/router/dns-secrets/dyn";
|
|
};
|
|
acme = {
|
|
enable = true;
|
|
provider = "dyn";
|
|
domains = [ "router.example.org" ];
|
|
email = "me@example.org";
|
|
acceptTerms = true;
|
|
};
|
|
};
|
|
expect = [ "lego has no DNS provider dyndns2" ];
|
|
};
|
|
acmeEnvironmentProviderValid = {
|
|
router = {
|
|
lans.main = lan { };
|
|
dnsProviders.aws = {
|
|
type = "route53";
|
|
environmentFile = "/var/lib/router/dns-secrets/aws.env";
|
|
};
|
|
acme = {
|
|
enable = true;
|
|
provider = "aws";
|
|
domains = [ "router.example.org" ];
|
|
email = "me@example.org";
|
|
acceptTerms = true;
|
|
};
|
|
};
|
|
expect = [ ];
|
|
};
|
|
acmeEnvironmentProviderWithoutFile = {
|
|
router = {
|
|
lans.main = lan { };
|
|
dnsProviders.aws.type = "route53";
|
|
acme = {
|
|
enable = true;
|
|
provider = "aws";
|
|
domains = [ "router.example.org" ];
|
|
email = "me@example.org";
|
|
acceptTerms = true;
|
|
};
|
|
};
|
|
expect = [ "needs environmentFile for certificates" ];
|
|
};
|
|
ddnsProviderUnknownToDdclient = {
|
|
router = {
|
|
lans.main = lan { };
|
|
dnsProviders.aws = {
|
|
type = "route53";
|
|
secretFile = "/var/lib/router/dns-secrets/aws";
|
|
};
|
|
ddns = {
|
|
enable = true;
|
|
provider = "aws";
|
|
domains = [ "home.example.org" ];
|
|
};
|
|
};
|
|
expect = [ "ddclient has no protocol route53" ];
|
|
};
|
|
acmeTermsNotAccepted = {
|
|
router = {
|
|
lans.main = lan { };
|
|
dnsProviders.cf = {
|
|
type = "cloudflare";
|
|
secretFile = "/var/lib/router/dns-secrets/cf";
|
|
};
|
|
acme = {
|
|
enable = true;
|
|
provider = "cf";
|
|
domains = [ "router.example.org" ];
|
|
email = "me@example.org";
|
|
};
|
|
};
|
|
expect = [ "subscriber agreement must be accepted" ];
|
|
};
|
|
acmeWithoutProvider = {
|
|
router = {
|
|
lans.main = lan { };
|
|
acme = {
|
|
enable = true;
|
|
domains = [ "router.example.org" ];
|
|
email = "me@example.org";
|
|
acceptTerms = true;
|
|
};
|
|
};
|
|
expect = [ "must name a connection in router.dnsProviders" ];
|
|
};
|
|
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 = [ ];
|
|
};
|
|
localValid = {
|
|
router = {
|
|
lans.main = lan { };
|
|
firewall.rules = [
|
|
{
|
|
from = "wan";
|
|
to = "local";
|
|
protocol = "tcp";
|
|
ports = [ 22 ];
|
|
sources = [ "198.51.100.0/24" ];
|
|
rateLimit = "5/minute";
|
|
}
|
|
{
|
|
from = "main";
|
|
to = "local";
|
|
protocol = "tcp";
|
|
ports = [ 8443 ];
|
|
}
|
|
];
|
|
};
|
|
expect = [ ];
|
|
};
|
|
localNatOnly = {
|
|
router = {
|
|
lans.main = lan { };
|
|
firewall.rules = [
|
|
{
|
|
from = "wan";
|
|
to = "local";
|
|
protocol = "tcp";
|
|
ports = [ 22 ];
|
|
natOnly = true;
|
|
}
|
|
];
|
|
};
|
|
expect = [ "natOnly does not apply to the router itself" ];
|
|
};
|
|
localNoPorts = {
|
|
router = {
|
|
lans.main = lan { };
|
|
firewall.rules = [
|
|
{
|
|
from = "wan";
|
|
to = "local";
|
|
protocol = "tcp";
|
|
}
|
|
];
|
|
};
|
|
expect = [ "must list the ports they open" ];
|
|
};
|
|
localZoneName = {
|
|
router = {
|
|
lans.main = lan { zone = "local"; };
|
|
};
|
|
expect = [ "`local` is a reserved zone name" ];
|
|
};
|
|
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"
|
|
"namesValid"
|
|
"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
|
|
''
|
|
)
|