210 lines
7.7 KiB
Nix
210 lines
7.7 KiB
Nix
# 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 delegates to the shared deployment transaction in deployment.nix.
|
|
# Builds precede activation; an independent timer recovers partial failures.
|
|
#
|
|
# 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 -euo 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
|
|
}
|
|
|
|
if [[ ! "$rev" =~ ^[0-9a-f]{40}$ ]]; then
|
|
echo "Invalid commit revision" >&2
|
|
exit 1
|
|
fi
|
|
if ${config.system.build.deploy}/bin/fred-deploy --flake "$flake"; then
|
|
${pkgs.coreutils}/bin/printf '%s' "$rev" > ${stateDir}/last-rev
|
|
${pkgs.coreutils}/bin/chown router-ui ${stateDir}/last-rev
|
|
notify "Router applied ''${rev:0:8}."
|
|
else
|
|
notify "Router apply ''${rev:0:8} failed. Check the apply journal and /var/lib/fred-deploy/last-result.json for recovery status."
|
|
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 through the shared transaction";
|
|
restartIfChanged = false;
|
|
unitConfig.X-StopOnRemoval = false;
|
|
path = [ pkgs.systemd ];
|
|
environment.HOME = "/root";
|
|
serviceConfig = {
|
|
Type = "oneshot";
|
|
ExecStart = "${applyScript} %i";
|
|
TimeoutStartSec = "105min";
|
|
};
|
|
};
|
|
|
|
# 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" ];
|
|
}
|
|
];
|
|
}];
|
|
|
|
};
|
|
}
|