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:
@@ -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)
|
||||
++ [
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 [
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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