# 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"; # Only the settings module's services (router-leases-dns) are used here; the # router itself is configured below. settings = pkgs.writeText "settings.json" ( builtins.toJSON { system = { hostName = "router"; stateVersion = "26.11"; }; router = { }; } ); 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 self.nixosModules.settings ]; routerSettings.file = settings; 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"; dynamicLeases = true; 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"; # Claims a name the configuration publishes for someone else. dhcpV4Config.Hostname = "nas"; }; }; laptop = { 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"; networkConfig.DHCP = "ipv4"; dhcpV4Config.Hostname = "Laptop.evil.example"; }; }; }; 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("DHCP clients are published under the name they gave"): laptop.wait_until_succeeds("ip -4 addr show dev eth1 | grep -q 192.168.10.1[0-9][0-9]") addr = laptop.succeed("ip -4 -o addr show dev eth1").split()[3].split("/")[0] router.wait_until_succeeds(f"dig +short @192.168.10.1 laptop.lan.test | grep -qx {addr}", timeout=60) assert q("laptop.lan.test") == addr rev = ".".join(reversed(addr.split("."))) + ".in-addr.arpa" assert q(rev, "PTR") == "laptop.lan.test." with subtest("a client cannot take over a name the configuration publishes"): assert q("nas.lan.test") == "192.168.10.20", q("nas.lan.test") assert q("20.10.168.192.in-addr.arpa", "PTR") == "nas.lan.test." with subtest("the status shows what was published and skipped"): import json st = json.loads(router.succeed("cat /run/router-leases-dns/status.json")) router.log(str(st)) 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") ''; }