The DHCP page edits the reservations per LAN (checked like the module's assertions) and offers active clients for reservation. The web UI checks that need no installed system moved from apply.nix into webui.nix (one node, under a minute); apply.nix shares one apply for the pool change and a reservation. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
211 lines
8.4 KiB
Nix
211 lines
8.4 KiB
Nix
# 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,
|
|
# - and the same through the web UI from a LAN client: discard the
|
|
# unapplied change, edit the DHCP pool and add a reservation, apply,
|
|
# confirm.
|
|
# The rest of the web UI (dashboard, backup, forms) is tested in webui.nix,
|
|
# which needs no installed system.
|
|
{ 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 [ ];
|
|
};
|
|
|
|
client =
|
|
{ lib, ... }:
|
|
{
|
|
imports = [ common.tools ];
|
|
virtualisation.vlans = [ 2 ];
|
|
networking.interfaces.eth1.ipv4.addresses = lib.mkForce [
|
|
{
|
|
address = "192.168.10.50";
|
|
prefixLength = 24;
|
|
}
|
|
];
|
|
};
|
|
|
|
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"
|
|
|
|
ui = "https://192.168.10.1"
|
|
|
|
def curl(args):
|
|
return client.succeed(f"curl -sk -b /tmp/jar -c /tmp/jar {args}")
|
|
|
|
def csrf(path):
|
|
return curl(f"{ui}{path} | grep -o 'name=\"csrf\" value=\"[^\"]*' | head -1 | cut -d'\"' -f4").strip()
|
|
|
|
with subtest("the web UI is reachable from the LAN and wants the password"):
|
|
target.succeed("echo hunter2hunter2 | router-webui passwd /var/lib/router/webui-password")
|
|
target.wait_for_unit("router-webui.service")
|
|
client.start()
|
|
client.wait_until_succeeds(f"curl -sk -o /dev/null {ui}/login", timeout=60)
|
|
assert curl(f"-o /dev/null -w '%{{http_code}}' -d password=wrong {ui}/login") == "401"
|
|
assert curl(f"-o /dev/null -w '%{{http_code}}' -d password=hunter2hunter2 {ui}/login") == "303"
|
|
|
|
with subtest("the web UI shows the rolled back change as unapplied and discards it"):
|
|
page = curl(f"{ui}/")
|
|
assert "not part of the confirmed system" in page, page
|
|
token = csrf("/")
|
|
curl(f"-d csrf={token} {ui}/discard")
|
|
assert "192.168.10.150" in target.succeed("cat /var/lib/router/config/settings.json")
|
|
assert "match the confirmed system" in curl(f"{ui}/")
|
|
|
|
with subtest("a pool change and a reservation made in the web UI are applied and confirmed"):
|
|
token = csrf("/network")
|
|
form = " ".join(f"--data-urlencode '{k}={v}'" for k, v in {
|
|
"csrf": token, "hostName": "router", "wan.interface": "eth1", "wan.type": "dhcp",
|
|
"lans": "1", "lan.0.name": "main", "lan.0.interface": "eth2", "lan.0.address": "192.168.10.1",
|
|
"lan.0.internet": "on", "lan.0.dhcp": "on",
|
|
"lan.0.poolStart": "192.168.10.100", "lan.0.poolEnd": "192.168.10.130",
|
|
}.items())
|
|
assert curl(f"-o /dev/null -w '%{{http_code}}' -X POST {ui}/network") == "403"
|
|
assert curl(f"-o /dev/null -w '%{{http_code}}' {form} {ui}/network") == "303"
|
|
# A reservation too: one apply for both changes.
|
|
reservation = " ".join(f"--data-urlencode '{k}={v}'" for k, v in {
|
|
"csrf": csrf("/dhcp"), "rows": "1", "res.0.lan": "main", "res.0.name": "printer",
|
|
"res.0.mac": "AA:BB:CC:00:11:22", "res.0.address": "192.168.10.60",
|
|
}.items())
|
|
assert curl(f"-o /dev/null -w '%{{http_code}}' {reservation} {ui}/dhcp") == "303"
|
|
curl(f"-d csrf={csrf('/')} {ui}/apply")
|
|
target.wait_until_succeeds("test $(systemctl show -p ActiveState --value router-apply.service) = inactive"
|
|
" && test $(systemctl show -p Result --value router-apply.service) = success", timeout=600)
|
|
assert pool_end() == "192.168.10.130"
|
|
target.succeed("grep -q 'aa:bb:cc:00:11:22' /etc/kea/dhcp4-server.conf && grep -q '192.168.10.60' /etc/kea/dhcp4-server.conf")
|
|
assert "Waiting for confirmation" in curl(f"{ui}/")
|
|
curl(f"-d csrf={csrf('/')} {ui}/confirm")
|
|
target.wait_until_fails("systemctl is-active router-rollback.timer", timeout=60)
|
|
target.wait_until_succeeds("test $(systemctl show -p ActiveState --value router-confirm.service) = inactive")
|
|
assert target.succeed("readlink -f /nix/var/nix/profiles/system").strip() == current()
|
|
assert "match the confirmed system" in curl(f"{ui}/")
|
|
'';
|
|
}
|