Files
nixos-router/tests/dns.nix
T
nilsandClaude Sonnet 5.5 84f6002d05 Add WireGuard, DNS and more to the web UI and router-setup
Web UI: a WireGuard page (tunnels, peers, keys made on the router, client
configs with a QR code, import of wg-quick configs, live status), a DNS page
(records, wildcards, forward zones), interface dropdowns with VLAN devices,
IPv6 options and a local domain. router-setup asks about VLANs, IPv6 and
the domain. The router publishes its own name in DNS and hands the local
domain to DHCP clients; wg and jq are on the router. Includes a script
that takes over antigua's tunnels.

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

169 lines
5.9 KiB
Nix

# Local DNS and DHCP reservations.
#
# upstream (ISP: DHCP, public DNS, a "branch" resolver) -- router -- client
#
# The router is authoritative for lan.test, answers a few names locally even
# though public DNS knows them (split horizon), forwards branch.test to its own
# resolver, and gives the client a fixed lease by MAC address.
{ self, pkgs }:
let
common = import ./common.nix;
clientMac = "02:00:00:00:00:51";
in
pkgs.testers.runNixOSTest {
name = "router-dns";
defaults.imports = [ common.tools ];
interactive.defaults = common.interactiveDefaults;
nodes = {
upstream =
{ lib, ... }:
{
virtualisation.vlans = [ 1 ];
networking.useNetworkd = true;
networking.useDHCP = false;
networking.firewall.enable = false;
networking.interfaces.eth1.ipv4.addresses = lib.mkForce [ ];
systemd.network.networks."10-wan" = {
matchConfig.Name = "eth1";
address = [ "203.0.113.1/24" ];
routes = [
{
Destination = "192.168.10.0/24";
Gateway = "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 = [
# Public answers; the router overrides the first one locally.
"/photos.example.test/203.0.113.7"
"/other.example.test/203.0.113.8"
"/host.branch.test/10.9.9.9"
];
};
};
};
router =
{ lib, ... }:
{
imports = [ self.nixosModules.router ];
virtualisation.vlans = [
1
2
];
networking.interfaces.eth1.ipv4.addresses = lib.mkForce [ ];
networking.interfaces.eth2.ipv4.addresses = lib.mkForce [ ];
router = {
enable = true;
wan.interface = "eth1";
lans.main = {
interface = "eth2";
internet = true;
ipv4.address = "192.168.10.1";
dhcp = {
pool = {
start = "192.168.10.100";
end = "192.168.10.199";
};
reservations.printer = {
mac = clientMac;
address = "192.168.10.51";
};
};
};
dns = {
forwarders = [ "203.0.113.1" ];
domain = "lan.test";
hosts = {
nas = [
"192.168.10.20"
"fd00::20"
];
"photos.example.test" = [ "192.168.10.50" ];
};
overrides."k8s.example.test" = [ "192.168.10.60" ];
forwardZones."branch.test" = [ "203.0.113.1" ];
};
};
# `.test` is special-cased by Unbound (RFC 6761) and the fake ISP cannot
# do DNSSEC; relax both for the made-up public names used here.
services.unbound.settings.server = {
local-zone = [ "test. transparent" ];
domain-insecure = [ "example.test" ];
};
};
client =
{ lib, ... }:
{
virtualisation.vlans = [ 2 ];
networking.useNetworkd = true;
networking.useDHCP = false;
networking.firewall.enable = false;
networking.interfaces.eth1.ipv4.addresses = lib.mkForce [ ];
systemd.network.networks."10-lan" = {
matchConfig.Name = "eth1";
linkConfig.MACAddress = "${clientMac}";
networkConfig.DHCP = "ipv4";
};
};
};
testScript = ''
start_all()
upstream.wait_for_unit("dnsmasq.service")
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("kea-dhcp4-server.service")
router.wait_for_unit("unbound.service")
def q(name, rr="A", server="192.168.10.1"):
return client.succeed(f"dig +short +time=2 +tries=1 @{server} {name} {rr}").strip()
with subtest("the reservation gives the client its fixed address"):
client.wait_until_succeeds("ip -4 addr show dev eth1 | grep -q 192.168.10.51")
assert "${clientMac}" in client.succeed("ip link show eth1")
# Not from the pool.
client.fail("ip -4 addr show dev eth1 | grep -q 192.168.10.1[0-9][0-9]")
with subtest("hosts and reservations are published in the local domain"):
assert q("nas.lan.test") == "192.168.10.20", q("nas.lan.test")
assert q("nas.lan.test", "AAAA") == "fd00::20"
assert q("printer.lan.test") == "192.168.10.51"
assert q("20.10.168.192.in-addr.arpa", "PTR") == "nas.lan.test."
with subtest("the router's own name resolves, with and without the domain"):
assert q("router.lan.test") == "192.168.10.1", q("router.lan.test")
assert q("router") == "192.168.10.1", q("router")
assert q("1.10.168.192.in-addr.arpa", "PTR") == "router.lan.test."
with subtest("the local domain is authoritative: unknown names are not forwarded"):
# Positive control: resolving through the router works at all.
assert q("other.example.test") == "203.0.113.8"
out = client.succeed("dig +time=2 +tries=1 @192.168.10.1 nothere.lan.test")
assert "status: NXDOMAIN" in out, out
with subtest("split horizon: only the named host is answered locally"):
assert q("photos.example.test") == "192.168.10.50", q("photos.example.test")
assert q("other.example.test") == "203.0.113.8"
with subtest("overrides cover the name and everything below it"):
assert q("k8s.example.test") == "192.168.10.60"
assert q("deep.sub.k8s.example.test") == "192.168.10.60"
with subtest("forward zones go to their own resolver"):
assert q("host.branch.test") == "10.9.9.9", q("host.branch.test")
'';
}