From 2f1e14495b002ec00ffe8bdb59319e8ca6120477 Mon Sep 17 00:00:00 2001 From: rope Date: Sat, 15 Aug 2026 12:15:54 +0100 Subject: [PATCH] router-ui: web management page for ports, devices, traffic, speedtest Co-Authored-By: Claude Opus 5 --- .gitignore | 4 + common.nix | 1 + devices.toml | 17 + ports.toml | 58 +-- scripts/router-ui-check.py | 143 ++++++ scripts/router-ui.py | 962 +++++++++++++++++++++++++++++++++++++ services/authelia.nix | 1 + services/nginx.nix | 8 + services/router-ui.nix | 211 ++++++++ services/router.nix | 32 +- 10 files changed, 1396 insertions(+), 41 deletions(-) create mode 100644 .gitignore create mode 100644 devices.toml create mode 100644 scripts/router-ui-check.py create mode 100644 scripts/router-ui.py create mode 100644 services/router-ui.nix diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7b5bb2c --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +__pycache__/ +result +result-* +*.qcow2 diff --git a/common.nix b/common.nix index 541bead..57e74b9 100644 --- a/common.nix +++ b/common.nix @@ -104,6 +104,7 @@ in ./services/shelfarr.nix ./services/adguard.nix ./services/router.nix + ./services/router-ui.nix ./services/crowdsec.nix ./services/service-health.nix ./services/sabnzbd.nix diff --git a/devices.toml b/devices.toml new file mode 100644 index 0000000..af52d3a --- /dev/null +++ b/devices.toml @@ -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" diff --git a/ports.toml b/ports.toml index 5e8c54a..49ea128 100644 --- a/ports.toml +++ b/ports.toml @@ -7,9 +7,31 @@ # ports — port range as a string, e.g. "26901-26902" # protocol — "tcp", "udp", or "both" # 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" +# --- 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]] name = "HTTP" port = 80 @@ -32,39 +54,3 @@ protocol = "tcp" name = "Pelican game servers" ports = "25565-25600" 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" diff --git a/scripts/router-ui-check.py b/scripts/router-ui-check.py new file mode 100644 index 0000000..4f2f4ce --- /dev/null +++ b/scripts/router-ui-check.py @@ -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) diff --git a/scripts/router-ui.py b/scripts/router-ui.py new file mode 100644 index 0000000..69b4cc4 --- /dev/null +++ b/scripts/router-ui.py @@ -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 = (""" + +Router + +
+

Router

+ + + + + +
+
+
+ +
+
+
+ + +
+ +
+ +
+
+ Saving commits devices.toml. Nothing reaches the router until you Apply.
+ + +
NameMACVendorLeaseReserved IPBlockNote
+
+ +
+
+
+ + +
NamePort / rangeProtocolDestination
+
+ +
+
+

Last 24 hours

+

Last 30 days

+

Monthly

+

Blue = download, amber = upload.

+
+ +
+
+ Takes ~30s, then refresh.
+
+ + +
WhenDownUpPingServer
+
+
+ +""").encode() + + +if __name__ == "__main__": + os.makedirs(STATE, exist_ok=True) + ThreadingHTTPServer(("127.0.0.1", PORT), Handler).serve_forever() diff --git a/services/authelia.nix b/services/authelia.nix index d3c6cd9..dea4204 100644 --- a/services/authelia.nix +++ b/services/authelia.nix @@ -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"; } diff --git a/services/nginx.nix b/services/nginx.nix index 9bd78c7..61bc4b9 100644 --- a/services/nginx.nix +++ b/services/nginx.nix @@ -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; diff --git a/services/router-ui.nix b/services/router-ui.nix new file mode 100644 index 0000000..dcea633 --- /dev/null +++ b/services/router-ui.nix @@ -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@ 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"; + 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" ]; + } + ]; + }]; + + }; +} diff --git a/services/router.nix b/services/router.nix index 63df5ea..fd368d0 100644 --- a/services/router.nix +++ b/services/router.nix @@ -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 ` 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; };