Add local DNS records, DHCP reservations and an installer ISO
DNS and DHCP (needed to replace a dnsmasq-based router): - router.dns.domain/hosts/overrides/forwardZones: authoritative local domain, split-horizon host records with reverse entries, wildcard overrides and per-zone forwarders (private zones skip DNSSEC). - lans.<n>.dhcp.reservations: Kea host reservations, published in DNS. - Tunnels with dns = true admit what their peers route, so a site-to-site peer without a transfer address can use the resolver. Installer: - router-install wipes a disk, creates an ESP and an ext4 root and installs a prebuilt system with its secrets; nothing is built on the box. installed-layout is the shared disk layout contract. - lib.mkInstaller builds an ISO for a given nixosSystem; packages.installer-example is the example's ISO. Covered by the dns and installer VM tests (the latter installs onto an empty disk and boots it under UEFI), wireguard DNS and assertion cases. PLAN.md records the remaining gaps for replacing the hand-written router. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -783,6 +783,163 @@ let
|
||||
};
|
||||
expect = [ "used by both the PPPoE uplink and a LAN" ];
|
||||
};
|
||||
dnsValid = {
|
||||
router = {
|
||||
lans.main = lan {
|
||||
dhcp.reservations = {
|
||||
nas = {
|
||||
mac = "a8:a1:59:0b:4b:97";
|
||||
address = "192.168.10.20";
|
||||
};
|
||||
};
|
||||
};
|
||||
dns = {
|
||||
domain = "home.example.org";
|
||||
hosts = {
|
||||
printer = [ "192.168.10.30" ];
|
||||
"git.example.org" = [
|
||||
"10.43.0.50"
|
||||
"fd00::50"
|
||||
];
|
||||
};
|
||||
overrides."k8s.example.org" = [ "10.43.0.50" ];
|
||||
forwardZones."branch.example.org" = [ "10.1.0.254" ];
|
||||
};
|
||||
};
|
||||
expect = [ ];
|
||||
};
|
||||
dnsShortNoDomain = {
|
||||
router = {
|
||||
lans.main = lan { dhcp.reservations = { }; };
|
||||
dns = {
|
||||
hosts.printer = [ "192.168.10.30" ];
|
||||
};
|
||||
};
|
||||
expect = [ "router.dns.domain must be set" ];
|
||||
};
|
||||
dnsBadName = {
|
||||
router = {
|
||||
lans.main = lan { dhcp.reservations = { }; };
|
||||
dns = {
|
||||
domain = "home.example.org";
|
||||
hosts."bad name" = [ "192.168.10.30" ];
|
||||
};
|
||||
};
|
||||
expect = [ "not a valid DNS name" ];
|
||||
};
|
||||
dnsBadAddress = {
|
||||
router = {
|
||||
lans.main = lan { dhcp.reservations = { }; };
|
||||
dns = {
|
||||
domain = "home.example.org";
|
||||
hosts.x = [ "192.168.10.300" ];
|
||||
};
|
||||
};
|
||||
expect = [ "valid IPv4 or IPv6 address" ];
|
||||
};
|
||||
dnsBadZone = {
|
||||
router = {
|
||||
lans.main = lan { dhcp.reservations = { }; };
|
||||
dns = {
|
||||
forwardZones."a.example.org" = [ ];
|
||||
};
|
||||
};
|
||||
expect = [ "valid resolver address" ];
|
||||
};
|
||||
dnsHostVsReservation = {
|
||||
router = {
|
||||
lans.main = lan {
|
||||
dhcp.reservations = {
|
||||
nas = {
|
||||
mac = "a8:a1:59:0b:4b:97";
|
||||
address = "192.168.10.20";
|
||||
};
|
||||
};
|
||||
};
|
||||
dns = {
|
||||
domain = "home.example.org";
|
||||
hosts.nas = [ "192.168.10.99" ];
|
||||
};
|
||||
};
|
||||
expect = [ "also a DHCP reservation" ];
|
||||
};
|
||||
resBadMac = {
|
||||
router = {
|
||||
lans.main = lan {
|
||||
dhcp.reservations = {
|
||||
nas = {
|
||||
mac = "nonsense";
|
||||
address = "192.168.10.20";
|
||||
};
|
||||
};
|
||||
};
|
||||
dns = { };
|
||||
};
|
||||
expect = [ "is not a MAC address" ];
|
||||
};
|
||||
resOutside = {
|
||||
router = {
|
||||
lans.main = lan {
|
||||
dhcp.reservations = {
|
||||
nas = {
|
||||
mac = "a8:a1:59:0b:4b:97";
|
||||
address = "192.168.99.20";
|
||||
};
|
||||
};
|
||||
};
|
||||
dns = { };
|
||||
};
|
||||
expect = [ "is not inside" ];
|
||||
};
|
||||
resRouterAddress = {
|
||||
router = {
|
||||
lans.main = lan {
|
||||
dhcp.reservations = {
|
||||
nas = {
|
||||
mac = "a8:a1:59:0b:4b:97";
|
||||
address = "192.168.10.1";
|
||||
};
|
||||
};
|
||||
};
|
||||
dns = { };
|
||||
};
|
||||
expect = [ "router's own address" ];
|
||||
};
|
||||
resDuplicates = {
|
||||
router = {
|
||||
lans.main = lan {
|
||||
dhcp.reservations = {
|
||||
a = {
|
||||
mac = "a8:a1:59:0b:4b:97";
|
||||
address = "192.168.10.20";
|
||||
};
|
||||
b = {
|
||||
mac = "A8:A1:59:0B:4B:97";
|
||||
address = "192.168.10.20";
|
||||
};
|
||||
};
|
||||
};
|
||||
dns = { };
|
||||
};
|
||||
expect = [
|
||||
"share a MAC address"
|
||||
"share an address"
|
||||
];
|
||||
};
|
||||
resBadHostname = {
|
||||
router = {
|
||||
lans.main = lan {
|
||||
dhcp.reservations = {
|
||||
"bad name" = {
|
||||
mac = "a8:a1:59:0b:4b:97";
|
||||
address = "192.168.10.20";
|
||||
};
|
||||
};
|
||||
};
|
||||
dns = { };
|
||||
};
|
||||
expect = [ "not a valid hostname" ];
|
||||
};
|
||||
nonOverlappingLans = {
|
||||
router.lans = {
|
||||
a = lan { };
|
||||
@@ -831,6 +988,7 @@ let
|
||||
"wgValid"
|
||||
"wanValid"
|
||||
"sharedLinkValid"
|
||||
"dnsValid"
|
||||
];
|
||||
|
||||
subnet =
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
vlans = import ./vlans.nix { inherit self pkgs; };
|
||||
onestick = import ./onestick.nix { inherit self pkgs; };
|
||||
rollback = import ./rollback.nix { inherit self pkgs; };
|
||||
dns = import ./dns.nix { inherit self pkgs; };
|
||||
installer = import ./installer.nix { inherit self pkgs; };
|
||||
wireguard = import ./wireguard.nix { inherit self pkgs; };
|
||||
lib = import ./lib.nix { inherit pkgs; };
|
||||
assertions = import ./assertions.nix { inherit self pkgs; };
|
||||
|
||||
+163
@@ -0,0 +1,163 @@
|
||||
# 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 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")
|
||||
'';
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
# router-install: an installer VM writes a router system to an empty disk
|
||||
# (/dev/vda), then a second VM boots that disk with UEFI and the installed
|
||||
# router must come up, with its secrets in place.
|
||||
#
|
||||
# Follows nixos/tests/installer.nix: both machines share one disk image; the
|
||||
# installer runs from a small second disk.
|
||||
{ self, pkgs }:
|
||||
let
|
||||
inherit (pkgs) lib;
|
||||
common = import ./common.nix;
|
||||
|
||||
# The system to install: a minimal router on the layout the installer creates.
|
||||
target = import "${pkgs.path}/nixos/lib/eval-config.nix" {
|
||||
system = "x86_64-linux";
|
||||
modules = [
|
||||
self.nixosModules.router
|
||||
self.nixosModules.installed-layout
|
||||
# Only so the test driver can talk to the installed system.
|
||||
"${pkgs.path}/nixos/modules/testing/test-instrumentation.nix"
|
||||
{
|
||||
system.stateVersion = "25.05";
|
||||
users.users.root.openssh.authorizedKeys.keys = [
|
||||
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFakeKeyForTestsOnlyXXXXXXXXXXXXXXXXXXXXXXXXXX test"
|
||||
];
|
||||
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";
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
];
|
||||
};
|
||||
toplevel = target.config.system.build.toplevel;
|
||||
|
||||
# Both machines must see the same disk.
|
||||
sharedDisk = {
|
||||
virtualisation.diskImage = "./target.qcow2";
|
||||
virtualisation.diskSize = 6144;
|
||||
virtualisation.memorySize = 2048;
|
||||
};
|
||||
in
|
||||
pkgs.testers.runNixOSTest {
|
||||
name = "router-installer";
|
||||
|
||||
nodes = {
|
||||
installer =
|
||||
{ lib, ... }:
|
||||
{
|
||||
imports = [
|
||||
sharedDisk
|
||||
self.nixosModules.installer
|
||||
common.tools
|
||||
];
|
||||
router.installer.system = toplevel;
|
||||
|
||||
# Run from a small second disk so /dev/vda is the target.
|
||||
virtualisation.fileSystems."/".autoFormat = true;
|
||||
virtualisation.emptyDiskImages = [ 1024 ];
|
||||
virtualisation.rootDevice = "/dev/vdb";
|
||||
# No network in the sandbox; everything needed is in the closure.
|
||||
nix.settings.substituters = lib.mkForce [ ];
|
||||
};
|
||||
|
||||
target = {
|
||||
imports = [ sharedDisk ];
|
||||
virtualisation.vlans = [
|
||||
1
|
||||
2
|
||||
];
|
||||
virtualisation.useBootLoader = true;
|
||||
virtualisation.useEFIBoot = true;
|
||||
virtualisation.useDefaultFilesystems = false;
|
||||
virtualisation.efi.keepVariables = false;
|
||||
virtualisation.fileSystems."/" = {
|
||||
device = "/dev/disk/by-label/this-is-not-real-and-will-never-be-used";
|
||||
fsType = "ext4";
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
testScript = ''
|
||||
installer.start()
|
||||
installer.wait_for_unit("multi-user.target")
|
||||
|
||||
with subtest("router-install refuses bad input"):
|
||||
installer.fail("router-install")
|
||||
installer.fail("router-install --yes /etc/hostname")
|
||||
# The installer's own root disk is mounted.
|
||||
out = installer.fail("router-install --yes /dev/vdb 2>&1")
|
||||
assert "mounted" in out, out
|
||||
installer.fail("router-install --yes --secrets /nonexistent /dev/vda")
|
||||
# Nothing was touched.
|
||||
installer.fail("blkid /dev/vda1")
|
||||
|
||||
with subtest("installation onto the empty disk"):
|
||||
installer.succeed("mkdir -p /root/secrets && echo -n hunter2 > /root/secrets/pppoe-password")
|
||||
installer.succeed("router-install --yes --secrets /root/secrets /dev/vda >&2")
|
||||
# Layout contract with the installed-layout module.
|
||||
assert "boot" in installer.succeed("blkid -s LABEL -o value /dev/vda1")
|
||||
assert "nixos" in installer.succeed("blkid -s LABEL -o value /dev/vda2")
|
||||
installer.shutdown()
|
||||
|
||||
# The same machine, booting from the installed disk this time.
|
||||
target.state_dir = installer.state_dir
|
||||
target.start()
|
||||
|
||||
with subtest("the installed system boots and runs the router"):
|
||||
target.wait_for_unit("multi-user.target")
|
||||
assert target.succeed("readlink -f /run/current-system").strip() == "${toplevel}"
|
||||
target.succeed("findmnt /boot | grep -q vfat")
|
||||
target.succeed("test -e /boot/EFI/BOOT/BOOTX64.EFI")
|
||||
target.wait_for_unit("nftables.service")
|
||||
target.wait_for_unit("unbound.service")
|
||||
target.wait_for_unit("kea-dhcp4-server.service")
|
||||
target.wait_until_succeeds("ip -4 addr show dev eth2 | grep -q 192.168.10.1")
|
||||
target.succeed("command -v router-arm router-confirm")
|
||||
|
||||
with subtest("secrets arrive with strict permissions"):
|
||||
assert target.succeed("cat /var/lib/router/pppoe-password") == "hunter2"
|
||||
assert target.succeed("stat -c %a /var/lib/router").strip() == "700"
|
||||
assert target.succeed("stat -c %a /var/lib/router/pppoe-password").strip() == "600"
|
||||
assert target.succeed("stat -c %U /var/lib/router/pppoe-password").strip() == "root"
|
||||
'';
|
||||
}
|
||||
@@ -223,6 +223,9 @@ pkgs.testers.runNixOSTest {
|
||||
privateKeyFile = "/etc/wg/s2s.key";
|
||||
listenPort = 51821;
|
||||
zone = "remote";
|
||||
# No address on the tunnel: the site queries the LAN address and
|
||||
# is allowed because it routes 192.168.50.0/24 through the tunnel.
|
||||
dns = true;
|
||||
peers.siteb = {
|
||||
publicKey = key.siteb.pub;
|
||||
endpoint = "203.0.113.60:51821";
|
||||
@@ -383,6 +386,9 @@ pkgs.testers.runNixOSTest {
|
||||
siteb.succeed("ping -c 1 -W 2 -I 192.168.50.1 192.168.10.1")
|
||||
siteb.fail("ping -c 1 -W 2 -I 192.168.50.1 192.168.10.100")
|
||||
|
||||
with subtest("site-to-site: the remote site can use the router's resolver"):
|
||||
siteb.succeed("dig +short +time=2 +tries=1 -b 192.168.50.1 @192.168.10.1 example.test | grep -qx 203.0.113.1")
|
||||
|
||||
with subtest("provider tunnel: only the listed destinations, masqueraded"):
|
||||
out = main.succeed("curl -sf --max-time 5 http://198.18.0.1/")
|
||||
assert out.strip() == "10.200.0.2", f"expected masquerade to 10.200.0.2, got {out!r}"
|
||||
|
||||
Reference in New Issue
Block a user