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