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