router-ui: web management page for ports, devices, traffic, speedtest
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
3030d20034
commit
2f1e14495b
10 changed files with 1396 additions and 41 deletions
|
|
@ -32,6 +32,7 @@
|
|||
{ domain = "homepage.nordhammer.it"; policy = "one_factor"; }
|
||||
# { domain = "7dtd.nordhammer.it"; policy = "one_factor"; } # 7DTD disabled
|
||||
{ domain = "adguard.nordhammer.it"; policy = "one_factor"; }
|
||||
{ domain = "router.nordhammer.it"; policy = "one_factor"; }
|
||||
{ domain = "sonarr.nordhammer.it"; policy = "one_factor"; }
|
||||
{ domain = "radarr.nordhammer.it"; policy = "one_factor"; }
|
||||
{ domain = "bazarr.nordhammer.it"; policy = "one_factor"; }
|
||||
|
|
|
|||
|
|
@ -141,6 +141,14 @@ in
|
|||
"homepage.nordhammer.it" = protectedProxy 8084;
|
||||
# "7dtd.nordhammer.it" = protectedProxy 8090; # 7DTD disabled
|
||||
"adguard.nordhammer.it" = protectedProxy 3000;
|
||||
# Router management UI (services/router-ui.nix). proxy_buffering off so
|
||||
# the apply log streams live instead of arriving in one lump at the end.
|
||||
"router.nordhammer.it" = lib.recursiveUpdate (protectedProxy 8086) {
|
||||
locations."/".extraConfig = autheliaAuthConfig + securityHeaders + ''
|
||||
proxy_buffering off;
|
||||
proxy_read_timeout 2h;
|
||||
'';
|
||||
};
|
||||
"profilarr.nordhammer.it" = protectedProxy 6868;
|
||||
"shelfarr.nordhammer.it" = protectedProxy 5056;
|
||||
"sabnzbd.nordhammer.it" = protectedProxy 8085;
|
||||
|
|
|
|||
211
services/router-ui.nix
Normal file
211
services/router-ui.nix
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
# 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";
|
||||
FORGEJO_TOKEN_FILE = "/var/secrets/forgejo-router-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";
|
||||
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" ];
|
||||
}
|
||||
];
|
||||
}];
|
||||
|
||||
};
|
||||
}
|
||||
|
|
@ -10,13 +10,33 @@
|
|||
# - dnsmasq: DHCP only (port 0 for DNS — AdGuard Home owns :53)
|
||||
# - AdGuard Home (already running): DNS for LAN clients
|
||||
#
|
||||
# Port forwards live in ../ports.toml so they're easy to edit.
|
||||
# Port forwards live in ../ports.toml and LAN devices (static reservations +
|
||||
# block list) in ../devices.toml, so both are easy to edit — by hand, or via
|
||||
# the router UI (services/router-ui.nix), which only ever writes those two
|
||||
# TOML files and never generates Nix.
|
||||
|
||||
{ config, lib, pkgs, ... }:
|
||||
let
|
||||
portsData = builtins.fromTOML (builtins.readFile ../ports.toml);
|
||||
destDefault = portsData.dest_default;
|
||||
|
||||
devices = (builtins.fromTOML (builtins.readFile ../devices.toml)).device or [ ];
|
||||
reservedDevices = builtins.filter (d: d ? ip) devices;
|
||||
blockedDevices = builtins.filter (d: d.blocked or false) devices;
|
||||
|
||||
# Drop everything from a blocked MAC arriving on the LAN. These are emitted
|
||||
# at the TOP of the input and forward chains, ahead of the `ct state
|
||||
# established,related accept` line — otherwise a device that was already
|
||||
# talking keeps its existing flows alive indefinitely.
|
||||
#
|
||||
# ponytail: conntrack entries created before the block still linger until
|
||||
# they time out (a few minutes). Add `conntrack -D -s <ip>` to the apply
|
||||
# path if that wait ever matters.
|
||||
# ponytail: MAC-based, so a device that randomises its MAC walks around it.
|
||||
blockRules = lib.concatMapStringsSep "\n "
|
||||
(d: ''iifname "eth0" ether saddr ${d.mac} drop comment "${d.name} blocked"'')
|
||||
blockedDevices;
|
||||
|
||||
# Phase-1 transition list; empty now that eero is in bridge mode and
|
||||
# eno1 is strictly the ISP-facing WAN.
|
||||
trustedLegacyCidrs = [ ];
|
||||
|
|
@ -116,6 +136,8 @@ in
|
|||
content = ''
|
||||
chain input {
|
||||
type filter hook input priority 0; policy drop;
|
||||
# Blocked devices first — before the conntrack accept.
|
||||
${blockRules}
|
||||
ct state established,related accept
|
||||
ct state invalid drop
|
||||
iifname "lo" accept
|
||||
|
|
@ -136,6 +158,8 @@ in
|
|||
}
|
||||
chain forward {
|
||||
type filter hook forward priority 0; policy drop;
|
||||
# Blocked devices first — before the conntrack accept.
|
||||
${blockRules}
|
||||
ct state established,related accept
|
||||
ct state invalid drop
|
||||
# LAN → anywhere
|
||||
|
|
@ -187,10 +211,8 @@ in
|
|||
"option:router,10.0.0.1"
|
||||
"option:dns-server,10.0.0.1"
|
||||
];
|
||||
# Static reservations — format: "MAC,label,IP"
|
||||
dhcp-host = [
|
||||
"f0:a7:31:6c:50:4b,camera-bedroom,10.0.0.39"
|
||||
];
|
||||
# Static reservations — format: "MAC,label,IP". From ../devices.toml.
|
||||
dhcp-host = map (d: "${d.mac},${d.name},${d.ip}") reservedDevices;
|
||||
# Helpful: log leases to the journal
|
||||
log-dhcp = true;
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue