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
+72
View File
@@ -156,6 +156,29 @@ in
example = "192.168.10.199";
};
};
reservations = mkOption {
type = types.attrsOf (
types.submodule {
options = {
mac = mkOption {
type = types.str;
example = "a8:a1:59:0b:4b:97";
description = "Hardware address of the client.";
};
address = mkOption {
type = types.str;
example = "192.168.10.20";
description = "Address handed to the client; must lie inside the LAN.";
};
};
}
);
default = { };
description = ''
Fixed leases, keyed by hostname. The name is also sent to the
client and published in DNS (under `router.dns.domain` if set).
'';
};
};
ipv6.pd = mkOption {
@@ -415,6 +438,55 @@ in
);
};
dns.domain = mkOption {
type = types.nullOr types.str;
default = null;
example = "home.example.org";
description = ''
Local domain. It is authoritative on the router: names below it that
have no record answer NXDOMAIN and are never forwarded. Short names in
`dns.hosts` and DHCP reservation names live in it.
'';
};
dns.hosts = mkOption {
type = types.attrsOf (types.listOf types.str);
default = { };
example = {
nas = [ "192.168.10.20" ];
"git.example.org" = [
"10.43.0.50"
"fd00::50"
];
};
description = ''
Local records: name to IPv4/IPv6 addresses. A name without a dot is
placed in `dns.domain`; a full name overrides public DNS for that
name only (split horizon). Reverse records are created as well.
'';
};
dns.overrides = mkOption {
type = types.attrsOf (types.listOf types.str);
default = { };
example = {
"k8s.example.org" = [ "10.43.0.50" ];
};
description = "Like `dns.hosts`, but the name and everything below it answer with these addresses (wildcard).";
};
dns.forwardZones = mkOption {
type = types.attrsOf (types.listOf types.str);
default = { };
example = {
"branch.example.org" = [ "10.1.0.254" ];
};
description = ''
Zones answered by other resolvers (e.g. across a tunnel). DNSSEC
validation is skipped for them: they are private zones.
'';
};
dns.forwarders = mkOption {
type = types.listOf types.str;
default = [ ];