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:
@@ -0,0 +1,118 @@
|
||||
# `router-install`: write a prebuilt router system to a disk. Nothing is built
|
||||
# on the box; the system closure travels with the installer.
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
let
|
||||
cfg = config.router.installer;
|
||||
|
||||
installScript = pkgs.writeShellApplication {
|
||||
name = "router-install";
|
||||
runtimeInputs = [
|
||||
config.system.build.nixos-install
|
||||
pkgs.coreutils
|
||||
pkgs.dosfstools
|
||||
pkgs.e2fsprogs
|
||||
pkgs.gptfdisk
|
||||
pkgs.util-linux
|
||||
];
|
||||
text = ''
|
||||
usage() {
|
||||
echo "usage: router-install [--yes] [--secrets DIR] <disk>" >&2
|
||||
echo " Wipes <disk> (e.g. /dev/nvme0n1) and installs ${cfg.system}." >&2
|
||||
echo " --secrets DIR copied to /var/lib/router (PPPoE password, WireGuard keys, ...)" >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
yes=0
|
||||
secrets=
|
||||
disk=
|
||||
while [ $# -gt 0 ]; do
|
||||
case $1 in
|
||||
--yes) yes=1 ;;
|
||||
--secrets) shift; [ $# -gt 0 ] || usage; secrets=$1 ;;
|
||||
-*) usage ;;
|
||||
*) [ -z "$disk" ] || usage; disk=$1 ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
[ -n "$disk" ] || usage
|
||||
|
||||
[ "$(id -u)" -eq 0 ] || { echo "router-install: must run as root" >&2; exit 1; }
|
||||
[ -b "$disk" ] || { echo "router-install: $disk is not a block device" >&2; exit 1; }
|
||||
if [ -n "$secrets" ] && [ ! -d "$secrets" ]; then
|
||||
echo "router-install: $secrets is not a directory" >&2
|
||||
exit 1
|
||||
fi
|
||||
if lsblk -nr -o MOUNTPOINTS "$disk" | grep -q .; then
|
||||
echo "router-install: $disk or one of its partitions is mounted; refusing" >&2
|
||||
exit 1
|
||||
fi
|
||||
[ -d /sys/firmware/efi ] || echo "warning: not booted in UEFI mode; the box itself must boot UEFI" >&2
|
||||
|
||||
echo "About to ERASE $disk:"
|
||||
lsblk -o NAME,SIZE,MODEL,FSTYPE,LABEL "$disk"
|
||||
if [ "$yes" -ne 1 ]; then
|
||||
printf 'Type the device name (%s) to continue: ' "$disk"
|
||||
read -r answer
|
||||
[ "$answer" = "$disk" ] || { echo "aborted" >&2; exit 1; }
|
||||
fi
|
||||
|
||||
part() {
|
||||
case $disk in
|
||||
*[0-9]) echo "''${disk}p$1" ;;
|
||||
*) echo "$disk$1" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
echo ">> partitioning"
|
||||
sgdisk --zap-all "$disk" >/dev/null
|
||||
sgdisk -n 1:0:+512M -t 1:ef00 -c 1:boot -n 2:0:0 -t 2:8300 -c 2:nixos "$disk" >/dev/null
|
||||
partprobe "$disk" || true
|
||||
udevadm settle
|
||||
|
||||
echo ">> formatting"
|
||||
mkfs.fat -F 32 -n boot "$(part 1)" >/dev/null
|
||||
mkfs.ext4 -q -F -L nixos "$(part 2)"
|
||||
|
||||
echo ">> mounting"
|
||||
mkdir -p /mnt
|
||||
mount "$(part 2)" /mnt
|
||||
mkdir -p /mnt/boot
|
||||
mount -o umask=0077 "$(part 1)" /mnt/boot
|
||||
|
||||
if [ -n "$secrets" ]; then
|
||||
echo ">> copying secrets"
|
||||
install -d -m 0700 /mnt/var/lib/router
|
||||
cp -rT "$secrets" /mnt/var/lib/router
|
||||
chown -R root:root /mnt/var/lib/router
|
||||
chmod -R go-rwx /mnt/var/lib/router
|
||||
else
|
||||
echo "note: no --secrets given; put the PPPoE password etc. in /var/lib/router later" >&2
|
||||
fi
|
||||
|
||||
echo ">> installing ${cfg.system}"
|
||||
nixos-install --system ${cfg.system} --root /mnt --no-root-passwd --no-channel-copy
|
||||
|
||||
echo ">> unmounting"
|
||||
umount -R /mnt
|
||||
sync
|
||||
echo "Done. Remove the installer and reboot."
|
||||
'';
|
||||
};
|
||||
in
|
||||
{
|
||||
options.router.installer.system = lib.mkOption {
|
||||
type = lib.types.package;
|
||||
description = "The system (a `system.build.toplevel`) `router-install` writes to disk.";
|
||||
};
|
||||
|
||||
config = {
|
||||
environment.systemPackages = [ installScript ];
|
||||
# The closure must be on the installer itself.
|
||||
system.extraDependencies = [ cfg.system ];
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
# Bootable installer ISO: the live system, `router-install` and the target
|
||||
# system's closure. SSH is open to the target's root keys so secrets can be
|
||||
# copied over (`scp -r secrets root@<installer>:`) before installing.
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
modulesPath,
|
||||
...
|
||||
}:
|
||||
{
|
||||
imports = [
|
||||
(modulesPath + "/installer/cd-dvd/installation-cd-minimal.nix")
|
||||
./install-script.nix
|
||||
];
|
||||
|
||||
isoImage.volumeID = "ROUTER_INSTALL";
|
||||
isoImage.squashfsCompression = "zstd -Xcompression-level 6";
|
||||
|
||||
services.openssh = {
|
||||
enable = true;
|
||||
settings.PermitRootLogin = "prohibit-password";
|
||||
};
|
||||
|
||||
users.motd = ''
|
||||
|
||||
nixos-router installer
|
||||
1. (optional) scp -r secrets root@<this-host>:secrets
|
||||
2. router-install --secrets /root/secrets /dev/<disk>
|
||||
The disk is erased after confirmation.
|
||||
'';
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
# The disk layout `router-install` creates, as a NixOS module: an ESP labelled
|
||||
# `boot` and an ext4 root labelled `nixos`. Target configurations import this so
|
||||
# the installed system finds its disks on any hardware.
|
||||
{ lib, ... }:
|
||||
{
|
||||
fileSystems."/" = {
|
||||
device = "/dev/disk/by-label/nixos";
|
||||
fsType = "ext4";
|
||||
};
|
||||
fileSystems."/boot" = {
|
||||
device = "/dev/disk/by-label/boot";
|
||||
fsType = "vfat";
|
||||
options = [ "umask=0077" ];
|
||||
};
|
||||
|
||||
boot.loader.systemd-boot.enable = true;
|
||||
# The installer may run anywhere (even a BIOS-booted stick) and the firmware
|
||||
# may be reset: boot through the removable fallback path, not NVRAM entries.
|
||||
boot.loader.efi.canTouchEfiVariables = lib.mkDefault false;
|
||||
|
||||
# Storage and USB controllers of common hardware, so the root is found on
|
||||
# an arbitrary box (and in VMs).
|
||||
boot.initrd.availableKernelModules = [
|
||||
"ahci"
|
||||
"nvme"
|
||||
"sd_mod"
|
||||
"sdhci_pci"
|
||||
"usb_storage"
|
||||
"xhci_pci"
|
||||
"virtio_blk"
|
||||
"virtio_pci"
|
||||
];
|
||||
hardware.enableRedistributableFirmware = lib.mkDefault true;
|
||||
}
|
||||
@@ -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
|
||||
++ [
|
||||
{
|
||||
|
||||
@@ -27,6 +27,11 @@ in
|
||||
inherit (l.ipv4) subnet;
|
||||
interface = l.device;
|
||||
pools = [ { pool = "${l.dhcp.pool.start} - ${l.dhcp.pool.end}"; } ];
|
||||
reservations = lib.mapAttrsToList (hostname: r: {
|
||||
hw-address = lib.toLower r.mac;
|
||||
ip-address = r.address;
|
||||
inherit hostname;
|
||||
}) l.dhcp.reservations;
|
||||
option-data = [
|
||||
{
|
||||
name = "routers";
|
||||
|
||||
+47
-6
@@ -17,6 +17,37 @@ let
|
||||
a
|
||||
else
|
||||
ipv4.cidr (builtins.head parts) (lib.toInt (builtins.elemAt parts 1));
|
||||
|
||||
dns = cfg.dns;
|
||||
inherit (dns) domain;
|
||||
fqdn = name: if lib.hasInfix "." name || domain == null then name else "${name}.${domain}";
|
||||
isV6 = lib.hasInfix ":";
|
||||
rrOf = a: if isV6 a then "AAAA" else "A";
|
||||
|
||||
# name -> addresses: explicit hosts plus DHCP reservations (A records only).
|
||||
reservations = lib.concatMap (
|
||||
l: lib.mapAttrsToList (n: r: lib.nameValuePair n [ r.address ]) l.dhcp.reservations
|
||||
) lans;
|
||||
records =
|
||||
lib.mapAttrsToList (n: a: lib.nameValuePair (fqdn n) a) dns.hosts
|
||||
++ map (r: lib.nameValuePair (fqdn r.name) r.value) reservations
|
||||
++ lib.mapAttrsToList (n: a: lib.nameValuePair n a) dns.overrides;
|
||||
|
||||
# Unbound reads these as one quoted argument.
|
||||
localData = lib.concatMap (r: map (a: ''"${r.name}. ${rrOf a} ${a}"'') r.value) records;
|
||||
# Reverse records for hosts and reservations (not for wildcard overrides).
|
||||
ptrs = lib.concatMap (r: map (a: ''"${a} ${r.name}."'') r.value) (
|
||||
lib.filter (r: !(lib.hasAttr r.name dns.overrides)) records
|
||||
);
|
||||
privateZones = lib.attrNames dns.forwardZones ++ lib.optional (domain != null) domain;
|
||||
|
||||
# Peers of tunnels that use the resolver may be remote networks with no
|
||||
# address of their own on the tunnel (site-to-site); allow what they route.
|
||||
dnsPeerNets = lib.filter (n: !lib.hasSuffix "/0" n) (
|
||||
lib.concatMap (w: lib.concatMap (p: p.allowedIPs) (lib.attrValues w.peers)) (
|
||||
lib.filter (w: w.dns) (lib.attrValues cfg.wireguard)
|
||||
)
|
||||
);
|
||||
in
|
||||
{
|
||||
config = lib.mkIf cfg.enable {
|
||||
@@ -31,16 +62,26 @@ in
|
||||
"127.0.0.0/8 allow"
|
||||
]
|
||||
++ map (l: "${l.ipv4.subnet} allow") lans
|
||||
++ map (a: "${netOf a} allow") dnsAddrs;
|
||||
++ map (a: "${netOf a} allow") dnsAddrs
|
||||
++ map (n: "${n} allow") dnsPeerNets;
|
||||
local-zone =
|
||||
lib.optional (domain != null) "${domain}. static"
|
||||
++ map (n: "${n}. redirect") (lib.attrNames dns.overrides);
|
||||
local-data = localData;
|
||||
local-data-ptr = ptrs;
|
||||
domain-insecure = privateZones ++ lib.attrNames dns.overrides;
|
||||
};
|
||||
}
|
||||
// lib.optionalAttrs (cfg.dns.forwarders != [ ]) {
|
||||
forward-zone = [
|
||||
{
|
||||
// lib.optionalAttrs (cfg.dns.forwarders != [ ] || dns.forwardZones != { }) {
|
||||
forward-zone =
|
||||
lib.mapAttrsToList (name: addrs: {
|
||||
inherit name;
|
||||
forward-addr = addrs;
|
||||
}) dns.forwardZones
|
||||
++ lib.optional (cfg.dns.forwarders != [ ]) {
|
||||
name = ".";
|
||||
forward-addr = cfg.dns.forwarders;
|
||||
}
|
||||
];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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 = [ ];
|
||||
|
||||
Reference in New Issue
Block a user