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
+52 -6
View File
@@ -7,6 +7,29 @@ in
options.router = {
enable = mkEnableOption "the router role";
interfaces = mkOption {
default = { };
example = {
wan.mac = "52:54:00:12:34:56";
};
description = ''
Names for physical interfaces, keyed by the name to give: the network
card with the given MAC address is called that from the next boot on
(`wan.interface` and `lans.*.interface` use these names). Interfaces
that are not listed keep the kernel's name. Names cannot change while
the system runs, so a change only takes effect after a reboot.
'';
type = types.attrsOf (
types.submodule {
options.mac = mkOption {
type = types.strMatching "([0-9a-f]{2}:){5}[0-9a-f]{2}";
example = "52:54:00:12:34:56";
description = "Permanent MAC address of the network card, in lower case.";
};
}
);
};
wan = {
interface = mkOption {
type = types.str;
@@ -37,6 +60,12 @@ in
default = null;
description = "VLAN ID the ISP requires for PPPoE, or null for untagged.";
};
vlanName = mkOption {
type = types.nullOr types.str;
default = null;
example = "isp";
description = "Name of the VLAN device of the PPPoE session; null: `<interface>.<vlan>`.";
};
username = mkOption {
type = types.str;
default = "";
@@ -117,6 +146,16 @@ in
description = "802.1Q VLAN ID, or null for an untagged LAN on `interface`.";
};
deviceName = mkOption {
type = types.nullOr types.str;
default = null;
example = "dmz";
description = ''
Name of the VLAN device (only with `vlan`); null: `<interface>.<vlan>`.
Like `interfaces`, a changed name is safe only after a reboot.
'';
};
device = mkOption {
type = types.str;
readOnly = true;
@@ -230,7 +269,12 @@ in
};
config.device =
if config.vlan == null then config.interface else "${config.interface}.${toString config.vlan}";
if config.vlan == null then
config.interface
else if config.deviceName != null then
config.deviceName
else
"${config.interface}.${toString config.vlan}";
# "invalid" is only ever seen when assertions.nix is already failing.
config.ipv4.subnet =
@@ -492,11 +536,13 @@ in
description = ''
Allow rules between zones, on top of the default (deny between zones).
Rules are one-directional; return traffic is allowed by connection
tracking. `to` must be the zone of a LAN or tunnel; `from` may also be
the reserved zone `wan` (traffic arriving on the uplinks), which
tracking. `to` must be the zone of a LAN or tunnel, or the reserved
zone `local`, the router itself (SSH, the web UI, ...); `from` may also
be the reserved zone `wan` (traffic arriving on the uplinks), which
publishes services to the outside: such rules must name a
`destination` and protocol (and ports for tcp/udp), so a typo cannot
expose a whole zone. Address translation is separate (`firewall.nat`);
`destination` (not for `local`) and protocol (and ports for tcp/udp),
so a typo cannot expose a whole zone. A port forward on the same port
takes the traffic first, so it never reaches `local`. Address translation is separate (`firewall.nat`);
a port forward needs an allow rule as well, usually with `natOnly`.
'';
type = types.listOf (
@@ -508,7 +554,7 @@ in
};
to = mkOption {
type = types.str;
description = "Destination zone.";
description = "Destination zone, or `local` (the router itself).";
};
protocol = mkOption {
type = types.enum [