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
141 lines
5.8 KiB
Nix
141 lines
5.8 KiB
Nix
# The web UI against a router's modules, without installing anything: one
|
|
# ordinary test node with the settings module, and a client on the LAN. What
|
|
# needs an installed system and on-device builds (apply, confirm, rollback)
|
|
# is tested in apply.nix; this one takes a minute instead of ten.
|
|
{ self, pkgs }:
|
|
let
|
|
common = import ./common.nix;
|
|
|
|
settings = pkgs.writeText "settings.json" (
|
|
builtins.toJSON {
|
|
system = {
|
|
hostName = "router";
|
|
stateVersion = "26.11";
|
|
};
|
|
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";
|
|
};
|
|
};
|
|
};
|
|
}
|
|
);
|
|
in
|
|
pkgs.testers.runNixOSTest {
|
|
name = "router-webui";
|
|
|
|
nodes = {
|
|
router = {
|
|
imports = [
|
|
self.nixosModules.router
|
|
self.nixosModules.settings
|
|
];
|
|
routerSettings.file = settings;
|
|
virtualisation.vlans = [
|
|
1
|
|
2
|
|
];
|
|
# What the device has at /var/lib/router/config.
|
|
systemd.tmpfiles.rules = [
|
|
"d /var/lib/router/config 0700 root root -"
|
|
"C /var/lib/router/config/settings.json 0600 root root - ${settings}"
|
|
];
|
|
};
|
|
|
|
client =
|
|
{ lib, ... }:
|
|
{
|
|
imports = [ common.tools ];
|
|
virtualisation.vlans = [ 2 ];
|
|
networking.interfaces.eth1.ipv4.addresses = lib.mkForce [
|
|
{
|
|
address = "192.168.10.50";
|
|
prefixLength = 24;
|
|
}
|
|
];
|
|
};
|
|
};
|
|
|
|
testScript = ''
|
|
ui = "https://192.168.10.1"
|
|
cfg = "/var/lib/router/config/settings.json"
|
|
|
|
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()
|
|
|
|
def post(path, fields):
|
|
form = " ".join(f"--data-urlencode '{k}={v}'" for k, v in fields.items())
|
|
return curl(f"-o /dev/null -w '%{{http_code}}' {form} {ui}{path}")
|
|
|
|
start_all()
|
|
router.wait_for_unit("router-webui.service")
|
|
router.succeed("echo hunter2hunter2 | router-webui passwd /var/lib/router/webui-password")
|
|
router.succeed("systemctl restart router-webui.service")
|
|
router.wait_for_unit("router-webui.service")
|
|
|
|
with subtest("the web UI is reachable from the LAN and wants the password"):
|
|
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 dashboard shows the uplink, the LAN and pings from the router"):
|
|
post("/prefs", {"csrf": csrf("/advanced"), "pingTargets": "gateway, 192.168.10.50"})
|
|
# 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("a change saved in the web UI is shown as unapplied and can be discarded"):
|
|
router.succeed(f"cp {cfg} /var/lib/router/settings.confirmed.json")
|
|
assert "match the confirmed system" in curl(f"{ui}/")
|
|
fields = {
|
|
"csrf": csrf("/network"), "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",
|
|
}
|
|
assert curl(f"-o /dev/null -w '%{{http_code}}' -X POST {ui}/network") == "403"
|
|
assert post("/network", fields) == "303"
|
|
assert "192.168.10.130" in router.succeed(f"cat {cfg}")
|
|
assert "not part of the confirmed system" in curl(f"{ui}/")
|
|
post("/discard", {"csrf": csrf("/")})
|
|
assert "192.168.10.130" not in router.succeed(f"cat {cfg}")
|
|
assert "match the confirmed system" in curl(f"{ui}/")
|
|
|
|
with subtest("DHCP reservations are saved, checked and kept by the network form"):
|
|
res = {"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.300"}
|
|
assert post("/dhcp", {"csrf": csrf("/dhcp"), **res}) == "422"
|
|
res["res.0.address"] = "192.168.10.50"
|
|
assert post("/dhcp", {"csrf": csrf("/dhcp"), **res}) == "303"
|
|
assert "printer" in curl(f"{ui}/dhcp")
|
|
saved = router.succeed(f"cat {cfg}")
|
|
assert '"aa:bb:cc:00:11:22"' in saved and '"192.168.10.50"' in saved, saved
|
|
# Saving the network form must not drop them.
|
|
assert post("/network", fields) == "303"
|
|
assert "printer" in router.succeed(f"cat {cfg}")
|
|
|
|
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
|
|
'';
|
|
}
|