Files
nilsandClaude Sonnet 5.5 a01214707a Test dynamic DHCP names in the DNS VM test
A client with a hostname is published with its reverse record, a client
claiming a configured name does not take it over, and a name outside the
domain is cut to its first label.

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

220 lines
7.8 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";
# 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")
'';
}