From 2c8d5562f9a3e41392a27a3df1ceb9244e3358d5 Mon Sep 17 00:00:00 2001 From: Nils Stinnesbeck Date: Wed, 28 Oct 2026 22:22:26 +0100 Subject: [PATCH] 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 --- flake.nix | 56 ++++++++++++++ modules/settings/default.nix | 108 +++++++++++++++++++++++++++ tests/apply.nix | 140 +++++++++++++++++++++++++++++++++++ tests/default.nix | 1 + 4 files changed, 305 insertions(+) create mode 100644 modules/settings/default.nix create mode 100644 tests/apply.nix diff --git a/flake.nix b/flake.nix index ccf998b..aedf85e 100644 --- a/flake.nix +++ b/flake.nix @@ -23,8 +23,64 @@ installer-iso = ./modules/installer/iso.nix; # Appliance disk image: dm-verity store, volatile root, state on /var. image = ./modules/image; + # Configuration from a settings file (JSON), rebuilt on the device. + settings = ./modules/settings; }; + # A router built from settings.json; this is what the device's own + # configuration flake (see mkConfigDir) evaluates. + lib.mkRouterSystem = + { + settings, + modules ? [ ], + }: + nixpkgs.lib.nixosSystem { + system = "x86_64-linux"; + modules = [ + self.nixosModules.router + self.nixosModules.settings + self.nixosModules.installed-layout + { + routerSettings.file = settings; + # The device rebuilds itself, offline if need be: it needs the + # sources its configuration flake points to. + system.extraDependencies = [ + self.outPath + nixpkgs.outPath + ]; + } + ] + ++ modules; + }; + + # The configuration directory installed to /var/lib/router/config: a + # flake pinned to exactly this router source, the settings and an + # optional local.nix with extra NixOS configuration. + lib.mkConfigDir = + { + pkgs, + settings, + local ? null, + }: + pkgs.runCommand "router-config" { } '' + mkdir -p $out + cat > $out/flake.nix <<'EOF' + { + inputs.nixos-router.url = "path:${self.outPath}"; + outputs = + { nixos-router, ... }: + { + nixosConfigurations.router = nixos-router.lib.mkRouterSystem { + settings = ./settings.json; + modules = if builtins.pathExists ./local.nix then [ ./local.nix ] else [ ]; + }; + }; + } + EOF + cp ${settings} $out/settings.json + ${pkgs.lib.optionalString (local != null) "cp ${local} $out/local.nix"} + ''; + # Installer ISO for a given nixosSystem: # nix build .#installer-example (see packages) lib.mkInstaller = diff --git a/modules/settings/default.nix b/modules/settings/default.nix new file mode 100644 index 0000000..5d9b4e7 --- /dev/null +++ b/modules/settings/default.nix @@ -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 + ]; + }; +} diff --git a/tests/apply.nix b/tests/apply.nix new file mode 100644 index 0000000..c69b722 --- /dev/null +++ b/tests/apply.nix @@ -0,0 +1,140 @@ +# A router configured by settings.json, changed and rebuilt on the device: +# install it (its configuration directory travels with the secrets), boot it +# without network, then +# - rebuilding the unchanged settings gives exactly the installed system, +# - a changed setting is built offline, activated and confirmed, +# - an unconfirmed change is rolled back. +{ self, pkgs }: +let + inherit (pkgs) lib; + common = import ./common.nix; + + settings = pkgs.writeText "settings.json" ( + builtins.toJSON { + system = { + hostName = "router"; + stateVersion = "26.11"; + sshKeys = [ + "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFakeKeyForTestsOnlyXXXXXXXXXXXXXXXXXXXXXXXXXX test" + ]; + }; + router = { + 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"; + }; + }; + }; + } + ); + + # Only so the test driver can talk to the installed system. + local = pkgs.writeText "local.nix" '' + { modulesPath, ... }: + { + imports = [ (modulesPath + "/testing/test-instrumentation.nix") ]; + } + ''; + + configDir = self.lib.mkConfigDir { inherit pkgs settings local; }; + + # What the device evaluates from configDir. + toplevel = + (self.lib.mkRouterSystem { + settings = "${configDir}/settings.json"; + modules = [ "${configDir}/local.nix" ]; + }).config.system.build.toplevel; + + sharedDisk = { + virtualisation.diskImage = "./target.qcow2"; + virtualisation.diskSize = 8192; + }; +in +pkgs.testers.runNixOSTest { + name = "router-apply"; + + nodes = { + installer = + { lib, ... }: + { + imports = [ + sharedDisk + self.nixosModules.installer + common.tools + ]; + router.installer.system = toplevel; + virtualisation.memorySize = 2048; + virtualisation.fileSystems."/".autoFormat = true; + virtualisation.emptyDiskImages = [ 1024 ]; + virtualisation.rootDevice = "/dev/vdb"; + nix.settings.substituters = lib.mkForce [ ]; + }; + + target = { + imports = [ sharedDisk ]; + # Evaluating a NixOS system takes memory. + virtualisation.memorySize = 3072; + 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") + installer.succeed("mkdir -p /root/secrets && cp -r ${configDir} /root/secrets/config") + installer.succeed("router-install --yes --secrets /root/secrets /dev/vda >&2") + installer.shutdown() + + target.state_dir = installer.state_dir + target.start() + target.wait_for_unit("multi-user.target") + + def current(): + return target.succeed("readlink -f /run/current-system").strip() + + def pool_end(): + return target.succeed("grep -o '192.168.10.100 - 192.168.10.[0-9]*' /etc/kea/dhcp4-server.conf").split()[-1] + + with subtest("the installed system is the one built from the settings"): + assert current() == "${toplevel}" + target.succeed("test -f /var/lib/router/config/settings.json") + + with subtest("rebuilding unchanged settings on the device changes nothing"): + out = target.succeed("router-apply 2>&1") + assert "no changes" in out, out + + with subtest("a changed setting is built offline, activated and confirmed"): + target.succeed("sed -i 's/192.168.10.199/192.168.10.150/' /var/lib/router/config/settings.json") + target.succeed("router-apply 5min >&2") + assert current() != "${toplevel}" + assert pool_end() == "192.168.10.150" + target.succeed("systemctl is-active router-rollback.timer") + target.succeed("router-confirm >&2") + target.fail("systemctl is-active router-rollback.timer") + confirmed = current() + assert target.succeed("readlink -f /nix/var/nix/profiles/system").strip() == confirmed + + with subtest("an unconfirmed change is rolled back"): + target.succeed("sed -i 's/192.168.10.150/192.168.10.120/' /var/lib/router/config/settings.json") + target.succeed("router-apply 20s >&2") + assert pool_end() == "192.168.10.120" + target.wait_until_succeeds(f"test $(readlink -f /run/current-system) = {confirmed}", timeout=120) + assert pool_end() == "192.168.10.150" + ''; +} diff --git a/tests/default.nix b/tests/default.nix index 0b2a7f7..e507426 100644 --- a/tests/default.nix +++ b/tests/default.nix @@ -10,6 +10,7 @@ rollback = import ./rollback.nix { inherit self pkgs; }; dns = import ./dns.nix { inherit self pkgs; }; installer = import ./installer.nix { inherit self pkgs; }; + apply = import ./apply.nix { inherit self pkgs; }; image = import ./image.nix { inherit self pkgs; }; wireguard = import ./wireguard.nix { inherit self pkgs; }; lib = import ./lib.nix { inherit pkgs; };