Configure a router from settings.json and rebuild it on the device

nixosModules.settings builds a router from a JSON file: the router
options as they are, plus host name, time zone, SSH keys and
stateVersion; the module system validates it. lib.mkRouterSystem builds
such a system, lib.mkConfigDir the configuration directory installed to
/var/lib/router/config (a flake pinned to this router source, the
settings and an optional local.nix). router-apply builds it and switches
with commit-confirm.

The device must be able to rebuild without internet, so the packages the
rebuilt derivations need are part of the system; they were found by
diffing the derivation graphs of typical changes against the installed
system.

checks.apply installs such a router, boots it offline, checks that the
unchanged settings rebuild to the installed system, applies and
confirms a change, and rolls back an unconfirmed one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-28 22:22:26 +01:00
co-authored by Claude Opus 5.5
parent a4f4ec12a3
commit 2c8d5562f9
4 changed files with 305 additions and 0 deletions
+108
View File
@@ -0,0 +1,108 @@
# A router configured by a settings file instead of Nix code, so that it can
# be changed on the box itself (by the web UI or by hand) and rebuilt there.
#
# settings.json holds the `router` options as JSON plus a few system settings:
#
# {
# "system": { "hostName": "router", "timeZone": "UTC", "sshKeys": [ "ssh-ed25519 ..." ],
# "stateVersion": "26.11" },
# "router": { "wan": { "interface": "enp1s0" }, "lans": { "lan": { ... } } }
# }
#
# The module system validates it like any configuration: unknown keys, wrong
# types and the router's assertions all fail the build. Secrets stay out of it
# (files under /var/lib/router, referenced by path).
#
# On the device the configuration lives in /var/lib/router/config: a flake
# (pinned to this router's source), settings.json and an optional local.nix.
# `router-apply` builds it and activates it with commit-confirm: unless
# `router-confirm` follows within router.rollback.timeout, the previous system
# comes back.
{
config,
lib,
pkgs,
...
}:
let
cfg = config.routerSettings;
settings = lib.importJSON cfg.file;
system = settings.system or { };
apply = pkgs.writeShellApplication {
name = "router-apply";
runtimeInputs = [
config.nix.package
pkgs.coreutils
];
text = ''
timeout=''${1:-${config.router.rollback.timeout}}
echo "router: building ${cfg.directory}"
new=$(nix build --no-link --print-out-paths --out-link /run/router-pending \
"path:${cfg.directory}#nixosConfigurations.router.config.system.build.toplevel")
if [ "$new" = "$(readlink -f /run/current-system)" ]; then
echo "router: no changes"
exit 0
fi
router-arm "$timeout"
echo "router: activating $new"
"$new/bin/switch-to-configuration" test
'';
};
in
{
# Outside `router`: the settings file defines that whole tree.
options.routerSettings = {
file = lib.mkOption {
type = lib.types.path;
description = "The settings file (JSON) this system is built from.";
};
directory = lib.mkOption {
type = lib.types.str;
default = "/var/lib/router/config";
description = "Where the configuration (flake and settings) lives on the device.";
};
};
config = {
router = settings.router // {
enable = true;
};
networking.hostName = system.hostName or "router";
time.timeZone = system.timeZone or "UTC";
users.users.root.openssh.authorizedKeys.keys = system.sshKeys or [ ];
system.stateVersion = system.stateVersion or config.system.nixos.release;
nix.settings.experimental-features = [
"nix-command"
"flakes"
];
environment.systemPackages = [ apply ];
# Rebuilding on the device must work without internet (the uplink may be
# what is being configured). What a changed configuration rebuilds is all
# generated by NixOS, but those builds need these packages; found by
# diffing the derivation graphs of typical changes (pool, addresses, host
# name, PPPoE on a VLAN, a VLAN LAN, IPv6 PD/ULA, a port forward, DNS
# records) against the installed system.
system.extraDependencies = with pkgs; [
stdenvNoCC
findXMLCatalogs
jq.dev
kmod.dev
libcap-text-verifier
libxslt.bin
# nftables checks its ruleset at build time inside lkl.
libredirect
lklWithFirewall.lib
lndir
makeShellWrapper
mdadm
nukeReferences
# Switching the uplink to PPPoE is a typical first change.
ppp
];
};
}