router-ui: web management page for ports, devices, traffic, speedtest

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
rope 2026-08-15 12:15:54 +01:00
parent 3030d20034
commit 2f1e14495b
10 changed files with 1396 additions and 41 deletions

4
.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
__pycache__/
result
result-*
*.qcow2

View file

@ -104,6 +104,7 @@ in
./services/shelfarr.nix ./services/shelfarr.nix
./services/adguard.nix ./services/adguard.nix
./services/router.nix ./services/router.nix
./services/router-ui.nix
./services/crowdsec.nix ./services/crowdsec.nix
./services/service-health.nix ./services/service-health.nix
./services/sabnzbd.nix ./services/sabnzbd.nix

17
devices.toml Normal file
View file

@ -0,0 +1,17 @@
# devices.toml — LAN device registry for the router (services/router.nix)
#
# Written by the router UI (router.nordhammer.it), safe to edit by hand.
# Fields:
# mac — device MAC, lowercase hex with colons (required)
# name — DHCP hostname / label (required)
# ip — static reservation. Omit for a normal pool lease.
# Must be OUTSIDE the DHCP pool 10.0.0.100-250.
# blocked — true drops all traffic from this MAC on the LAN interface.
# Omitted rather than written as false, which is what the UI does.
# note — free text, shown in the UI only
[[device]]
mac = "f0:a7:31:6c:50:4b"
name = "camera-bedroom"
ip = "10.0.0.39"
note = "go2rtc / RTSP source"

View file

@ -7,9 +7,31 @@
# ports — port range as a string, e.g. "26901-26902" # ports — port range as a string, e.g. "26901-26902"
# protocol — "tcp", "udp", or "both" # protocol — "tcp", "udp", or "both"
# dest — LAN IP to forward to (optional; defaults to 10.0.0.1) # dest — LAN IP to forward to (optional; defaults to 10.0.0.1)
#
# Editable from the router UI (services/router-ui.nix). Keep parked/commented
# entries ABOVE the first [[forward]]: in TOML a comment belongs to whatever
# table precedes it, so anything written below the last [[forward]] is deleted
# along with that entry the moment the UI removes it.
dest_default = "10.0.0.1" dest_default = "10.0.0.1"
# --- Parked: re-add by moving a block down and uncommenting it ---------------
#
# 7DTD — servers disabled in services/game-servers.nix.
# name = "7DTD game", port = 26900, protocol = "both"
# name = "7DTD voice/dynamic", ports = "26901-26902", protocol = "udp"
# name = "7DTD-coop game", port = 26910, protocol = "both"
# name = "7DTD-coop voice", ports = "26911-26912", protocol = "udp"
#
# DR (Dungeon Runners) — services/dr-server.nix is disabled.
# 2110 tcp, 2603 both, 2604-2605 udp, 2606 tcp
#
# WoW Classic — stopped 2026-07-28 (containers stopped, not deleted).
# name = "WoW Classic realmd", port = 3724, protocol = "tcp"
# name = "WoW Classic worldserver", port = 8095, protocol = "tcp"
# --- Active ------------------------------------------------------------------
[[forward]] [[forward]]
name = "HTTP" name = "HTTP"
port = 80 port = 80
@ -32,39 +54,3 @@ protocol = "tcp"
name = "Pelican game servers" name = "Pelican game servers"
ports = "25565-25600" ports = "25565-25600"
protocol = "both" protocol = "both"
# 7DTD forwards commented out — servers disabled in services/game-servers.nix.
# [[forward]]
# name = "7DTD game"
# port = 26900
# protocol = "both"
#
# [[forward]]
# name = "7DTD voice/dynamic"
# ports = "26901-26902"
# protocol = "udp"
#
# [[forward]]
# name = "7DTD-coop game"
# port = 26910
# protocol = "both"
#
# [[forward]]
# name = "7DTD-coop voice/dynamic"
# ports = "26911-26912"
# protocol = "udp"
# DR (Dungeon Runners) forwards removed — services/dr-server.nix is disabled.
# Re-add 2110 tcp, 2603 both, 2604-2605 udp, 2606 tcp if it comes back.
# WoW Classic server stopped 2026-07-28 (docker containers stopped, not deleted).
# Re-enable both forwards to reopen WAN access.
# [[forward]]
# name = "WoW Classic realmd"
# port = 3724
# protocol = "tcp"
#
# [[forward]]
# name = "WoW Classic worldserver"
# port = 8095
# protocol = "tcp"

143
scripts/router-ui-check.py Normal file
View file

@ -0,0 +1,143 @@
"""Self-check for router-ui.py: TOML round-trips and the refusal rules.
Run it against the same python the service uses:
nix shell .#nixosConfigurations.FredOS-Mediaserver.pkgs.python3Packages.tomlkit \
-c python3 scripts/router-ui-check.py
The TOML round-trip cases are the ones that matter: a save must leave an
unedited file byte-identical, and must never eat the parked/commented forwards
in ports.toml.
"""
import difflib
import importlib.util
import os
import sys
import tempfile
import tomlkit
HERE = os.path.dirname(os.path.abspath(__file__))
REPO = os.path.join(HERE, "..")
spec = importlib.util.spec_from_file_location("routerui", os.path.join(HERE, "router-ui.py"))
r = importlib.util.module_from_spec(spec)
spec.loader.exec_module(r)
def refuses(fn, *a):
try:
fn(*a)
except r.Refused as e:
return str(e)
raise AssertionError(f"{fn.__name__} accepted what it should have refused")
def same(before, after, what):
assert before == after, what + ":\n" + "".join(
difflib.unified_diff(before.splitlines(1), after.splitlines(1), "before", "after"))
# --- ports.toml round-trip ---------------------------------------------------
src = open(os.path.join(REPO, "ports.toml")).read()
doc = tomlkit.parse(src)
items = r.forwards_from(doc)
assert len(items) == 4, items
assert items[3]["port"] == "25565-25600", items[3]
# Saving without editing anything must not touch a single byte.
r.check_forwards(items)
r.edit_aot(doc, "forward", items)
same(src, tomlkit.dumps(doc), "no-op forward save rewrote the file")
# Add one, drop the Pelican range; every comment block must survive.
doc2 = tomlkit.parse(src)
items2 = r.forwards_from(doc2)
items2.pop(3)
items2.append({"_i": None, "name": "Test", "port": "9999", "protocol": "udp",
"dest": "10.0.0.5"})
r.check_forwards(items2)
r.edit_aot(doc2, "forward", items2)
out = tomlkit.dumps(doc2)
assert "7DTD voice/dynamic" in out, "parked blocks were lost"
assert "WoW Classic realmd" in out, "parked blocks were lost"
assert "Pelican game-server allocation range" in out, "inline comment was lost"
assert "Pelican game servers" not in out.replace("# Pelican", ""), "deletion did not happen"
assert 'name = "Test"' in out and "port = 9999" in out, out[-300:]
back = tomlkit.parse(out)["forward"]
assert len(back) == 4 and back[3]["protocol"] == "udp" and back[3]["dest"] == "10.0.0.5"
assert [f["name"] for f in back[:3]] == ["HTTP", "HTTPS", "SSH"]
# --- forward validation ------------------------------------------------------
mk = lambda: r.forwards_from(tomlkit.parse(src)) # noqa: E731 - fresh fixture per case
print(refuses(r.check_forwards, [f for f in mk() if f["name"] != "SSH"]))
print(refuses(r.check_forwards, [f for f in mk() if f["name"] != "HTTPS"]))
print(refuses(r.check_forwards, mk() + [{"_i": None, "name": "x", "port": "70000",
"protocol": "tcp", "dest": ""}]))
print(refuses(r.check_forwards, mk() + [{"_i": None, "name": "x", "port": "9-8",
"protocol": "tcp", "dest": ""}]))
print(refuses(r.check_forwards, mk() + [{"_i": None, "name": "x", "port": "80",
"protocol": "sctp", "dest": ""}]))
print(refuses(r.check_forwards, mk() + [{"_i": None, "name": "", "port": "80",
"protocol": "tcp", "dest": ""}]))
# A range that covers 22 and 443 satisfies the keep-me-reachable rule.
r.check_forwards([{"_i": None, "name": "wide", "port": "20-500", "protocol": "both",
"dest": ""}])
# --- device validation -------------------------------------------------------
def dev(**kw):
base = {"_i": None, "mac": "aa:bb:cc:dd:ee:01", "name": "thing", "ip": "",
"blocked": False, "note": ""}
return {**base, **kw}
me, my_ip = "aa:bb:cc:dd:ee:99", "10.0.0.161"
print(refuses(r.check_devices, [dev(ip="10.0.0.161")], me, my_ip)) # inside the pool
print(refuses(r.check_devices, [dev(ip="10.0.0.1")], me, my_ip)) # the router itself
print(refuses(r.check_devices, [dev(ip="192.168.1.5")], me, my_ip)) # off-LAN
print(refuses(r.check_devices, [dev(mac="nope")], me, my_ip))
print(refuses(r.check_devices, [dev(name="bad name")], me, my_ip))
print(refuses(r.check_devices, [dev(), dev()], me, my_ip)) # duplicate MAC
print(refuses(r.check_devices, [dev(ip="10.0.0.20"),
dev(mac="aa:bb:cc:dd:ee:02", ip="10.0.0.20")], me, my_ip))
print(refuses(r.check_devices, [dev(mac=me, blocked=True)], me, my_ip))
print(refuses(r.check_devices, [dev(ip="10.0.0.30", blocked=True)], me, "10.0.0.30"))
ok = [dev(ip="10.0.0.39", name="camera-bedroom")]
r.check_devices(ok, me, my_ip)
assert ok[0]["ip"] == "10.0.0.39" and "blocked" not in ok[0] and "note" not in ok[0]
cleared = [dev(ip="")]
r.check_devices(cleared, me, my_ip)
assert "ip" not in cleared[0], "an empty reservation should drop the key entirely"
# --- devices.toml round-trip -------------------------------------------------
dsrc = open(os.path.join(REPO, "devices.toml")).read()
ddoc = tomlkit.parse(dsrc)
ditems = r.devices_from(ddoc)
assert len(ditems) == 1 and ditems[0]["ip"] == "10.0.0.39"
r.check_devices(ditems, me, my_ip)
r.edit_aot(ddoc, "device", ditems)
same(dsrc, tomlkit.dumps(ddoc), "no-op device save rewrote the file")
ditems = r.devices_from(ddoc)
ditems[0]["ip"] = ""
ditems[0]["blocked"] = True
r.check_devices(ditems, me, my_ip)
r.edit_aot(ddoc, "device", ditems)
entry = tomlkit.dumps(ddoc).split("[[device]]")[1]
assert "ip = " not in entry, entry
assert "blocked = true" in entry, entry
# --- speedtest parsing (bytes/s -> Mbps, ns -> ms) ---------------------------
sample = ('{"timestamp":"2026-08-15 12:00:55","servers":[{"name":"Preston",'
'"latency":18796260,"dl_speed":63607890.17,"ul_speed":8839605.08}]}\n')
tmp = os.path.join(tempfile.mkdtemp(), "st.jsonl")
open(tmp, "w").write(sample + "not json at all\n" + sample)
r.SPEEDTEST_LOG = tmp
got = r.speedtests()
assert len(got) == 2, got
assert got[0] == {"ts": "2026-08-15 12:00:55", "down": 508.9, "up": 70.7,
"ping": 18.8, "server": "Preston"}, got[0]
print("\nall checks passed", file=sys.stderr)

962
scripts/router-ui.py Normal file
View file

@ -0,0 +1,962 @@
#!/usr/bin/env python3
"""router-ui — management page for the FredOS router (services/router-ui.nix).
Deliberately narrow: this process never generates Nix. It reads and writes two
TOML files (ports.toml, devices.toml) through the Forgejo API, then asks a
systemd unit to deploy that exact commit. Everything else is read-only.
Two views of the config exist and they are not the same thing:
* deployed /etc/router/*.toml, baked into the running system generation
* git what Forgejo has on main, i.e. what the next apply will deploy
The UI edits git and shows drift against deployed.
No auth here: nginx + Authelia is the gate, and we bind loopback only.
"""
import base64
import json
import os
import re
import socket
import subprocess
import time
import urllib.error
import urllib.request
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import tomlkit
# --- config (all injected by the systemd unit) -------------------------------
PORT = int(os.environ.get("ROUTER_UI_PORT", "8086"))
STATE = os.environ.get("ROUTER_UI_STATE", "/var/lib/router-ui")
ETC = os.environ.get("ROUTER_UI_ETC", "/etc/router")
FORGEJO = os.environ.get("FORGEJO_API", "https://forg.gregersen.it/api/v1")
REPO = os.environ.get("FORGEJO_REPO", "rope/nixos")
BRANCH = os.environ.get("FORGEJO_BRANCH", "main")
TOKEN_FILE = os.environ.get("FORGEJO_TOKEN_FILE", "/var/secrets/forgejo-router-token")
WAN_IF = os.environ.get("WAN_IF", "eno1")
LAN_IF = os.environ.get("LAN_IF", "eth0")
LAN_PREFIX = os.environ.get("LAN_PREFIX", "10.0.0.")
ROUTER_IP = os.environ.get("ROUTER_IP", "10.0.0.1")
POOL = (int(os.environ.get("POOL_START", "100")), int(os.environ.get("POOL_END", "250")))
LEASES = os.environ.get("DNSMASQ_LEASES", "/var/lib/dnsmasq/dnsmasq.leases")
OUI_FILE = os.environ.get("OUI_FILE", "")
SPEEDTEST_LOG = os.path.join(STATE, "speedtest.jsonl")
LAST_REV = os.path.join(STATE, "last-rev")
# Absolute, injected by the unit. sudo matches on argv, so the systemctl path
# here must be byte-identical to the one in the sudoers rule.
SUDO = os.environ.get("SUDO_BIN", "/run/wrappers/bin/sudo")
SYSTEMCTL = os.environ.get("SYSTEMCTL_BIN", "systemctl")
JOURNALCTL = os.environ.get("JOURNALCTL_BIN", "journalctl")
IP = os.environ.get("IP_BIN", "ip")
VNSTAT = os.environ.get("VNSTAT_BIN", "vnstat")
# Forwards that must always survive an edit — losing either locks us out.
REQUIRED_FORWARDS = [("tcp", "22"), ("tcp", "443")]
MAC_RE = re.compile(r"^[0-9a-f]{2}(:[0-9a-f]{2}){5}$")
PORT_RE = re.compile(r"^\d{1,5}(-\d{1,5})?$")
REV_RE = re.compile(r"^[0-9a-f]{7,40}$")
class Refused(Exception):
"""A validation failure we want to show the user verbatim."""
# --- small helpers -----------------------------------------------------------
def run(*args, timeout=15):
try:
return subprocess.run(
args, capture_output=True, text=True, timeout=timeout
).stdout
except (subprocess.SubprocessError, OSError):
return ""
def token():
try:
with open(TOKEN_FILE) as fh:
return fh.read().strip()
except OSError as exc:
raise Refused(
f"no Forgejo token at {TOKEN_FILE} — create one with write:repository "
f"on {REPO} and write it there (see services/router-ui.nix)"
) from exc
def forgejo(method, path, payload=None):
req = urllib.request.Request(
f"{FORGEJO}/{path}",
method=method,
data=json.dumps(payload).encode() if payload is not None else None,
headers={
"Authorization": f"token {token()}",
"Content-Type": "application/json",
"Accept": "application/json",
},
)
try:
with urllib.request.urlopen(req, timeout=30) as resp:
return json.loads(resp.read() or b"{}")
except urllib.error.HTTPError as exc:
detail = exc.read().decode(errors="replace")[:400]
raise Refused(f"Forgejo {exc.code}: {detail}") from exc
except (urllib.error.URLError, TimeoutError, socket.timeout) as exc:
raise Refused(f"Forgejo unreachable: {exc}") from exc
def git_file(name):
"""Return (tomlkit document, blob sha) for a file on the tracked branch."""
meta = forgejo("GET", f"repos/{REPO}/contents/{name}?ref={BRANCH}")
text = base64.b64decode(meta["content"]).decode()
return tomlkit.parse(text), meta["sha"]
def git_commit(name, doc, sha, message):
body = {
"content": base64.b64encode(tomlkit.dumps(doc).encode()).decode(),
"sha": sha,
"branch": BRANCH,
"message": message,
}
return forgejo("PUT", f"repos/{REPO}/contents/{name}", body)["commit"]["sha"]
def deployed(name):
"""The copy baked into the running generation, or None if unreadable."""
try:
with open(os.path.join(ETC, name)) as fh:
return tomlkit.parse(fh.read())
except (OSError, ValueError):
return None
def edit_aot(doc, key, items):
"""Apply the UI's list back onto an array-of-tables, in place.
In place, not wholesale replacement: ports.toml carries a lot of
commented-out forwards, and tomlkit only keeps those if the surrounding
tables survive. Each item carries `_i`, its index in the original array,
or None when it's new.
Assignments are skipped when the value is already what we'd write. tomlkit
moves a key to the end of its table when you re-add it, and the last table
in a file owns every trailing comment so a blind rewrite drags keys down
past unrelated comment blocks. Writing only genuine changes keeps an
untouched entry byte-identical.
"""
aot = doc[key]
original = len(aot) # fixed up front: appends must not shift the delete set
keep = set()
added = []
for item in items:
fields = {k: v for k, v in item.items() if not k.startswith("_")}
idx = item.get("_i")
if idx is None:
added.append(fields)
continue
if not isinstance(idx, int) or not 0 <= idx < original:
raise Refused(f"stale index {idx} — reload the page and retry")
keep.add(idx)
table = aot[idx]
for stale in [k for k in table.keys() if k not in fields]:
del table[stale]
for k, v in fields.items():
if k not in table or table[k] != v:
table[k] = v
for idx in sorted(set(range(original)) - keep, reverse=True):
aot.pop(idx)
for fields in added:
table = tomlkit.table()
for k, v in fields.items():
table[k] = v
aot.append(table)
# --- LAN state ---------------------------------------------------------------
_oui_cache = {}
def vendor(mac):
if not OUI_FILE:
return ""
if not _oui_cache:
try:
with open(OUI_FILE, errors="replace") as fh:
for line in fh:
if line.startswith("#") or " " not in line:
continue
prefix, _, name = line.strip().partition(" ")
if len(prefix) == 6:
_oui_cache[prefix.upper()] = name
except OSError:
_oui_cache["_"] = ""
return _oui_cache.get(mac.replace(":", "")[:6].upper(), "")
def leases():
out = {}
try:
with open(LEASES) as fh:
for line in fh:
parts = line.split()
if len(parts) >= 4:
expiry, mac, ip, name = parts[0], parts[1].lower(), parts[2], parts[3]
out[mac] = {"ip": ip, "lease_name": "" if name == "*" else name,
"expires": int(expiry)}
except OSError:
pass
return out
def neighbours():
"""MACs the kernel has recently exchanged packets with, i.e. online-ish."""
online = {}
for line in run(IP, "neigh", "show", "dev", LAN_IF).splitlines():
parts = line.split()
if len(parts) >= 5 and parts[1] == "lladdr":
state = parts[-1]
online[parts[2].lower()] = state not in ("FAILED", "INCOMPLETE")
return online
def caller_ip(handler):
fwd = handler.headers.get("X-Forwarded-For", "")
return fwd.split(",")[0].strip() or handler.client_address[0]
def mac_of(ip):
for mac, lease in leases().items():
if lease["ip"] == ip:
return mac
for line in run(IP, "neigh", "show", "dev", LAN_IF).splitlines():
parts = line.split()
if len(parts) >= 5 and parts[0] == ip and parts[1] == "lladdr":
return parts[2].lower()
return ""
# --- validation --------------------------------------------------------------
def check_devices(items, self_mac, self_ip):
seen_mac, seen_ip = set(), set()
for d in items:
mac = str(d.get("mac", "")).strip().lower()
name = str(d.get("name", "")).strip()
if not MAC_RE.match(mac):
raise Refused(f"'{mac}' is not a MAC address (aa:bb:cc:dd:ee:ff)")
if not re.match(r"^[A-Za-z0-9_-]{1,63}$", name):
raise Refused(f"'{name}' is not a usable DHCP name (letters, digits, - and _)")
if mac in seen_mac:
raise Refused(f"{mac} listed twice")
seen_mac.add(mac)
d["mac"], d["name"] = mac, name
if d.get("blocked") and (mac == self_mac or d.get("ip") == self_ip):
raise Refused("that's the device you're connected from — refusing to block it")
# Drop defaults rather than writing them out: router.nix reads both
# with `or` fallbacks, and absent keys keep the file (and its diffs)
# small.
if d.get("blocked"):
d["blocked"] = True
else:
d.pop("blocked", None)
if not str(d.get("note", "")).strip():
d.pop("note", None)
else:
d["note"] = str(d["note"]).strip()
ip = str(d.get("ip", "")).strip()
if not ip:
d.pop("ip", None)
continue
if not ip.startswith(LAN_PREFIX):
raise Refused(f"{ip} is outside the LAN ({LAN_PREFIX}0/24)")
host = ip[len(LAN_PREFIX):]
if not host.isdigit() or not 1 <= int(host) <= 254:
raise Refused(f"{ip} is not a valid LAN address")
if ip == ROUTER_IP:
raise Refused(f"{ip} is the router itself")
if POOL[0] <= int(host) <= POOL[1]:
raise Refused(
f"{ip} sits inside the DHCP pool {LAN_PREFIX}{POOL[0]}-{POOL[1]}"
f"reservations must live outside it"
)
if ip in seen_ip:
raise Refused(f"{ip} reserved twice")
seen_ip.add(ip)
d["ip"] = ip
def check_forwards(items):
covered = set()
for f in items:
name = str(f.get("name", "")).strip()
proto = str(f.get("protocol", "")).strip()
if not name:
raise Refused("every forward needs a name")
if proto not in ("tcp", "udp", "both"):
raise Refused(f"'{proto}' is not tcp, udp or both")
# Accept either key so re-validating an already-normalised entry works.
spec = str(f.get("port") or f.get("ports") or "").strip()
if not PORT_RE.match(spec):
raise Refused(f"'{spec}' is not a port or range (443, or 26901-26902)")
bounds = [int(p) for p in spec.split("-")]
if any(not 1 <= p <= 65535 for p in bounds):
raise Refused(f"{spec} is out of range")
if len(bounds) == 2 and bounds[0] >= bounds[1]:
raise Refused(f"{spec} is backwards")
# Normalise onto the two keys router.nix understands.
f.pop("port", None)
f.pop("ports", None)
f["name"] = name
f["protocol"] = proto
if len(bounds) == 2:
f["ports"] = spec
else:
f["port"] = bounds[0]
dest = str(f.get("dest", "")).strip()
if dest:
f["dest"] = dest
else:
f.pop("dest", None)
for p in range(bounds[0], bounds[-1] + 1):
for pr in (("tcp", "udp") if proto == "both" else (proto,)):
covered.add((pr, str(p)))
for need in REQUIRED_FORWARDS:
if need not in covered:
raise Refused(
f"refusing to drop the {need[0]}/{need[1]} forward — "
f"that's how you reach this box from outside"
)
# --- API payloads ------------------------------------------------------------
def forwards_from(doc):
out = []
for i, f in enumerate(doc.get("forward", [])):
out.append({
"_i": i,
"name": str(f.get("name", "")),
"port": str(f["ports"]) if "ports" in f else str(f.get("port", "")),
"protocol": str(f.get("protocol", "tcp")),
"dest": str(f.get("dest", "")),
})
return out
def devices_from(doc):
out = []
for i, d in enumerate(doc.get("device", [])):
out.append({
"_i": i,
"mac": str(d.get("mac", "")).lower(),
"name": str(d.get("name", "")),
"ip": str(d.get("ip", "")),
"blocked": bool(d.get("blocked", False)),
"note": str(d.get("note", "")),
})
return out
def device_view():
"""Registry from git, joined with what's actually on the wire."""
doc, sha = git_file("devices.toml")
known = devices_from(doc)
lease = leases()
online = neighbours()
by_mac = {d["mac"]: d for d in known}
for d in known:
seen = lease.get(d["mac"], {})
d["lease_ip"] = seen.get("ip", "")
d["hostname"] = seen.get("lease_name", "")
d["online"] = online.get(d["mac"], False)
d["vendor"] = vendor(d["mac"])
d["known"] = True
unknown = []
for mac, seen in sorted(lease.items(), key=lambda kv: kv[1]["ip"]):
if mac in by_mac:
continue
unknown.append({
"_i": None, "mac": mac, "name": seen["lease_name"] or mac.replace(":", ""),
"ip": "", "blocked": False, "note": "",
"lease_ip": seen["ip"], "hostname": seen["lease_name"],
"online": online.get(mac, False), "vendor": vendor(mac), "known": False,
})
return {"sha": sha, "devices": known + unknown}
def wan_stats():
rx = tx = 0
try:
with open("/proc/net/dev") as fh:
for line in fh:
iface, _, rest = line.partition(":")
if iface.strip() == WAN_IF:
cols = rest.split()
rx, tx = int(cols[0]), int(cols[8])
except (OSError, ValueError, IndexError):
pass
return {"rx": rx, "tx": tx, "t": time.time()}
def overview():
addr = run(IP, "-4", "-o", "addr", "show", WAN_IF).split()
wan_ip = addr[3].split("/")[0] if len(addr) > 3 else "down"
try:
with open("/proc/uptime") as fh:
up = int(float(fh.read().split()[0]))
except (OSError, ValueError):
up = 0
try:
with open(LAST_REV) as fh:
rev = fh.read().strip()
except OSError:
rev = ""
drift = {}
for name in ("ports.toml", "devices.toml"):
local = deployed(name)
try:
remote, _ = git_file(name)
drift[name] = local is not None and tomlkit.dumps(local) != tomlkit.dumps(remote)
except Refused:
drift[name] = None
return {
"wan_ip": wan_ip,
"uptime": up,
"leases": len(leases()),
"last_rev": rev,
"drift": drift,
"wan_if": WAN_IF,
}
def pending():
"""Commits on the branch that this UI has not deployed."""
try:
with open(LAST_REV) as fh:
last = fh.read().strip()
except OSError:
last = ""
commits = forgejo("GET", f"repos/{REPO}/commits?sha={BRANCH}&limit=20&stat=false")
out = []
for c in commits:
if c["sha"] == last:
break
out.append({
"sha": c["sha"],
"message": c["commit"]["message"].splitlines()[0],
"date": c["commit"]["committer"]["date"],
})
head = commits[0]["sha"] if commits else ""
return {"head": head, "last_rev": last, "unapplied": out}
def speedtests(limit=30):
out = []
try:
with open(SPEEDTEST_LOG) as fh:
for line in fh:
try:
r = json.loads(line)
except ValueError:
continue
srv = (r.get("servers") or [{}])[0]
out.append({
"ts": r.get("timestamp", ""),
"down": round(srv.get("dl_speed", 0) * 8 / 1e6, 1),
"up": round(srv.get("ul_speed", 0) * 8 / 1e6, 1),
"ping": round(srv.get("latency", 0) / 1e6, 1),
"server": srv.get("name", ""),
})
except OSError:
pass
return out[-limit:]
def traffic():
raw = run(VNSTAT, "--json", "-i", WAN_IF, timeout=20)
try:
iface = json.loads(raw)["interfaces"][0]["traffic"]
except (ValueError, KeyError, IndexError):
return {"day": [], "month": [], "hour": []}
def series(key):
return [
{
"label": "{:04d}-{:02d}-{:02d}".format(
e["date"]["year"], e["date"].get("month", 1), e["date"].get("day", 1)
) + (" {:02d}:00".format(e["time"]["hour"]) if "time" in e else ""),
"rx": e["rx"],
"tx": e["tx"],
}
for e in iface.get(key, [])
]
return {"hour": series("hour")[-24:], "day": series("day")[-30:],
"month": series("month")[-12:]}
# --- actions -----------------------------------------------------------------
def start_unit(unit):
subprocess.run(
[SUDO, "-n", SYSTEMCTL, "start", "--no-block", unit],
check=True, capture_output=True, timeout=20,
)
def save(name, key, items, message, sha, validate):
doc, current = git_file(name)
if sha and sha != current:
raise Refused(f"{name} changed in git since you loaded it — reload and redo")
validate(items)
edit_aot(doc, key, items)
rev = git_commit(name, doc, current, message)
return {"ok": True, "rev": rev}
def apply(rev, confirmed):
if not REV_RE.match(rev or ""):
raise Refused("not a commit id")
state = pending()
extra = [c for c in state["unapplied"] if c["sha"] != rev]
if extra and not confirmed:
raise Refused(
"CONFIRM:main carries commits this page didn't make; applying deploys "
"those too:\n" + "\n".join(f" {c['sha'][:8]} {c['message']}" for c in extra)
)
start_unit(f"router-apply@{rev}.service")
return {"ok": True, "rev": rev}
# --- HTTP --------------------------------------------------------------------
class Handler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def log_message(self, fmt, *args): # journal already timestamps
print(fmt % args, flush=True)
def _send(self, code, body, ctype="application/json", extra=None):
blob = body if isinstance(body, bytes) else body.encode()
self.send_response(code)
self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(blob)))
for k, v in (extra or {}).items():
self.send_header(k, v)
self.end_headers()
self.wfile.write(blob)
def _json(self, obj, code=200):
self._send(code, json.dumps(obj))
def _body(self):
length = int(self.headers.get("Content-Length", "0"))
return json.loads(self.rfile.read(length) or b"{}")
def do_GET(self):
path, _, query = self.path.partition("?")
args = dict(p.split("=", 1) for p in query.split("&") if "=" in p)
try:
if path == "/":
return self._send(200, PAGE, "text/html; charset=utf-8")
if path == "/api/overview":
return self._json(overview())
if path == "/api/pending":
return self._json(pending())
if path == "/api/devices":
return self._json(device_view())
if path == "/api/ports":
doc, sha = git_file("ports.toml")
return self._json({"sha": sha, "forwards": forwards_from(doc)})
if path == "/api/live":
return self._json(wan_stats())
if path == "/api/traffic":
return self._json(traffic())
if path == "/api/speedtest":
return self._json(speedtests())
if path == "/api/apply/log":
return self._stream_log(args.get("rev", ""))
except Refused as exc:
return self._json({"error": str(exc)}, 400)
except Exception as exc: # noqa: BLE001 - surface, don't 500 silently
return self._json({"error": f"{type(exc).__name__}: {exc}"}, 500)
self._json({"error": "not found"}, 404)
def do_POST(self):
try:
body = self._body()
if self.path == "/api/devices":
self_ip = caller_ip(self)
return self._json(save(
"devices.toml", "device", body.get("devices", []),
"devices.toml: update from router UI", body.get("sha"),
lambda items: check_devices(items, mac_of(self_ip), self_ip),
))
if self.path == "/api/ports":
return self._json(save(
"ports.toml", "forward", body.get("forwards", []),
"ports.toml: update from router UI", body.get("sha"),
check_forwards,
))
if self.path == "/api/apply":
return self._json(apply(body.get("rev", ""), body.get("confirm", False)))
if self.path == "/api/speedtest/run":
start_unit("router-speedtest.service")
return self._json({"ok": True})
except Refused as exc:
return self._json({"error": str(exc)}, 400)
except Exception as exc: # noqa: BLE001
return self._json({"error": f"{type(exc).__name__}: {exc}"}, 500)
self._json({"error": "not found"}, 404)
def _stream_log(self, rev):
if not REV_RE.match(rev or ""):
return self._json({"error": "not a commit id"}, 400)
self.send_response(200)
self.send_header("Content-Type", "text/event-stream")
self.send_header("Cache-Control", "no-cache")
self.send_header("X-Accel-Buffering", "no") # nginx must not buffer SSE
self.send_header("Connection", "close")
self.end_headers()
proc = subprocess.Popen(
[JOURNALCTL, "-u", f"router-apply@{rev}.service",
"-f", "-n", "200", "-o", "cat", "--since", "-30min"],
stdout=subprocess.PIPE, text=True,
)
try:
for line in proc.stdout:
self.wfile.write(f"data: {line.rstrip()}\n\n".encode())
self.wfile.flush()
except (BrokenPipeError, ConnectionResetError, OSError):
pass
finally:
proc.terminate()
PAGE = ("""<!doctype html>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Router</title>
<style>
:root{--bg:#12141a;--card:#1b1e27;--line:#2b3040;--fg:#e7e9f0;--dim:#9aa1b5;
--ok:#4ade80;--bad:#f87171;--warn:#fbbf24;--accent:#60a5fa}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--fg);font:14px/1.5 system-ui,sans-serif}
header{display:flex;gap:4px;padding:12px 16px;border-bottom:1px solid var(--line);
flex-wrap:wrap;align-items:center;position:sticky;top:0;background:var(--bg);z-index:5}
h1{font-size:15px;margin:0 16px 0 0;font-weight:600}
button{background:var(--card);color:var(--fg);border:1px solid var(--line);
border-radius:6px;padding:6px 12px;cursor:pointer;font:inherit}
button:hover{border-color:var(--accent)}
button.on{background:var(--accent);color:#06101f;border-color:var(--accent)}
button.go{background:var(--ok);color:#06210f;border-color:var(--ok);font-weight:600}
button.danger{border-color:var(--bad);color:var(--bad)}
main{padding:16px;max-width:1100px}
section{display:none}section.on{display:block}
.cards{display:flex;gap:12px;flex-wrap:wrap;margin-bottom:16px}
.card{background:var(--card);border:1px solid var(--line);border-radius:10px;
padding:12px 16px;min-width:150px}
.card .k{color:var(--dim);font-size:12px}.card .v{font-size:22px;font-weight:600}
table{border-collapse:collapse;width:100%;background:var(--card);
border:1px solid var(--line);border-radius:10px;overflow:hidden}
th,td{padding:7px 10px;text-align:left;border-bottom:1px solid var(--line);font-size:13px}
th{color:var(--dim);font-weight:500}tr:last-child td{border-bottom:none}
input[type=text]{background:#0e1016;color:var(--fg);border:1px solid var(--line);
border-radius:5px;padding:4px 7px;font:inherit;width:100%;min-width:70px}
select{background:#0e1016;color:var(--fg);border:1px solid var(--line);
border-radius:5px;padding:4px;font:inherit}
.dot{display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--dim)}
.dot.up{background:var(--ok)}
.msg{padding:10px 12px;border-radius:8px;margin:12px 0;white-space:pre-wrap;
border:1px solid var(--line);background:var(--card);display:none;font-size:13px}
.msg.bad{border-color:var(--bad);color:var(--bad)}
.msg.ok{border-color:var(--ok);color:var(--ok)}
.bar{display:flex;align-items:flex-end;gap:2px;height:70px;margin-top:8px}
.bar i{flex:1;background:var(--accent);border-radius:2px 2px 0 0;min-height:1px}
.bar i.up{background:var(--warn)}
pre{background:#0e1016;border:1px solid var(--line);border-radius:8px;padding:12px;
max-height:340px;overflow:auto;font-size:12px;white-space:pre-wrap}
.dim{color:var(--dim)}.right{text-align:right}
.row{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin:12px 0}
</style>
<header>
<h1>Router</h1>
<button data-tab="overview" class="on">Overview</button>
<button data-tab="devices">Devices</button>
<button data-tab="ports">Ports</button>
<button data-tab="traffic">Traffic</button>
<button data-tab="speed">Speedtest</button>
</header>
<main>
<div id="msg" class="msg"></div>
<section id="overview" class="on">
<div class="cards" id="ovcards"></div>
<div class="row">
<button class="go" id="applybtn">Apply pending changes</button>
<span class="dim" id="pendinfo"></span>
</div>
<pre id="applylog" style="display:none"></pre>
</section>
<section id="devices">
<div class="row"><button class="go" id="devsave">Save to git</button>
<span class="dim">Saving commits devices.toml. Nothing reaches the router until you Apply.</span></div>
<table><thead><tr><th></th><th>Name</th><th>MAC</th><th>Vendor</th><th>Lease</th>
<th>Reserved IP</th><th>Block</th><th>Note</th><th></th></tr></thead>
<tbody id="devrows"></tbody></table>
</section>
<section id="ports">
<div class="row"><button class="go" id="portsave">Save to git</button>
<button id="portadd">Add forward</button></div>
<table><thead><tr><th>Name</th><th>Port / range</th><th>Protocol</th>
<th>Destination</th><th></th></tr></thead>
<tbody id="portrows"></tbody></table>
</section>
<section id="traffic">
<div class="cards" id="livecards"></div>
<h3>Last 24 hours</h3><div class="bar" id="th"></div>
<h3>Last 30 days</h3><div class="bar" id="td"></div>
<h3>Monthly</h3><div class="bar" id="tm"></div>
<p class="dim">Blue = download, amber = upload.</p>
</section>
<section id="speed">
<div class="row"><button class="go" id="strun">Run speedtest now</button>
<span class="dim">Takes ~30s, then refresh.</span></div>
<div class="cards" id="stcards"></div>
<table><thead><tr><th>When</th><th class="right">Down</th><th class="right">Up</th>
<th class="right">Ping</th><th>Server</th></tr></thead>
<tbody id="strows"></tbody></table>
</section>
</main>
<script>
const $ = s => document.querySelector(s);
const el = (t, p = {}) => Object.assign(document.createElement(t), p);
let devSha = '', portSha = '', devs = [], ports = [], head = '';
function say(text, bad) {
const m = $('#msg'); m.textContent = text;
m.className = 'msg ' + (bad ? 'bad' : 'ok'); m.style.display = text ? 'block' : 'none';
}
async function api(path, opts) {
const r = await fetch(path, opts);
const j = await r.json().catch(() => ({ error: r.statusText }));
if (!r.ok) throw new Error(j.error || r.statusText);
return j;
}
const post = (p, b) => api(p, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(b)
});
document.querySelectorAll('header button').forEach(b => b.onclick = () => {
document.querySelectorAll('header button').forEach(x => x.classList.remove('on'));
document.querySelectorAll('section').forEach(x => x.classList.remove('on'));
b.classList.add('on'); $('#' + b.dataset.tab).classList.add('on'); say(''); load(b.dataset.tab);
});
function dur(s) {
const d = Math.floor(s / 86400), h = Math.floor(s % 86400 / 3600);
return d ? d + 'd ' + h + 'h' : h + 'h ' + Math.floor(s % 3600 / 60) + 'm';
}
const gib = b => (b / 1024 / 1024 / 1024).toFixed(1) + ' GiB';
async function load(tab) {
try {
if (tab === 'overview') {
const o = await api('/api/overview'), p = await api('/api/pending');
head = p.head;
const drift = Object.entries(o.drift).filter(([, v]) => v).map(([k]) => k);
$('#ovcards').innerHTML = '';
[['WAN address', o.wan_ip], ['Uptime', dur(o.uptime)],
['DHCP leases', o.leases], ['Deployed', o.last_rev ? o.last_rev.slice(0, 8) : 'unknown'],
['Config drift', drift.length ? drift.join(', ') : 'none']
].forEach(([k, v]) => {
const c = el('div', { className: 'card' });
c.append(el('div', { className: 'k', textContent: k }),
el('div', { className: 'v', textContent: v }));
$('#ovcards').append(c);
});
$('#pendinfo').textContent = p.unapplied.length
? p.unapplied.length + ' commit(s) not yet deployed: '
+ p.unapplied.map(c => c.sha.slice(0, 8) + ' ' + c.message).join(' · ')
: 'Nothing to deploy — git matches the last apply.';
}
if (tab === 'devices') {
const d = await api('/api/devices'); devSha = d.sha; devs = d.devices; drawDevs();
}
if (tab === 'ports') {
const p = await api('/api/ports'); portSha = p.sha; ports = p.forwards; drawPorts();
}
if (tab === 'traffic') { live(); drawTraffic(await api('/api/traffic')); }
if (tab === 'speed') drawSpeed(await api('/api/speedtest'));
} catch (e) { say(e.message, true); }
}
function bind(obj, key) {
const i = el('input', { type: 'text', value: obj[key] ?? '' });
i.oninput = () => obj[key] = i.value.trim();
return i;
}
function drawDevs() {
const body = $('#devrows'); body.innerHTML = '';
devs.forEach((d, n) => {
const tr = el('tr');
const dot = el('span', { className: 'dot' + (d.online ? ' up' : '') });
const blocked = el('input', { type: 'checkbox', checked: d.blocked });
blocked.onchange = () => d.blocked = blocked.checked;
const cells = [dot, bind(d, 'name'), d.mac, d.vendor || '',
d.lease_ip || '', bind(d, 'ip'), blocked, bind(d, 'note')];
cells.forEach(c => { const td = el('td'); td.append(c.nodeType ? c : document.createTextNode(c)); tr.append(td); });
const td = el('td');
if (d.known) {
const b = el('button', { className: 'danger', textContent: 'Forget' });
b.onclick = () => { devs.splice(n, 1); drawDevs(); };
td.append(b);
} else td.append(el('span', { className: 'dim', textContent: 'unregistered' }));
tr.append(td); body.append(tr);
});
}
function drawPorts() {
const body = $('#portrows'); body.innerHTML = '';
ports.forEach((f, n) => {
const tr = el('tr');
const proto = el('select');
['tcp', 'udp', 'both'].forEach(p => proto.append(el('option', { value: p, textContent: p, selected: f.protocol === p })));
proto.onchange = () => f.protocol = proto.value;
[bind(f, 'name'), bind(f, 'port'), proto, bind(f, 'dest')].forEach(c => {
const td = el('td'); td.append(c); tr.append(td);
});
const td = el('td'), b = el('button', { className: 'danger', textContent: 'Remove' });
b.onclick = () => { ports.splice(n, 1); drawPorts(); };
td.append(b); tr.append(td); body.append(tr);
});
}
function bars(node, rows) {
node.innerHTML = '';
const max = Math.max(1, ...rows.map(r => Math.max(r.rx, r.tx)));
rows.forEach(r => {
[['rx', ''], ['tx', ' up']].forEach(([k, cls]) => {
node.append(el('i', {
className: cls.trim(), title: r.label + ' ' + k + ' ' + gib(r[k]),
style: 'height:' + Math.max(1, r[k] / max * 100) + '%'
}));
});
});
}
function drawTraffic(t) { bars($('#th'), t.hour); bars($('#td'), t.day); bars($('#tm'), t.month); }
let prev = null, timer = null;
async function live() {
clearInterval(timer);
const tick = async () => {
if (!$('#traffic').classList.contains('on')) return;
const s = await api('/api/live');
if (prev) {
const dt = Math.max(0.5, s.t - prev.t);
const mbps = b => ((b) * 8 / dt / 1e6).toFixed(1) + ' Mbps';
$('#livecards').innerHTML = '';
[['Down now', mbps(s.rx - prev.rx)], ['Up now', mbps(s.tx - prev.tx)]]
.forEach(([k, v]) => {
const c = el('div', { className: 'card' });
c.append(el('div', { className: 'k', textContent: k }),
el('div', { className: 'v', textContent: v }));
$('#livecards').append(c);
});
}
prev = s;
};
await tick(); timer = setInterval(tick, 2000);
}
function drawSpeed(rows) {
const last = rows[rows.length - 1];
$('#stcards').innerHTML = '';
if (last) [['Download', last.down + ' Mbps'], ['Upload', last.up + ' Mbps'],
['Ping', last.ping + ' ms'], ['When', last.ts]].forEach(([k, v]) => {
const c = el('div', { className: 'card' });
c.append(el('div', { className: 'k', textContent: k }),
el('div', { className: 'v', textContent: v }));
$('#stcards').append(c);
});
const body = $('#strows'); body.innerHTML = '';
rows.slice().reverse().forEach(r => {
const tr = el('tr');
[r.ts, r.down, r.up, r.ping, r.server].forEach((v, i) => {
tr.append(el('td', { textContent: v, className: i > 0 && i < 4 ? 'right' : '' }));
});
body.append(tr);
});
}
$('#devsave').onclick = async () => {
try {
const r = await post('/api/devices', { sha: devSha, devices: devs });
head = r.rev; say('Committed ' + r.rev.slice(0, 8) + '. Go to Overview and Apply.');
load('devices');
} catch (e) { say(e.message, true); }
};
$('#portsave').onclick = async () => {
try {
const r = await post('/api/ports', { sha: portSha, forwards: ports });
head = r.rev; say('Committed ' + r.rev.slice(0, 8) + '. Go to Overview and Apply.');
load('ports');
} catch (e) { say(e.message, true); }
};
$('#portadd').onclick = () => {
ports.push({ _i: null, name: '', port: '', protocol: 'tcp', dest: '' }); drawPorts();
};
$('#strun').onclick = async () => {
try { await post('/api/speedtest/run', {}); say('Speedtest started — refresh in ~30s.'); }
catch (e) { say(e.message, true); }
};
$('#applybtn').onclick = async () => {
if (!head) { say('Nothing to apply.', true); return; }
await doApply(false);
};
async function doApply(confirm) {
try {
await post('/api/apply', { rev: head, confirm });
say('Applying ' + head.slice(0, 8) + ' — test, health check, then switch.');
tail(head);
} catch (e) {
if (e.message.startsWith('CONFIRM:')) {
if (window.confirm(e.message.slice(8) + '\\n\\nApply anyway?')) return doApply(true);
return say('Cancelled.', true);
}
say(e.message, true);
}
}
function tail(rev) {
const pre = $('#applylog'); pre.style.display = 'block'; pre.textContent = '';
const src = new EventSource('/api/apply/log?rev=' + rev);
src.onmessage = e => { pre.textContent += e.data + '\\n'; pre.scrollTop = pre.scrollHeight; };
src.onerror = () => src.close();
}
load('overview');
</script>
""").encode()
if __name__ == "__main__":
os.makedirs(STATE, exist_ok=True)
ThreadingHTTPServer(("127.0.0.1", PORT), Handler).serve_forever()

