Files
nixos-router/tests/webui.nix
T
nilsandClaude Sonnet 5.5 605753b20e Add a firewall page to the web UI
Allow rules between zones (LANs and tunnels) are edited on their own page:
from, to, protocol, ports, destination. Port forwards and rules with more
options are listed and kept as they are.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
2026-10-30 08:31:30 +01:00

146 lines
6.1 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 firewall page lists the zones and refuses a rule to an unknown one"):
assert 'value="main"' in curl(f"{ui}/firewall")
rule = {"rows": "1", "rule.0.from": "main", "rule.0.to": "nowhere", "rule.0.protocol": "any"}
assert post("/firewall", {"csrf": csrf("/firewall"), **rule}) == "422"
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
'';
}