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:
2026-10-28 12:31:44 +01:00
co-authored by Claude Sonnet 5.5
parent 0653b5cf2f
commit b5429ad876
15 changed files with 943 additions and 21 deletions
+132
View File
@@ -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"
'';
}