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:
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user