Metric history (5s to 1h tiers, persisted), uplink status, ping targets, DHCP leases, and a tar.gz backup of the state directory. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
221 lines
9.1 KiB
Nix
221 lines
9.1 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, apply, confirm.
|
|
{ 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 change made in the web UI is 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"
|
|
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"
|
|
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}/")
|
|
|
|
with subtest("the dashboard shows the uplink, the LAN and pings from the router"):
|
|
curl(f"-d csrf={csrf('/advanced')} --data-urlencode 'pingTargets=gateway, 192.168.10.50' {ui}/prefs")
|
|
# The UI pings from its sandbox (raw socket); the client answers.
|
|
# (Into a file: grep -q would end curl with SIGPIPE, failing the pipe.)
|
|
client.wait_until_succeeds(
|
|
f"curl -sk -b /tmp/jar -o /tmp/dash '{ui}/dashboard?range=1h' && grep -q 'Ping 192.168.10.50</dt><dd>[0-9.]* ms' /tmp/dash",
|
|
timeout=60)
|
|
page = curl(f"{ui}/")
|
|
# No DHCP server on the uplink in this test.
|
|
for text in ["Not connected", "LAN main (eth2)", "Internet (eth1)", "Latency", "CPU and memory", "/static/dashboard.js"]:
|
|
assert text in page, f"{text!r} missing:\n{page}"
|
|
assert "<html" not in curl(f"'{ui}/dashboard?range=7d'")
|
|
client.succeed("curl -sfk -o /dev/null https://192.168.10.1/static/dashboard.js")
|
|
|
|
with subtest("the web UI hands out a backup of the router's state"):
|
|
curl(f"-sf -d csrf={csrf('/advanced')} -o /tmp/backup.tar.gz {ui}/backup")
|
|
files = client.succeed("tar -tzf /tmp/backup.tar.gz")
|
|
for f in ["router/config/settings.json", "router/webui-password", "router/webui/cert.pem", "router/webui/dashboard.json"]:
|
|
assert f in files.split(), files
|
|
'';
|
|
}
|