Files
nixos-router/modules/settings/default.nix
T
nilsandClaude Sonnet 5.5 586274af4e 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
2026-11-01 16:56:59 +01:00

345 lines
13 KiB
Nix

# A router configured by a settings file instead of Nix code, so that it can
# be changed on the box itself (by the web UI or by hand) and rebuilt there.
#
# settings.json holds the `router` options as JSON plus a few system settings:
#
# {
# "system": { "hostName": "router", "timeZone": "UTC", "sshKeys": [ "ssh-ed25519 ..." ],
# "stateVersion": "26.11" },
# "router": { "wan": { "interface": "enp1s0" }, "lans": { "lan": { ... } } }
# }
#
# The module system validates it like any configuration: unknown keys, wrong
# types and the router's assertions all fail the build. Secrets stay out of it
# (files under /var/lib/router, referenced by path).
#
# On the device the configuration lives in /var/lib/router/config: a flake
# (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. A change of interface names is only staged for the next boot.
{
config,
lib,
pkgs,
options,
...
}:
let
cfg = config.routerSettings;
profile = "/nix/var/nix/profiles/system";
settings = lib.importJSON cfg.file;
system = settings.system or { };
pending = "/run/router-pending-settings.json";
confirmed = "${cfg.stateDirectory}/settings.confirmed.json";
apply = pkgs.writeShellApplication {
name = "router-apply";
runtimeInputs = [
config.nix.package
pkgs.coreutils
pkgs.gawk
pkgs.gnugrep
];
text = ''
timeout=''${1:-${config.router.rollback.timeout}}
# Remember what is being built: confirming records it (the web UI shows
# settings that differ from it as not yet applied).
building=$(mktemp /run/router-building.XXXXXX)
cp ${cfg.directory}/settings.json "$building"
echo "router: building ${cfg.directory}"
new=$(nix build --no-link --print-out-paths --out-link /run/router-pending \
"path:${cfg.directory}#nixosConfigurations.router.config.system.build.toplevel")
if [ "$new" = "$(readlink -f /run/current-system)" ]; then
echo "router: no changes"
if [ "$new" = "$(readlink -f ${profile})" ]; then
install -m 0600 "$building" ${confirmed}
fi
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"
"$new/bin/switch-to-configuration" test
'';
};
# 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
{
imports = [ ./update.nix ];
# Outside `router`: the settings file defines that whole tree.
options.routerSettings = {
file = lib.mkOption {
type = lib.types.path;
description = "The settings file (JSON) this system is built from.";
};
directory = lib.mkOption {
type = lib.types.str;
default = "/var/lib/router/config";
description = "Where the configuration (flake and settings) lives on the device.";
};
stateDirectory = lib.mkOption {
type = lib.types.str;
default = "/var/lib/router";
description = "Secrets and web UI state (password hash, certificate).";
};
webui = {
enable = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Serve the web UI (HTTPS) to LANs and tunnels with `allowManagement`.";
};
port = lib.mkOption {
type = lib.types.port;
default = 443;
};
httpPort = lib.mkOption {
type = lib.types.nullOr lib.types.port;
default = 80;
description = "Redirect plain HTTP on this port to HTTPS (to the LANs and tunnels with `allowManagement`); null: do not listen.";
};
};
};
config = {
router = lib.mkMerge [
(settings.router // { enable = true; })
{
rollback.onConfirm = ''
if [ -e ${pending} ]; then mv ${pending} ${confirmed}; fi
'';
}
(lib.mkIf cfg.webui.enable {
managementPorts = [
cfg.webui.port
]
++ lib.optional (cfg.webui.httpPort != null) cfg.webui.httpPort;
})
];
networking.hostName = system.hostName or "router";
time.timeZone = system.timeZone or "UTC";
users.users.root.openssh.authorizedKeys.keys = system.sshKeys or [ ];
# Written by router-setup (the web UI password). Users are mutable, so it
# only sets the password when the account is created, at installation;
# `passwd` changes it later.
users.users.root.hashedPasswordFile = lib.mkDefault "${cfg.stateDirectory}/root-password";
system.stateVersion = system.stateVersion or config.system.nixos.release;
nix.settings.experimental-features = [
"nix-command"
"flakes"
];
environment.systemPackages = [
apply
webui
pkgs.jq # for looking at and editing settings.json by hand
pkgs.wireguard-tools # wg: the tunnels' state, from a shell
];
# The web UI starts these rather than running anything itself, so a switch
# that restarts the UI does not cut them short; a switch must not restart
# or stop them either (router-apply is what runs the switch).
systemd.services.router-apply = {
description = "Build and activate the router configuration";
restartIfChanged = false;
stopIfChanged = false;
path = [ "/run/current-system/sw" ];
serviceConfig = {
Type = "oneshot";
ExecStart = lib.getExe apply;
TimeoutStartSec = "1h";
};
};
# 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;
stopIfChanged = false;
serviceConfig = {
Type = "oneshot";
ExecStart = pkgs.writeShellScript "router-root-password" ''
exec ${pkgs.shadow}/bin/usermod --password "$(cat ${cfg.stateDirectory}/root-password)" root
'';
};
};
systemd.services.router-confirm = {
description = "Confirm the active router configuration";
restartIfChanged = false;
stopIfChanged = false;
path = [ "/run/current-system/sw" ];
serviceConfig = {
Type = "oneshot";
ExecStart = "/run/current-system/sw/bin/router-confirm";
};
};
systemd.services.router-webui = lib.mkIf cfg.webui.enable {
description = "Router web UI";
wantedBy = [ "multi-user.target" ];
after = [ "network.target" ];
# iproute2: the dashboard reads the default route and the PPP peer;
# wireguard-tools: the tunnels' status; qrencode: client configs.
path = [
config.systemd.package
pkgs.iproute2
pkgs.wireguard-tools
pkgs.qrencode
pkgs.mkpasswd # the hash of root's password
];
serviceConfig = {
ExecStart = "${lib.getExe webui} -listen :${toString cfg.webui.port}${
lib.optionalString (cfg.webui.httpPort != null) " -http :${toString cfg.webui.httpPort}"
}${
lib.optionalString (
config.router.acme.enable && config.router.acme.domains != [ ]
) " -acme-dir /var/lib/acme/${lib.head config.router.acme.domains}"
} -options ${optionsJson} -config ${cfg.directory} -state ${cfg.stateDirectory}";
Restart = "on-failure";
# It edits settings and secrets and talks to systemd; nothing else.
ProtectSystem = "strict";
ReadWritePaths = [ cfg.stateDirectory ];
ProtectHome = true;
PrivateTmp = true;
PrivateDevices = true;
NoNewPrivileges = true;
ProtectKernelTunables = true;
ProtectKernelModules = true;
ProtectControlGroups = true;
RestrictAddressFamilies = [
"AF_UNIX"
"AF_INET"
"AF_INET6"
"AF_NETLINK"
];
LockPersonality = true;
MemoryDenyWriteExecute = true;
};
};
# The DHCP clients' names in DNS (router.dns.dynamicLeases): reads Kea's
# leases and publishes them in the running Unbound.
systemd.services.router-leases-dns =
lib.mkIf (config.router.enable && config.router.dns.dynamicLeases)
{
description = "Publish DHCP clients' names in DNS";
wantedBy = [ "multi-user.target" ];
after = [
"unbound.service"
"kea-dhcp4-server.service"
];
path = [ config.services.unbound.package ];
serviceConfig = {
ExecStart = "${lib.getExe webui} leases-dns -leases /var/lib/kea/dhcp4.leases -static /etc/router/dns-static.json -socket ${config.services.unbound.localControlSocketPath} -run /run/router-leases-dns";
RuntimeDirectory = "router-leases-dns";
Restart = "always";
RestartSec = 5;
# It reads leases and talks to Unbound's socket; nothing else.
ProtectSystem = "strict";
ProtectHome = true;
PrivateTmp = true;
PrivateNetwork = true;
PrivateDevices = true;
NoNewPrivileges = true;
ProtectKernelTunables = true;
ProtectKernelModules = true;
ProtectControlGroups = true;
RestrictAddressFamilies = [ "AF_UNIX" ];
LockPersonality = true;
MemoryDenyWriteExecute = true;
};
};
# Rebuilding on the device must work without internet (the uplink may be
# what is being configured). What a changed configuration rebuilds is all
# generated by NixOS, but those builds need these packages; found by
# diffing the derivation graphs of typical changes (pool, addresses, host
# name, PPPoE on a VLAN, a VLAN LAN, IPv6 PD/ULA, a port forward, DNS
# records) against the installed system.
system.extraDependencies = with pkgs; [
stdenvNoCC
findXMLCatalogs
jq.dev
kmod.dev
libcap-text-verifier
libxslt.bin
# nftables checks its ruleset at build time inside lkl.
libredirect
lklWithFirewall.lib
lndir
makeShellWrapper
mdadm
nukeReferences
# Switching the uplink to PPPoE is a typical first change.
ppp
];
};
}