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")
'';
}
+115
View File
@@ -227,6 +227,64 @@ let
};
expect = [ "WAN interface `eth0` is also used by a LAN" ];
};
namesValid = {
router = {
interfaces = {
wan.mac = "52:54:00:12:34:56";
lan.mac = "52:54:00:12:34:57";
};
wan.interface = "wan";
lans = {
main = lan { interface = "lan"; };
dmz = lan2 {
interface = "lan";
vlan = 1002;
deviceName = "dmz";
};
};
};
expect = [ ];
};
nameKernelLike = {
router.lans.main = lan { };
router.interfaces = {
eth7.mac = "52:54:00:12:34:56";
wlp1s0.mac = "52:54:00:12:34:57";
};
expect = [ "do not look like a kernel name" ];
};
nameTooLong = {
router.lans.main = lan { };
router.interfaces.a-very-long-interface-name.mac = "52:54:00:12:34:56";
expect = [ "at most 15 characters" ];
};
sameMacTwice = {
router.lans.main = lan { };
router.interfaces = {
wan.mac = "52:54:00:12:34:56";
lan.mac = "52:54:00:12:34:56";
};
expect = [ "same MAC address" ];
};
vlanNameClash = {
router = {
interfaces.dmz.mac = "52:54:00:12:34:56";
lans.a = lan {
vlan = 20;
deviceName = "dmz";
};
};
expect = [ "used twice among `interfaces`" ];
};
deviceNameWithoutVlan = {
router.lans.main = lan { deviceName = "dmz"; };
expect = [ "deviceName needs a vlan" ];
};
pppoeVlanNameWithoutVlan = {
router.lans.main = lan { };
router.wan.ipv4.pppoe.vlanName = "isp";
expect = [ "vlanName needs pppoe.vlan" ];
};
deviceNameTooLong = {
router.lans.main = lan {
interface = "enp1s0f1np1";
@@ -764,6 +822,62 @@ let
};
expect = [ ];
};
localValid = {
router = {
lans.main = lan { };
firewall.rules = [
{
from = "wan";
to = "local";
protocol = "tcp";
ports = [ 22 ];
sources = [ "198.51.100.0/24" ];
rateLimit = "5/minute";
}
{
from = "main";
to = "local";
protocol = "tcp";
ports = [ 8443 ];
}
];
};
expect = [ ];
};
localNatOnly = {
router = {
lans.main = lan { };
firewall.rules = [
{
from = "wan";
to = "local";
protocol = "tcp";
ports = [ 22 ];
natOnly = true;
}
];
};
expect = [ "natOnly does not apply to the router itself" ];
};
localNoPorts = {
router = {
lans.main = lan { };
firewall.rules = [
{
from = "wan";
to = "local";
protocol = "tcp";
}
];
};
expect = [ "must list the ports they open" ];
};
localZoneName = {
router = {
lans.main = lan { zone = "local"; };
};
expect = [ "`local` is a reserved zone name" ];
};
wanNoDestination = {
router = {
lans.main = lan { };
@@ -1446,6 +1560,7 @@ let
"pdOnOneLan"
"pppoeValid"
"vlansOnOneTrunk"
"namesValid"
"ruleValid"
"wgValid"
"wanValid"
+1
View File
@@ -6,6 +6,7 @@
wan4 = "pppoe";
};
vlans = import ./vlans.nix { inherit self pkgs; };
names = import ./names.nix { inherit self pkgs; };
onestick = import ./onestick.nix { inherit self pkgs; };
rollback = import ./rollback.nix { inherit self pkgs; };
dns = import ./dns.nix { inherit self pkgs; };
+81
View File
@@ -0,0 +1,81 @@
# Network cards are named by MAC address: the WAN and the LAN card get the
# names wan and lan, and a VLAN on the LAN card gets the name dmz.
{ self, pkgs }:
let
common = import ./common.nix;
# The test driver gives the NIC on VLAN n of machine m the MAC 52:54:00:12:0n:0m.
mac = net: "52:54:00:12:0${toString net}:01";
in
pkgs.testers.runNixOSTest {
name = "router-names";
defaults.imports = [ common.tools ];
interactive.defaults = common.interactiveDefaults;
nodes.router =
{ lib, ... }:
{
imports = [ self.nixosModules.router ];
virtualisation.vlans = [
1
2
];
networking.interfaces.eth1.ipv4.addresses = lib.mkForce [ ];
networking.interfaces.eth2.ipv4.addresses = lib.mkForce [ ];
router = {
enable = true;
interfaces = {
wan.mac = mac 1;
lan.mac = mac 2;
};
wan.interface = "wan";
lans = {
main = {
interface = "lan";
ipv4.address = "192.168.10.1";
dhcp.pool = {
start = "192.168.10.100";
end = "192.168.10.199";
};
};
guests = {
interface = "lan";
vlan = 1002;
deviceName = "dmz";
ipv4.address = "192.168.20.1";
dhcp.pool = {
start = "192.168.20.100";
end = "192.168.20.199";
};
};
};
};
};
testScript = ''
router.start()
router.wait_for_unit("network-online.target")
with subtest("the cards have the names given by MAC address"):
router.succeed("ip -br link show wan | grep -qi ${mac 1}")
router.succeed("ip -br link show lan | grep -qi ${mac 2}")
router.fail("ip link show eth1")
router.fail("ip link show eth2")
with subtest("the VLAN device has the name given to it"):
out = router.succeed("ip -d link show dmz")
assert "vlan protocol 802.1Q id 1002" in out and "lan" in out, out
router.fail("ip link show lan.1002")
router.wait_until_succeeds("ip -4 addr show dev lan | grep -q 192.168.10.1/24")
router.wait_until_succeeds("ip -4 addr show dev dmz | grep -q 192.168.20.1/24")
with subtest("services use the names"):
router.wait_for_unit("nftables.service")
rules = router.succeed("nft list ruleset")
assert '"lan"' in rules and '"dmz"' in rules and "eth2" not in rules, rules
router.wait_for_unit("kea-dhcp4-server.service")
assert sorted(router.succeed("cat /etc/router/dhcp-interfaces").split()) == ["dmz", "lan"], "dhcp interfaces"
router.succeed("grep -q '^nic ${mac 2} lan$' /etc/router/interface-names")
'';
}
+7 -4
View File
@@ -2,7 +2,8 @@
# the three LANs sharing a zone.
#
# zone trusted: main (untagged, internet)
# zone iot: iot (VLAN 20) + cams (VLAN 30), no internet, no management
# zone iot: iot (VLAN 20) + cams (VLAN 30, its device is called dmz), no internet,
# no management
#
# rules: trusted -> iot (anything)
# iot -> trusted, tcp 8080, only to 192.168.10.100
@@ -133,6 +134,7 @@ pkgs.testers.runNixOSTest {
cams = {
interface = "eth2";
vlan = 30;
deviceName = "dmz"; # a VLAN device with a name of its own
zone = "iot";
allowManagement = false;
ipv4.address = "192.168.30.1";
@@ -261,12 +263,13 @@ pkgs.testers.runNixOSTest {
router.wait_for_unit("nftables.service")
router.wait_for_unit("kea-dhcp4-server.service")
router.wait_for_unit("unbound.service")
for vid in (20, 30):
out = router.succeed(f"ip -d link show eth2.{vid}")
for dev, vid in (("eth2.20", 20), ("dmz", 30)):
out = router.succeed(f"ip -d link show {dev}")
assert f"vlan protocol 802.1Q id {vid}" in out, out
router.fail("ip link show eth2.30")
router.succeed("ip -4 addr show dev eth2 | grep -q 192.168.10.1")
router.succeed("ip -4 addr show dev eth2.20 | grep -q 192.168.20.1")
router.succeed("ip -4 addr show dev eth2.30 | grep -q 192.168.30.1")
router.succeed("ip -4 addr show dev dmz | grep -q 192.168.30.1")
with subtest("each host gets an address and gateway from its own VLAN"):
main.wait_until_succeeds("ip -4 addr show dev eth1 | grep -q 192.168.10.100")