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
+66 -2
View File
@@ -17,11 +17,12 @@
# (pinned to this router's source), settings.json and an optional local.nix.
# `router-apply` builds it and activates it with commit-confirm: unless
# `router-confirm` follows within router.rollback.timeout, the previous system
# comes back.
# comes back. A change of interface names is only staged for the next boot.
{
config,
lib,
pkgs,
options,
...
}:
let
@@ -38,6 +39,8 @@ let
runtimeInputs = [
config.nix.package
pkgs.coreutils
pkgs.gawk
pkgs.gnugrep
];
text = ''
timeout=''${1:-${config.router.rollback.timeout}}
@@ -56,6 +59,24 @@ let
rm -f "$building"
exit 0
fi
# Interface names cannot change on a running system, and the new
# configuration refers to them: stage it for the next boot instead.
names() { cat "$1/etc/router/interface-names" 2>/dev/null || true; }
staged=
# A card gets, loses or changes its name.
[ "$(names "$new" | grep '^nic ' || true)" = "$(names /run/current-system | grep '^nic ' || true)" ] || staged=1
# A VLAN device that exists keeps its parent and ID but gets another name.
awk 'NR == FNR { if ($1 == "vlan") old[$2 " " $3] = $4; next }
$1 == "vlan" && ($2 " " $3) in old && old[$2 " " $3] != $4 { bad = 1 }
END { exit bad }' <(names /run/current-system) <(names "$new") || staged=1
if [ -n "$staged" ]; then
nix-env -p ${profile} --set "$new"
"$new/bin/switch-to-configuration" boot
install -m 0600 "$building" ${confirmed}
rm -f "$building"
echo "router: interface names change; the new configuration starts at the next reboot"
exit 0
fi
mv "$building" ${pending}
router-arm "$timeout"
echo "router: activating $new"
@@ -63,6 +84,38 @@ let
'';
};
# What the web UI's MCP endpoint offers an assistant as the router's options.
optionsJson =
(pkgs.nixosOptionsDoc {
options = {
inherit (options) router;
};
warningsAreErrors = false;
}).optionsJSON
+ "/share/doc/nixos/options.json";
# Evaluate the saved settings like an apply would, without building: the
# option types and the router's assertions. Started by the web UI.
check = pkgs.writeShellApplication {
name = "router-check";
runtimeInputs = [
config.nix.package
pkgs.coreutils
pkgs.jq
];
text = ''
etag=$(sha256sum ${cfg.directory}/settings.json | cut -c1-16)
mkdir -p ${cfg.stateDirectory}/check
out=$(mktemp ${cfg.stateDirectory}/check/result.XXXXXX)
if msg=$(nix eval --raw "path:${cfg.directory}#nixosConfigurations.router.config.system.build.toplevel.drvPath" 2>&1); then
jq -n --arg etag "$etag" --arg time "$(date -u +%FT%TZ)" '{ok: true, etag: $etag, time: $time}' > "$out"
else
jq -n --arg etag "$etag" --arg time "$(date -u +%FT%TZ)" --arg error "$msg" '{ok: false, error: $error, etag: $etag, time: $time}' > "$out"
fi
mv "$out" ${cfg.stateDirectory}/check/result.json
'';
};
webui = pkgs.callPackage ../../pkgs/router-webui/package.nix { };
in
{
@@ -155,6 +208,17 @@ in
};
# Puts root's password hash (written by the web UI) into /etc/shadow now,
# rather than at the next switch; the web UI itself may not touch /etc.
systemd.services.router-check = lib.mkIf cfg.webui.enable {
description = "Evaluate the router configuration (no build)";
restartIfChanged = false;
stopIfChanged = false;
path = [ "/run/current-system/sw" ];
serviceConfig = {
Type = "oneshot";
ExecStart = lib.getExe check;
TimeoutStartSec = "10min";
};
};
systemd.services.router-root-password = lib.mkIf cfg.webui.enable {
description = "Apply root's password from the web UI";
restartIfChanged = false;
@@ -197,7 +261,7 @@ in
lib.optionalString (
config.router.acme.enable && config.router.acme.domains != [ ]
) " -acme-dir /var/lib/acme/${lib.head config.router.acme.domains}"
} -config ${cfg.directory} -state ${cfg.stateDirectory}";
} -options ${optionsJson} -config ${cfg.directory} -state ${cfg.stateDirectory}";
Restart = "on-failure";
# It edits settings and secrets and talks to systemd; nothing else.
ProtectSystem = "strict";