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
+105
View File
@@ -7,6 +7,12 @@ let
lans = lib.attrValues lansByName;
validIp = s: ipv4.parse s != null;
hostnameOk = s: builtins.match "[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?" s != null;
# A DNS name: labels of letters, digits, dashes and underscores.
dnsNameOk =
s:
builtins.match "[A-Za-z0-9_]([A-Za-z0-9_-]*[A-Za-z0-9_])?(\\.[A-Za-z0-9_]([A-Za-z0-9_-]*[A-Za-z0-9_])?)*" s
!= null;
# Assertions about a single LAN; skipped (reported once) when its addresses
# do not even parse, so we never feed null into the integer helpers.
@@ -58,6 +64,60 @@ let
message = "router: ${where}.dhcp.pool contains the router's own address ${lan.ipv4.address}.";
}
]
)
++ lib.concatLists (
lib.mapAttrsToList (
host: r:
let
rwhere = "${where}.dhcp.reservations.${host}";
rok = validIp r.address;
in
[
{
assertion = hostnameOk host;
message = "router: ${rwhere}: `${host}` is not a valid hostname (letters, digits and dashes).";
}
{
assertion = builtins.match "([0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}" r.mac != null;
message = "router: ${rwhere}.mac `${r.mac}` is not a MAC address like aa:bb:cc:dd:ee:ff.";
}
{
assertion = rok;
message = "router: ${rwhere}.address `${r.address}` is not a valid IPv4 address.";
}
{
assertion = lan.dhcp.enable;
message = "router: ${rwhere}: reservations need dhcp.enable on the LAN.";
}
]
++ lib.optionals (rok && addrOk) [
{
assertion = ipv4.inSubnet net (ipv4.parse r.address);
message = "router: ${rwhere}.address ${r.address} is not inside ${lan.ipv4.subnet}.";
}
{
assertion = r.address != lan.ipv4.address;
message = "router: ${rwhere}.address is the router's own address.";
}
]
) lan.dhcp.reservations
)
++ (
let
rs = lib.attrValues lan.dhcp.reservations;
macs = map (r: lib.toLower r.mac) rs;
addrs = map (r: r.address) rs;
in
[
{
assertion = lib.length macs == lib.length (lib.unique macs);
message = "router: ${where}.dhcp.reservations: two reservations share a MAC address.";
}
{
assertion = lib.length addrs == lib.length (lib.unique addrs);
message = "router: ${where}.dhcp.reservations: two reservations share an address.";
}
]
);
wgs = lib.attrValues cfg.wireguard;
@@ -234,6 +294,50 @@ let
}
];
dnsCfg = cfg.dns;
anyIp = s: validIp s || v6Cidr "${s}/128";
dnsAssertions =
let
names =
lib.attrNames dnsCfg.hosts ++ lib.attrNames dnsCfg.overrides ++ lib.attrNames dnsCfg.forwardZones;
recordSets = dnsCfg.hosts // dnsCfg.overrides;
shortNames = lib.filter (n: !(lib.hasInfix "." n)) (lib.attrNames dnsCfg.hosts);
reservationNames = lib.concatMap (l: lib.attrNames l.dhcp.reservations) lans;
in
[
{
assertion = lib.all dnsNameOk names && (dnsCfg.domain == null || dnsNameOk dnsCfg.domain);
message = "router.dns: a name in domain/hosts/overrides/forwardZones is not a valid DNS name.";
}
{
assertion = lib.all (as: as != [ ] && lib.all anyIp as) (lib.attrValues recordSets);
message = "router.dns: every host/override needs at least one valid IPv4 or IPv6 address.";
}
{
assertion = lib.all (as: as != [ ] && lib.all anyIp as) (lib.attrValues dnsCfg.forwardZones);
message = "router.dns.forwardZones: every zone needs at least one valid resolver address.";
}
{
assertion = dnsCfg.domain != null || shortNames == [ ];
message = "router.dns.hosts: `${lib.concatStringsSep ", " shortNames}` has no dot, so router.dns.domain must be set.";
}
{
assertion =
lib.length (lib.attrNames dnsCfg.hosts) == lib.length (
lib.unique (
map (n: if lib.hasInfix "." n || dnsCfg.domain == null then n else "${n}.${dnsCfg.domain}") (
lib.attrNames dnsCfg.hosts
)
)
);
message = "router.dns.hosts: two names are the same once placed in the domain.";
}
{
assertion = lib.all (n: !(lib.elem n (lib.attrNames dnsCfg.hosts))) reservationNames;
message = "router.dns.hosts: a name is also a DHCP reservation, which already publishes it.";
}
];
overlapsLan = n: lib.any (l: ipv4.overlaps n l) lanNets;
wgAssertions =
@@ -347,6 +451,7 @@ in
}
]
++ lib.concatLists (lib.mapAttrsToList perLan lansByName)
++ dnsAssertions
++ lib.concatMap ruleAssertions cfg.firewall.rules
++ [
{