View file

@ -32,6 +32,7 @@
{ domain = "homepage.nordhammer.it"; policy = "one_factor"; } { domain = "homepage.nordhammer.it"; policy = "one_factor"; }
# { domain = "7dtd.nordhammer.it"; policy = "one_factor"; } # 7DTD disabled # { domain = "7dtd.nordhammer.it"; policy = "one_factor"; } # 7DTD disabled
{ domain = "adguard.nordhammer.it"; policy = "one_factor"; } { domain = "adguard.nordhammer.it"; policy = "one_factor"; }
{ domain = "router.nordhammer.it"; policy = "one_factor"; }
{ domain = "sonarr.nordhammer.it"; policy = "one_factor"; } { domain = "sonarr.nordhammer.it"; policy = "one_factor"; }
{ domain = "radarr.nordhammer.it"; policy = "one_factor"; } { domain = "radarr.nordhammer.it"; policy = "one_factor"; }
{ domain = "bazarr.nordhammer.it"; policy = "one_factor"; } { domain = "bazarr.nordhammer.it"; policy = "one_factor"; }

View file

@ -141,6 +141,14 @@ in
"homepage.nordhammer.it" = protectedProxy 8084; "homepage.nordhammer.it" = protectedProxy 8084;
# "7dtd.nordhammer.it" = protectedProxy 8090; # 7DTD disabled # "7dtd.nordhammer.it" = protectedProxy 8090; # 7DTD disabled
"adguard.nordhammer.it" = protectedProxy 3000; "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; "profilarr.nordhammer.it" = protectedProxy 6868;
"shelfarr.nordhammer.it" = protectedProxy 5056; "shelfarr.nordhammer.it" = protectedProxy 5056;
"sabnzbd.nordhammer.it" = protectedProxy 8085; "sabnzbd.nordhammer.it" = protectedProxy 8085;

