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
+44 -3
View File
@@ -6,6 +6,19 @@ let
lansByName = cfg.lans;
lans = lib.attrValues lansByName;
# A name chosen for a network card or a VLAN device: short, plain, and not
# one the kernel or udev hands out itself (a rename could then collide).
# Keep in step with reservedName in the web UI (pkgs/router-webui/settings.go).
chosenNameOk =
s:
builtins.match "[a-z][a-z0-9-]{0,14}" s != null
&& builtins.match "(eth|wlan|ppp|wg|veth|bond|tun|tap|dummy|ifb|docker|virbr|br|lo)[0-9]*|(en|wl|ww|sl)[opsx][0-9a-f].*" s == null;
chosenNames =
lib.attrNames cfg.interfaces
++ lib.concatMap (l: lib.optional (l.deviceName != null) l.deviceName) lans
++ lib.optional (cfg.wan.ipv4.pppoe.vlanName != null) cfg.wan.ipv4.pppoe.vlanName;
macs = lib.mapAttrsToList (_: i: i.mac) cfg.interfaces;
validIp = s: ipv4.parse s != null;
hostnameOk = s: builtins.match "[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?" s != null;
# A DNS name: labels of letters, digits, dashes and underscores.
@@ -37,6 +50,10 @@ let
assertion = addrOk;
message = "router: ${where}.ipv4.address `${lan.ipv4.address}` is not a valid IPv4 address.";
}
{
assertion = lan.deviceName == null || lan.vlan != null;
message = "router: ${where}.deviceName needs a vlan.";
}
{
# IFNAMSIZ is 16 including the terminating NUL.
assertion = lib.stringLength lan.device <= 15;
@@ -208,12 +225,16 @@ let
message = "router: ${where}: unknown source zone `${r.from}` (zones: wan, ${lib.concatStringsSep ", " zoneNames}).";
}
{
assertion = lib.elem r.to zoneNames;
assertion = r.to == "local" || lib.elem r.to zoneNames;
message =
if r.to == "wan" then
"router: ${where}: `wan` cannot be a destination; use `internet = true` on the LAN or tunnel."
else
"router: ${where}: unknown destination zone `${r.to}` (zones: ${lib.concatStringsSep ", " zoneNames}).";
"router: ${where}: unknown destination zone `${r.to}` (zones: local, ${lib.concatStringsSep ", " zoneNames}).";
}
{
assertion = !(r.to == "local" && r.natOnly);
message = "router: ${where}: natOnly does not apply to the router itself (`local`); a port forward leads to a host in a zone.";
}
{
assertion = r.from != r.to;
@@ -249,7 +270,7 @@ let
# Rules from the outside must not be able to expose a whole zone by accident.
++ lib.optionals fromWan [
{
assertion = r.destination != null;
assertion = r.destination != null || r.to == "local";
message = "router: ${where}: rules from `wan` must name a destination host or network.";
}
{
@@ -509,6 +530,22 @@ in
assertion = lans != [ ];
message = "router: at least one LAN must be defined.";
}
{
assertion = lib.length macs == lib.length (lib.unique macs);
message = "router.interfaces: two names are given to the same MAC address.";
}
{
assertion = lib.all chosenNameOk chosenNames;
message = "router: names for interfaces and VLAN devices are lower case letters, digits and dashes, start with a letter, have at most 15 characters and do not look like a kernel name (eth0, wlan0, enp2s0, ...): ${lib.concatStringsSep ", " (lib.filter (n: !chosenNameOk n) chosenNames)}.";
}
{
assertion = lib.length chosenNames == lib.length (lib.unique chosenNames);
message = "router: a name is used twice among `interfaces` and the VLAN devices.";
}
{
assertion = cfg.wan.ipv4.pppoe.vlanName == null || cfg.wan.ipv4.pppoe.vlan != null;
message = "router.wan.ipv4.pppoe.vlanName needs pppoe.vlan.";
}
{
assertion = lib.any (l: l.allowManagement) lans;
message = "router: no LAN has allowManagement = true; you would be locked out of the router.";
@@ -567,6 +604,10 @@ in
assertion = !(lib.elem "wan" zoneNames);
message = "router: `wan` is a reserved zone name; give the LAN or tunnel a different `zone`.";
}
{
assertion = !(lib.elem "local" zoneNames);
message = "router: `local` is a reserved zone name (the router itself); give the LAN or tunnel a different `zone`.";
}
]
++ lib.concatLists (lib.mapAttrsToList wgAssertions cfg.wireguard)
++ [