Files
nixos-router/tests/basic.nix
T
nilsandClaude Sonnet 5.5 96e5b7b859 Derive LAN subnets and validate addressing at build time
Add an IPv4 helper lib (parse, netmask, cidr, overlap) and derive
router.lans.<name>.ipv4.subnet from address and prefixLength instead of
requiring it twice. New assertions reject invalid addresses, DHCP pools that
are reversed, outside the subnet or contain the router address, and
overlapping LANs.

Add `lib` unit tests and an `assertions` check that evaluates broken configs
and compares the exact error messages.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 15:00:24 +01:00

133 lines
5.2 KiB
Nix

# Single WAN (DHCP) + single LAN: DHCP, DNS, NAT and the default firewall policy.
#
# upstream (ISP, 203.0.113.1) -- vlan 1 -- router -- vlan 2 -- client
{ self, pkgs }:
let
# `fail` in the test script also passes on "command not found", so make sure
# the tools the script relies on are really there.
tools = { pkgs, ... }: {
environment.systemPackages = with pkgs; [ dnsutils netcat-openbsd curl tcpdump ];
};
in
pkgs.testers.runNixOSTest {
name = "router-basic";
defaults.imports = [ tools ];
# `driverInteractive` uses the full QEMU, which would open a window per VM;
# `nix run .#lab` wants the consoles in the terminal instead.
interactive.defaults.virtualisation.qemu.options = [ "-display" "none" ];
nodes = {
# Plays the ISP: hands out one WAN lease, answers DNS, and serves a page
# that echoes the client address it sees (to prove NAT happens).
upstream = { lib, ... }: {
virtualisation.vlans = [ 1 ];
networking.useDHCP = false;
networking.firewall.enable = false;
networking.interfaces.eth1.ipv4 = {
addresses = lib.mkForce [ { address = "203.0.113.1"; prefixLength = 24; } ];
# Route back to the LAN via the router so that "unreachable" below
# is due to the firewall and not a missing route.
routes = [ { address = "192.168.10.0"; prefixLength = 24; via = "203.0.113.100"; } ];
};
services.dnsmasq = {
enable = true;
settings = {
interface = "eth1";
bind-interfaces = true;
dhcp-range = "203.0.113.100,203.0.113.100,255.255.255.0,1h";
dhcp-option = [ "option:router,203.0.113.1" ];
address = "/example.test/203.0.113.1";
};
};
services.nginx = {
enable = true;
virtualHosts."example.test".locations."/".return = ''200 "$remote_addr"'';
};
};
router = { lib, ... }: {
imports = [ self.nixosModules.router ];
virtualisation.vlans = [ 1 2 ];
# The test driver preconfigures static addresses on every vlan interface.
networking.interfaces.eth1.ipv4.addresses = lib.mkForce [ ];
networking.interfaces.eth2.ipv4.addresses = lib.mkForce [ ];
router = {
enable = true;
wan.interface = "eth1";
lans.main = {
interface = "eth2";
ipv4 = { address = "192.168.10.1"; };
dhcp.pool = { start = "192.168.10.100"; end = "192.168.10.199"; };
};
dns.forwarders = [ "203.0.113.1" ];
};
# Unbound answers `.test` locally (RFC 6761) and would validate DNSSEC
# against a fake ISP resolver that cannot provide it; neither applies to
# a real forwarder, so relax both for the made-up test domain only.
services.unbound.settings.server = {
local-zone = [ "test. transparent" ];
domain-insecure = [ "example.test" ];
};
};
client = { lib, ... }: {
virtualisation.vlans = [ 2 ];
networking.useNetworkd = true;
networking.useDHCP = false;
networking.interfaces.eth1.ipv4.addresses = lib.mkForce [ ];
systemd.network.networks."10-lan" = {
matchConfig.Name = "eth1";
networkConfig.DHCP = "ipv4";
};
networking.firewall.enable = false;
};
};
testScript = ''
start_all()
upstream.wait_for_unit("dnsmasq.service")
upstream.wait_for_unit("nginx.service")
with subtest("router obtains its WAN lease and brings up services"):
router.wait_for_unit("network-online.target")
router.wait_until_succeeds("ip -4 addr show dev eth1 | grep -q 203.0.113.100")
router.wait_for_unit("nftables.service")
router.wait_for_unit("kea-dhcp4-server.service")
router.wait_for_unit("unbound.service")
with subtest("client gets address, gateway and DNS from the router"):
client.wait_until_succeeds("ip -4 addr show dev eth1 | grep -q 192.168.10.")
client.succeed("ip route show default | grep -q 'via 192.168.10.1'")
client.succeed("resolvectl status eth1 | grep -q 192.168.10.1")
with subtest("client resolves names through the router"):
client.wait_until_succeeds("dig +short +time=2 +tries=1 @192.168.10.1 example.test | grep -qx 203.0.113.1")
with subtest("client reaches the 'internet' and is NATed"):
out = client.wait_until_succeeds("curl -sf --max-time 5 --resolve example.test:80:203.0.113.1 http://example.test/")
assert out.strip() == "203.0.113.100", f"expected NATed source 203.0.113.100, got {out!r}"
with subtest("WAN cannot initiate to the router or the LAN"):
# Positive control: the probe tool works against a port that is open.
upstream.succeed("nc -z -w 2 203.0.113.1 80")
upstream.fail("nc -z -w 2 203.0.113.100 22")
upstream.fail("nc -z -w 2 203.0.113.100 53")
upstream.fail("ping -c 1 -W 2 192.168.10.1")
upstream.fail("ping -c 1 -W 2 203.0.113.100")
client_ip = client.succeed("ip -4 -o addr show dev eth1 | awk '{print $4}' | cut -d/ -f1").strip()
upstream.fail(f"ping -c 1 -W 2 {client_ip}")
with subtest("LAN can reach SSH and ping on the router"):
client.succeed("nc -z -w 2 192.168.10.1 22")
client.succeed("ping -c 1 -W 2 192.168.10.1")
'';
}