Add the appliance web UI look, interface names, MCP and REST API, local zone

Web UI: dark/light themes (appliance layout, sidebar, switch checkboxes),
theme preference on the Advanced page, Changes page with the pending diff.

Interface names: router.interfaces.<name>.mac, lans.<l>.deviceName and
pppoe.vlanName; a rename is staged for the next reboot by router-apply.

Remote access: an MCP server (POST /mcp) and an OpenAPI REST API
(/api/v1, /api/openapi.json) with the same tokens, scopes, limits and audit
log; tools to read status, settings, leases, logs (get_logs), and to edit
and check the saved settings (never applied). Tokens on the API access page.

Firewall: a reserved `local` zone (the router itself) as the destination of
rules, so the web UI or SSH can be allowed from the WAN.

PLAN.md lists what is still left.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
This commit is contained in:
2026-11-01 16:56:59 +01:00
co-authored by Claude Sonnet 5.5
parent 95ab7223b9
commit 586274af4e
41 changed files with 3138 additions and 152 deletions
+90
View File
@@ -208,5 +208,95 @@ pkgs.testers.runNixOSTest {
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("naming the network cards in the web UI is staged for the next boot"):
before = current()
mac = {n: target.succeed(f"cat /sys/class/net/{n}/address").strip() for n in ("eth1", "eth2")}
names = " ".join(f"--data-urlencode '{k}={v}'" for k, v in {
"csrf": csrf("/network"), "rows": "2",
"dev.0": "eth1", "mac.0": mac["eth1"], "from.0": "eth1", "name.0": "wan",
"dev.1": "eth2", "mac.1": mac["eth2"], "from.1": "eth2", "name.1": "lan",
}.items())
assert curl(f"-o /dev/null -w '%{{http_code}}' {names} {ui}/interfaces") == "303"
# The settings follow the names at once; the system does not.
saved = target.succeed("cat /var/lib/router/config/settings.json")
assert '"interface": "lan"' in saved and '"interface": "wan"' in saved and mac["eth2"] in saved, saved
assert "after the next reboot" in curl(f"{ui}/network")
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 current() == before
target.fail("systemctl is-active router-rollback.timer")
staged = target.succeed("readlink -f /nix/var/nix/profiles/system").strip()
assert staged != before
target.succeed(f"grep -q '^nic {mac['eth2']} lan$' {staged}/etc/router/interface-names")
target.succeed("ip link show eth2")
# The overview samples the system every few seconds.
client.wait_until_succeeds(f"curl -sk -b /tmp/jar {ui}/ | grep -c 'starts at the next reboot' >/dev/null", timeout=60)
with subtest("after the reboot the cards have their names and the router works"):
target.shutdown()
target.start()
target.wait_for_unit("multi-user.target")
target.succeed("ip link show wan && ip link show lan")
target.fail("ip link show eth1")
target.wait_until_succeeds("ip -4 addr show dev lan | grep -q 192.168.10.1/24")
target.wait_for_unit("kea-dhcp4-server.service")
target.wait_for_unit("router-webui.service")
client.wait_until_succeeds(f"curl -sk -o /dev/null {ui}/login", timeout=120)
assert "match the confirmed system" in curl(f"-d password=hunter2hunter2 -L {ui}/login")
assert "starts at the next reboot" not in curl(f"{ui}/")
with subtest("an assistant with a change token edits and checks the settings through MCP"):
secret = curl(f"-d csrf={csrf('/tokens')} -d name=assistant -d scope=change {ui}/tokens | grep -o 'rtr_[A-Za-z0-9_-]*' | sed -n 1p").strip()
assert secret.startswith("rtr_"), secret
import json
def mcp(tool, args=None, token=secret):
body = json.dumps({"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": tool, "arguments": args or {}}})
out = client.succeed(f"curl -sk -H 'Authorization: Bearer {token}' -d '{body}' {ui}/mcp")
res = json.loads(out)["result"]
return res["content"][0]["text"], res.get("isError", False)
assert client.succeed(f"curl -sk -o /dev/null -w '%{{http_code}}' -d '{{}}' {ui}/mcp").strip() == "401"
text, failed = mcp("get_settings")
got = json.loads(text)
assert not failed and got["settings"]["router"]["lans"]["main"], text
assert "WAN" in mcp("list_interfaces")[0]
assert json.loads(mcp("get_status")[0])["uplink"] is not None
# A bad pool: saved, not applied, and the check reports the router's assertion.
text, failed = mcp("update_settings", {"etag": got["etag"], "patch": {"router": {"lans": {"main": {"dhcp": {"pool": {"end": "10.9.9.9"}}}}}}})
assert not failed and '"applied": false' in text, text
text, failed = mcp("check_settings")
for _ in range(20):
if "running" not in text:
break
text, failed = mcp("check_settings")
res = json.loads(text)
assert res["ok"] is False and "pool is not inside" in res["error"], text
assert current() == target.succeed("readlink -f /nix/var/nix/profiles/system").strip()
assert "10.9.9.9" in mcp("get_pending_changes")[0]
# Discarded, the settings evaluate again.
assert not mcp("discard_changes")[1]
text, failed = mcp("check_settings")
for _ in range(20):
if "running" not in text:
break
text, failed = mcp("check_settings")
assert json.loads(text)["ok"] is True, text
# The journal can be read, through MCP and through the REST API (same tokens).
text, failed = mcp("get_logs", {"unit": "router-webui", "lines": 5})
assert not failed and "router-webui" in text, text
text, failed = mcp("get_logs", {"unit": "--vacuum-time=1s"})
assert failed, text
rest = f"curl -sk -H 'Authorization: Bearer {secret}'"
assert "router-webui" in client.succeed(f"{rest} '{ui}/api/v1/logs?unit=router-webui&lines=5'")
assert json.loads(client.succeed(f"{rest} {ui}/api/v1/status"))["uplink"] is not None
assert client.succeed(f"{rest} -o /dev/null -w '%{{http_code}}' -X PATCH -d '{{}}' {ui}/api/v1/settings").strip() == "400"
assert client.succeed(f"curl -sk -o /dev/null -w '%{{http_code}}' {ui}/api/v1/status").strip() == "401"
spec = json.loads(client.succeed(f"curl -sk {ui}/api/openapi.json"))
assert spec["openapi"].startswith("3.") and "/api/v1/logs" in spec["paths"]
# The log shows what was done, by whom.
assert "update_settings" in curl(f"{ui}/tokens") and "assistant" in curl(f"{ui}/tokens")
'';
}