Add DHCP reservations to the web UI, split out a fast webui test
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
This commit is contained in:
+12
-22
@@ -5,7 +5,10 @@
|
||||
# - 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.
|
||||
# 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;
|
||||
@@ -176,7 +179,7 @@ pkgs.testers.runNixOSTest {
|
||||
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"):
|
||||
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",
|
||||
@@ -186,35 +189,22 @@ pkgs.testers.runNixOSTest {
|
||||
}.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}/")
|
||||
|
||||
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
|
||||
'';
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
dns = import ./dns.nix { inherit self pkgs; };
|
||||
installer = import ./installer.nix { inherit self pkgs; };
|
||||
apply = import ./apply.nix { inherit self pkgs; };
|
||||
webui = import ./webui.nix { inherit self pkgs; };
|
||||
setup = import ./setup.nix { inherit self pkgs; };
|
||||
update = import ./update.nix { inherit self pkgs; };
|
||||
image = import ./image.nix { inherit self pkgs; };
|
||||
|
||||
+140
@@ -0,0 +1,140 @@
|
||||
# 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
|
||||
'';
|
||||
}
|
||||
Reference in New Issue
Block a user