# 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@ 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' '' | 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"; }; }; 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" ]; } ]; }]; }; }