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)
++ [
+9 -2
View File
@@ -120,8 +120,9 @@ let
id = toString i;
label = "${r.from}->${r.to}";
fromDevs = if r.from == "wan" then uplinks else zoneDevices r.from;
toLocal = r.to == "local";
toDevs = zoneDevices r.to;
base = "iifname ${set fromDevs} oifname ${set toDevs} ${daddrOf r}${l4 r.protocol r.ports r.sourcePorts}${lib.optionalString r.natOnly "ct status dnat "}";
base = "iifname ${set fromDevs} ${lib.optionalString (!toLocal) "oifname ${set toDevs} "}${daddrOf r}${l4 r.protocol r.ports r.sourcePorts}${lib.optionalString r.natOnly "ct status dnat "}";
logPrefix = what: ''log prefix "router fwd#${id} ${label} ${what}: " '';
limitLines =
v:
@@ -148,7 +149,11 @@ let
in
{
# A zone nothing is assigned to yet has no devices; the rule waits for one.
forward = lib.optionals (fromDevs != [ ] && toDevs != [ ]) (
# `local` is the router itself: those rules are on the input chain.
forward = lib.optionals (!toLocal && fromDevs != [ ] && toDevs != [ ]) (
lib.concatMap (v: limitLines v ++ acceptLines v) (variantsOf r)
);
input = lib.optionals (toLocal && fromDevs != [ ]) (
lib.concatMap (v: limitLines v ++ acceptLines v) (variantsOf r)
);
};
@@ -260,6 +265,8 @@ in
${lib.optionalString (mgmtIfaces != [ ]) "iifname ${set mgmtIfaces} tcp dport { ${
lib.concatMapStringsSep ", " toString ([ 22 ] ++ config.router.managementPorts)
} } accept"}
${lib.concatStringsSep "\n " (lib.concatMap (p: p.input) parts)}
}
chain forward {
+31 -1
View File
@@ -9,7 +9,11 @@ let
v6dev = if v6ppp then cfg.wan.ipv4.interface else cfg.wan.interface;
pppoe = cfg.wan.ipv4.type == "pppoe";
vlan = cfg.wan.ipv4.pppoe.vlan;
vlanDevice = "${cfg.wan.interface}.${toString vlan}";
vlanDevice =
if cfg.wan.ipv4.pppoe.vlanName != null then
cfg.wan.ipv4.pppoe.vlanName
else
"${cfg.wan.interface}.${toString vlan}";
wanVlanOn = parent: lib.optional (pppoe && vlan != null && parent == cfg.wan.interface) vlanDevice;
lans = lib.attrValues cfg.lans;
@@ -38,6 +42,32 @@ in
lib.attrValues cfg.lans
);
# Rename network cards by MAC address. Only at boot: the interface that is
# up cannot be renamed, and router-apply stages such a change for the
# next boot instead of activating it (see the names file below).
systemd.network.links = lib.mapAttrs' (
name: i:
lib.nameValuePair "05-${name}" {
matchConfig = {
PermanentMACAddress = i.mac;
Type = "ether";
};
linkConfig.Name = name;
}
) cfg.interfaces;
# The names this system gives to devices; router-apply compares the file
# between the running and the new system and stages a change of a name for
# the next boot (a device that is up cannot be renamed).
environment.etc."router/interface-names".text =
lib.concatMapStrings (l: l + "\n") (
lib.sort lib.lessThan (
lib.mapAttrsToList (name: i: "nic ${i.mac} ${name}") cfg.interfaces
++ map (l: "vlan ${l.interface} ${toString l.vlan} ${l.device}") vlanLans
++ lib.optional (pppoe && vlan != null) "vlan ${cfg.wan.interface} ${toString vlan} ${vlanDevice}"
)
);
boot.kernel.sysctl = {
"net.ipv4.conf.all.forwarding" = 1;
"net.ipv6.conf.all.forwarding" = 1;
+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 [
+7 -1
View File
@@ -12,7 +12,13 @@ let
pppoe = wan.ipv4.pppoe;
# Device carrying the PPPoE session: the VLAN on the uplink, or the uplink itself.
device = if pppoe.vlan == null then wan.interface else "${wan.interface}.${toString pppoe.vlan}";
device =
if pppoe.vlan == null then
wan.interface
else if pppoe.vlanName != null then
pppoe.vlanName
else
"${wan.interface}.${toString pppoe.vlan}";
# Unit that appears once udev has seen the device (VLAN devices are created
# by networkd after boot has begun, so pppd must not start before it).
+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";