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:
@@ -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")
|
||||
'';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user