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:
@@ -23,8 +23,64 @@
|
|||||||
installer-iso = ./modules/installer/iso.nix;
|
installer-iso = ./modules/installer/iso.nix;
|
||||||
# Appliance disk image: dm-verity store, volatile root, state on /var.
|
# Appliance disk image: dm-verity store, volatile root, state on /var.
|
||||||
image = ./modules/image;
|
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:
|
# Installer ISO for a given nixosSystem:
|
||||||
# nix build .#installer-example (see packages)
|
# nix build .#installer-example (see packages)
|
||||||
lib.mkInstaller =
|
lib.mkInstaller =
|
||||||
|
|||||||
@@ -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
|
||||||
|
];
|
||||||
|
};
|
||||||
|
}
|
||||||
+140
@@ -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"
|
||||||
|
'';
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
rollback = import ./rollback.nix { inherit self pkgs; };
|
rollback = import ./rollback.nix { inherit self pkgs; };
|
||||||
dns = import ./dns.nix { inherit self pkgs; };
|
dns = import ./dns.nix { inherit self pkgs; };
|
||||||
installer = import ./installer.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; };
|
image = import ./image.nix { inherit self pkgs; };
|
||||||
wireguard = import ./wireguard.nix { inherit self pkgs; };
|
wireguard = import ./wireguard.nix { inherit self pkgs; };
|
||||||
lib = import ./lib.nix { inherit pkgs; };
|
lib = import ./lib.nix { inherit pkgs; };
|
||||||
|
|||||||
Reference in New Issue
Block a user