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
This commit is contained in:
2026-10-30 19:27:49 +01:00
co-authored by Claude Sonnet 5.5
parent a32bf42777
commit caf2c6b26d
33 changed files with 1985 additions and 224 deletions
+31 -7
View File
@@ -87,6 +87,10 @@ pkgs.testers.runNixOSTest {
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.
@@ -144,20 +148,40 @@ pkgs.testers.runNixOSTest {
saved = router.succeed(f"cat {cfg}")
assert '"type": "dnat"' in saved and '"from": "wan"' in saved and '"natOnly": true' 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",
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}) == "422"
assert post("/ddns", {"csrf": csrf("/ddns"), "secret": "tok3n-abc", **dd}) == "303"
assert post("/ddns", {"csrf": csrf("/ddns"), **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")
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
'';
}