nixos/services/router-ui.nix

245 lines
9.4 KiB
Nix
Raw Permalink Normal View History

# services/router-ui.nix — web UI for the day-to-day router chores
# (router.nordhammer.it, behind Authelia via services/nginx.nix).
#
# The app never generates Nix. It edits two TOML files — ../ports.toml and
# ../devices.toml — through the Forgejo API, then asks router-apply@<rev> to
# deploy that exact commit. services/router.nix reads those same files, so the
# UI is a constrained editor for config that is still fully reviewable in git.
#
# Apply is deliberately paranoid, because a bad generation here takes the
# household's internet with it:
#
# nixos-rebuild test -> eval/build failure aborts before anything activates
# wait, then health-check WAN ping + dnsmasq + LAN address + NAT table
# healthy -> nixos-rebuild switch (only now does it become the boot default)
# unhealthy -> switch-to-configuration test on the old profile, + ntfy alert
#
# `test` never touches the boot default, so a reboot is always an escape hatch.
#
# Requires a Forgejo token with write:repository on rope/nixos:
# printf '%s' '<token>' | sudo tee /var/secrets/forgejo-router-token
# sudo chmod 600 /var/secrets/forgejo-router-token
{ config, lib, pkgs, ... }:
let
port = 8086;
stateDir = "/var/lib/router-ui";
flakeUrl = "git+https://forg.gregersen.it/rope/nixos";
pythonEnv = pkgs.python3.withPackages (ps: [ ps.tomlkit ]);
# Absolute paths, not PATH lookups: the sudoers rule below has to match the
# argv the app actually execs, character for character.
systemctl = "${pkgs.systemd}/bin/systemctl";
sudo = "/run/wrappers/bin/sudo";
applyScript = pkgs.writeShellScript "router-apply" ''
set -uo pipefail
rev="$1"
flake="${flakeUrl}?rev=$rev"
secret=/var/secrets/ntfy-url
notify() {
[ -f "$secret" ] || return 0
url=$(${pkgs.coreutils}/bin/tr -d '\n' < "$secret")
${pkgs.curl}/bin/curl -fsS --max-time 10 \
-H "Title: Router config" -H "Priority: high" -H "Tags: satellite_antenna" \
-d "$1" "$url" >/dev/null 2>&1 || true
}
old=$(${pkgs.coreutils}/bin/readlink /run/current-system)
echo "==> building + activating $rev (test: boot default untouched)"
if ! nixos-rebuild test --refresh --flake "$flake" -L; then
echo "!! eval or build failed nothing was activated, old config still running"
notify "Router apply ''${rev:0:8}: build failed. Nothing changed."
exit 1
fi
echo "==> activated, waiting 45s for the network to settle"
sleep 45
healthy=1
${pkgs.iputils}/bin/ping -c1 -W3 -I eno1 1.1.1.1 >/dev/null 2>&1 \
|| ${pkgs.iputils}/bin/ping -c1 -W3 -I eno1 8.8.8.8 >/dev/null 2>&1 \
|| { echo "!! no WAN"; healthy=0; }
${systemctl} is-active --quiet dnsmasq || { echo "!! dnsmasq down"; healthy=0; }
${pkgs.iproute2}/bin/ip -4 addr show eth0 \
| ${pkgs.gnugrep}/bin/grep -q '10\.0\.0\.1' || { echo "!! LAN address gone"; healthy=0; }
${pkgs.nftables}/bin/nft list table ip router-nat >/dev/null 2>&1 \
|| { echo "!! NAT table missing"; healthy=0; }
if [ "$healthy" = 1 ]; then
echo "==> healthy making $rev the boot default"
if nixos-rebuild switch --refresh --flake "$flake" -L; then
${pkgs.coreutils}/bin/printf '%s' "$rev" > ${stateDir}/last-rev
${pkgs.coreutils}/bin/chown router-ui ${stateDir}/last-rev || true
record-update "$old" /run/current-system || true
notify "Router applied ''${rev:0:8}."
echo "==> done"
else
echo "!! switch failed after a healthy test running config is fine, boot default is not"
notify "Router apply ''${rev:0:8}: switch failed after a healthy test. Check the box."
exit 1
fi
else
echo "!! health check failed reverting to the previous generation"
/nix/var/nix/profiles/system/bin/switch-to-configuration test
notify "Router apply ''${rev:0:8} FAILED health check. Rolled back."
exit 1
fi
'';
in
{
config = lib.mkIf (config.networking.hostName == "FredOS-Mediaserver") {
users.users.router-ui = {
isSystemUser = true;
group = "router-ui";
description = "Router management UI";
};
users.groups.router-ui = { };
# WAN traffic history for the Traffic tab. No further config needed —
# vnstatd picks up every interface on its own.
services.vnstat.enable = true;
# The copies baked into the running generation. The UI diffs these against
# what Forgejo has on main to show whether a change is still undeployed.
environment.etc."router/ports.toml".source = ../ports.toml;
environment.etc."router/devices.toml".source = ../devices.toml;
systemd.services.router-ui = {
description = "Router management UI";
wantedBy = [ "multi-user.target" ];
after = [ "network.target" ];
path = [ pkgs.iproute2 config.services.vnstat.package pkgs.systemd ];
environment = {
ROUTER_UI_PORT = toString port;
ROUTER_UI_STATE = stateDir;
ROUTER_UI_ETC = "/etc/router";
FORGEJO_API = "https://forg.gregersen.it/api/v1";
FORGEJO_REPO = "rope/nixos";
FORGEJO_BRANCH = "main";
# systemd reads the real file as root and drops a copy in the unit's
# credentials dir, owned by the service user. Keeps /var/secrets
# uniformly root-only 0600 like every other secret on this box.
FORGEJO_TOKEN_FILE = "%d/forgejo-token";
WAN_IF = "eno1";
LAN_IF = "eth0";
LAN_PREFIX = "10.0.0.";
ROUTER_IP = "10.0.0.1";
POOL_START = "100";
POOL_END = "250";
DNSMASQ_LEASES = "/var/lib/dnsmasq/dnsmasq.leases";
OUI_FILE = "${pkgs.nmap}/share/nmap/nmap-mac-prefixes";
SUDO_BIN = sudo;
SYSTEMCTL_BIN = systemctl;
JOURNALCTL_BIN = "${pkgs.systemd}/bin/journalctl";
IP_BIN = "${pkgs.iproute2}/bin/ip";
VNSTAT_BIN = "${config.services.vnstat.package}/bin/vnstat";
};
serviceConfig = {
ExecStart = "${pythonEnv}/bin/python3 ${../scripts/router-ui.py}";
User = "router-ui";
Group = "router-ui";
StateDirectory = "router-ui";
# Note: the unit refuses to start if this file is missing. That's the
# loud failure we want — without it the UI can't read or write config.
LoadCredential = [ "forgejo-token:/var/secrets/forgejo-router-token" ];
Restart = "on-failure";
RestartSec = 5;
# Reads /var/secrets/forgejo-router-token, so it can't be fully locked
# down, but nothing here needs to write outside its state dir.
ProtectSystem = "strict";
ProtectHome = true;
PrivateTmp = true;
NoNewPrivileges = false; # it shells out through sudo for apply
};
};
# Deploy one specific commit. Instance name is the git rev.
systemd.services."router-apply@" = {
description = "Deploy router config %i (test, health check, then switch)";
path = [ pkgs.nixos-rebuild pkgs.nix pkgs.systemd "/run/current-system/sw" ];
environment.HOME = "/root";
serviceConfig = {
Type = "oneshot";
ExecStart = "${applyScript} %i";
TimeoutStartSec = "90min";
};
};
# Snapshot the nftables per-device counters and fold them into totals that
# outlive a ruleset reload. Root, because reading an nft set needs
# NET_ADMIN; the web app only ever reads the resulting file.
systemd.services.router-accounting = {
description = "Accumulate per-device traffic counters";
serviceConfig = {
Type = "oneshot";
ExecStart = "${pythonEnv}/bin/python3 ${../scripts/router-ui.py} tick";
# No StateDirectory: this unit is root, and StateDirectory would chown
# /var/lib/router-ui away from the router-ui user on every tick. The
# script mkdir -p's it, so a cold start before the web app is fine.
};
environment = {
ROUTER_UI_STATE = stateDir;
DNSMASQ_LEASES = "/var/lib/dnsmasq/dnsmasq.leases";
NFT_BIN = "${pkgs.nftables}/bin/nft";
};
};
systemd.timers.router-accounting = {
wantedBy = [ "timers.target" ];
timerConfig = {
OnBootSec = "2min";
OnUnitActiveSec = "1min";
};
};
systemd.services.router-speedtest = {
description = "Record a speedtest result";
serviceConfig = {
Type = "oneshot";
User = "router-ui";
Group = "router-ui";
StateDirectory = "router-ui";
};
script = ''
out=$(${pkgs.speedtest-go}/bin/speedtest-go --json 2>/dev/null) || exit 0
# Only append if it actually produced a JSON object; a failed run
# prints nothing and must not corrupt the log.
case "$out" in
'{'*) ${pkgs.coreutils}/bin/printf '%s\n' "$out" >> ${stateDir}/speedtest.jsonl ;;
*) echo "speedtest produced no result" >&2 ;;
esac
'';
};
systemd.timers.router-speedtest = {
wantedBy = [ "timers.target" ];
timerConfig = {
OnBootSec = "10min";
OnUnitActiveSec = "6h";
RandomizedDelaySec = "10min";
};
};
# The UI's only privilege: starting these two units. Nothing else.
security.sudo.extraRules = [{
users = [ "router-ui" ];
commands = [
{
command = "${systemctl} start --no-block router-apply@[0-9a-f]*.service";
options = [ "NOPASSWD" ];
}
{
command = "${systemctl} start --no-block router-speedtest.service";
options = [ "NOPASSWD" ];
}
];
}];
};
}