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
+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")