router.ddns keeps provider records on the uplink's addresses with ddclient, run by a timer and by a watcher that starts it when an address changes. The Dynamic DNS page edits it; the secret is a private file on the router. The overview shows the running kernel and says when the current system boots a different one. PLAN.md records why the A/B image approach was not pursued. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
164 lines
7.3 KiB
Nix
164 lines
7.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("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 = {"forwards": "1", "fwd.0.protocol": "tcp", "fwd.0.ports": "8080", "fwd.0.to": "main",
|
|
"fwd.0.destination": "192.168.10.5", "fwd.0.nat": "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 '"dnat": true' in saved and '"from": "wan"' in saved, saved
|
|
|
|
with subtest("dynamic DNS is saved with its secret kept out of the settings"):
|
|
dd = {"enabled": "on", "protocol": "cloudflare", "username": "token", "zone": "example.org",
|
|
"domains": "home.example.org", "ipv4": "on", "ssl": "on", "interval": "5min"}
|
|
assert post("/ddns", {"csrf": csrf("/ddns"), **dd}) == "422"
|
|
assert post("/ddns", {"csrf": csrf("/ddns"), "secret": "tok3n-abc", **dd}) == "303"
|
|
saved = router.succeed(f"cat {cfg}")
|
|
assert '"home.example.org"' in saved and "tok3n-abc" not in saved, saved
|
|
assert router.succeed("stat -c '%a' /var/lib/router/ddns-secret").strip() == "600"
|
|
assert "tok3n-abc" not in curl(f"{ui}/ddns")
|
|
|
|
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
|
|
'';
|
|
}
|