Files
nixos-router/tests/webui.nix
T
nilsandClaude Sonnet 5.5 caf2c6b26d Add DNS accounts, Let's Encrypt, password change, reboot, HTTP redirect
- router.dnsProviders: named accounts at DNS providers, used by dynamic
  DNS and certificates (ddclient and lego provider lists in
  pkgs/router-webui/providers.json). Schema 2 migrates router.ddns.
- router.acme: a Let's Encrypt certificate for the web UI via
  security.acme (DNS-01, resolver 1.1.1.1), served and reloaded by the
  web UI; Certificate page.
- Web UI: password change (web UI and root), reboot button, HTTP to HTTPS
  redirect (router.webui.httpPort), grouped menu.
- Latest kernel (linuxPackages_latest); 7.3 and 7.3.0 are the same kernel
  for the reboot hint.
- SPECS.md: wanted later (REST API, GraphQL, MCP).

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

188 lines
9.3 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("plain HTTP is sent to HTTPS"):
out = client.succeed("curl -s -o /dev/null -w '%{http_code} %{redirect_url}' http://192.168.10.1/login?x=1")
assert out == "308 https://192.168.10.1/login?x=1", out
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("a port forward is saved and listed"):
fwd = {"nats": "1", "nat.0.type": "dnat", "nat.0.from": "wan", "nat.0.protocol": "tcp", "nat.0.ports": "8080",
"nat.0.to": "main", "nat.0.toaddress": "192.168.10.5", "nat.0.allow": "on"}
assert post("/firewall", {"csrf": csrf("/firewall"), **fwd}) == "303"
assert 'value="192.168.10.5"' in curl(f"{ui}/firewall")
saved = router.succeed(f"cat {cfg}")
assert '"type": "dnat"' in saved and '"from": "wan"' in saved and '"natOnly": true' in saved, saved
with subtest("a DNS account is saved with its secret kept out of the settings, and dynamic DNS uses it"):
acct = {"providers": "1", "prov.0.new": "1", "prov.0.name": "cf", "prov.0.type": "cloudflare", "prov.0.username": "token"}
assert post("/dnsproviders", {"csrf": csrf("/dnsproviders"), **acct}) == "422"
assert post("/dnsproviders", {"csrf": csrf("/dnsproviders"), "prov.0.secret": "tok3n-abc", **acct}) == "303"
assert router.succeed("stat -c '%a' /var/lib/router/dns-secrets/cf").strip() == "600"
dd = {"enabled": "on", "provider": "cf", "zone": "example.org",
"domains": "home.example.org", "ipv4": "on", "ssl": "on", "interval": "5min"}
assert post("/ddns", {"csrf": csrf("/ddns"), **dd}) == "303"
saved = router.succeed(f"cat {cfg}")
assert '"home.example.org"' in saved and '"provider": "cf"' in saved and "tok3n-abc" not in saved, saved
assert "tok3n-abc" not in curl(f"{ui}/dnsproviders") + curl(f"{ui}/ddns")
with subtest("a certificate order is saved, and the page shows what is served"):
crt = {"enabled": "on", "provider": "cf", "domains": "router.example.org", "email": "me@example.org", "staging": "on"}
assert post("/certificate", {"csrf": csrf("/certificate"), **crt}) == "422"
assert post("/certificate", {"csrf": csrf("/certificate"), "agree": "on", **crt}) == "303"
assert '"acme"' in router.succeed(f"cat {cfg}")
assert "self-signed" in curl(f"{ui}/certificate")
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
with subtest("changing the password changes the web UI's and root's"):
pw = {"current": "wrong", "new": "hunter3hunter3", "confirm": "hunter3hunter3"}
curl(f"-o /dev/null -d csrf={csrf('/advanced')} " + " ".join(f"--data-urlencode {k}={v}" for k, v in pw.items()) + f" {ui}/password")
assert curl(f"-o /dev/null -w '%{{http_code}}' -d password=hunter3hunter3 {ui}/login") == "401"
pw["current"] = "hunter2hunter2"
curl(f"-o /dev/null -d csrf={csrf('/advanced')} " + " ".join(f"--data-urlencode {k}={v}" for k, v in pw.items()) + f" {ui}/password")
assert curl(f"-o /dev/null -w '%{{http_code}}' -d password=hunter3hunter3 {ui}/login") == "303"
assert curl(f"-o /dev/null -w '%{{http_code}}' -d password=hunter2hunter2 {ui}/login") == "401"
hashed = router.succeed("cat /var/lib/router/root-password").strip()
assert f"root:{hashed}:" in router.succeed("cat /etc/shadow"), hashed
'';
}