211
services/router-ui.nix Normal file
View 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" ];
}
];
}];
};
}

View file

@ -10,13 +10,33 @@
# - dnsmasq: DHCP only (port 0 for DNS — AdGuard Home owns :53) # - dnsmasq: DHCP only (port 0 for DNS — AdGuard Home owns :53)
# - AdGuard Home (already running): DNS for LAN clients # - 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, ... }: { config, lib, pkgs, ... }:
let let
portsData = builtins.fromTOML (builtins.readFile ../ports.toml); portsData = builtins.fromTOML (builtins.readFile ../ports.toml);
destDefault = portsData.dest_default; 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 # Phase-1 transition list; empty now that eero is in bridge mode and
# eno1 is strictly the ISP-facing WAN. # eno1 is strictly the ISP-facing WAN.
trustedLegacyCidrs = [ ]; trustedLegacyCidrs = [ ];
@ -116,6 +136,8 @@ in
content = '' content = ''
chain input { chain input {
type filter hook input priority 0; policy drop; type filter hook input priority 0; policy drop;
# Blocked devices first — before the conntrack accept.
${blockRules}
ct state established,related accept ct state established,related accept
ct state invalid drop ct state invalid drop
iifname "lo" accept iifname "lo" accept
@ -136,6 +158,8 @@ in
} }
chain forward { chain forward {
type filter hook forward priority 0; policy drop; type filter hook forward priority 0; policy drop;
# Blocked devices first — before the conntrack accept.
${blockRules}
ct state established,related accept ct state established,related accept
ct state invalid drop ct state invalid drop
# LAN → anywhere # LAN → anywhere
@ -187,10 +211,8 @@ in
"option:router,10.0.0.1" "option:router,10.0.0.1"
"option:dns-server,10.0.0.1" "option:dns-server,10.0.0.1"
]; ];
# Static reservations — format: "MAC,label,IP" # Static reservations — format: "MAC,label,IP". From ../devices.toml.
dhcp-host = [ dhcp-host = map (d: "${d.mac},${d.name},${d.ip}") reservedDevices;
"f0:a7:31:6c:50:4b,camera-bedroom,10.0.0.39"
];
# Helpful: log leases to the journal # Helpful: log leases to the journal
log-dhcp = true; log-dhcp = true;
}; };