#!/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 sys 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") TRAFFIC_FILE = os.path.join(STATE, "traffic.json") # 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") NFT = os.environ.get("NFT_BIN", "nft") # 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(): """{mac: ARP state}. REACHABLE/DELAY/PROBE mean traffic in the last few seconds; STALE only means we spoke to it at some point since boot, which is why it doesn't count as online on its own.""" states = {} for line in run(IP, "neigh", "show", "dev", LAN_IF).splitlines(): parts = line.split() if len(parts) >= 5 and parts[1] == "lladdr": states[parts[2].lower()] = parts[-1] return states LIVE_STATES = ("REACHABLE", "DELAY", "PROBE") def nft_counters(): """{lan_ip: {"up": bytes, "down": bytes}} from the accounting table.""" out = {} for direction in ("up", "down"): try: blob = json.loads(run(NFT, "-j", "list", "set", "ip", "accounting", direction)) except ValueError: continue for node in blob.get("nftables", []): if not isinstance(node, dict): continue for elem in node.get("set", {}).get("elem", []): e = elem.get("elem", elem) if isinstance(elem, dict) else None if not isinstance(e, dict): continue ip, counter = e.get("val"), e.get("counter") or {} if isinstance(ip, str) and "bytes" in counter: out.setdefault(ip, {"up": 0, "down": 0})[direction] = counter["bytes"] return out def load_traffic(): try: with open(TRAFFIC_FILE) as fh: data = json.load(fh) except (OSError, ValueError): data = {} data.setdefault("totals", {}) data.setdefault("snapshot", {}) data.setdefault("since", int(time.time())) return data def accounting_tick(): """Fold the live nft counters into per-device totals that survive. Run as root from router-accounting.service. The nft counters restart at zero every time the ruleset reloads, so a sample below the previous one is read as a reset and counted in full rather than as a negative delta. Totals are keyed by MAC where a DHCP lease gives us one, so a device keeps its history across an address change. """ data = load_traffic() by_ip = {lease["ip"]: mac for mac, lease in leases().items()} now = int(time.time()) current = nft_counters() for ip, counts in current.items(): key = by_ip.get(ip, ip) was = data["snapshot"].get(ip, {}) entry = data["totals"].setdefault(key, {"up": 0, "down": 0, "last_seen": 0}) moved = False for direction in ("up", "down"): sample, previous = counts.get(direction, 0), was.get(direction, 0) delta = sample - previous if sample >= previous else sample if delta > 0: entry[direction] += delta moved = True if moved: entry["last_seen"] = now entry["ip"] = ip data["snapshot"] = current tmp = TRAFFIC_FILE + ".tmp" with open(tmp, "w") as fh: json.dump(data, fh) os.replace(tmp, TRAFFIC_FILE) os.chmod(TRAFFIC_FILE, 0o644) # the web app runs as router-ui and only reads return data 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() states = neighbours() totals = load_traffic()["totals"] now = int(time.time()) def enrich(d, seen): d["lease_ip"] = seen.get("ip", "") d["hostname"] = seen.get("lease_name", "") d["vendor"] = vendor(d["mac"]) # Counters are keyed by MAC once a lease exists, by raw IP before that. stats = totals.get(d["mac"]) or totals.get(d["lease_ip"]) or {} d["up"] = stats.get("up", 0) d["down"] = stats.get("down", 0) d["last_seen"] = stats.get("last_seen", 0) # ARP says "right now"; the accounting tick only resolves to a minute, # so treat a recent tick as live too for devices that idle quietly. d["online"] = bool(states.get(d["mac"]) in LIVE_STATES or (d["last_seen"] and now - d["last_seen"] < 180)) return d by_mac = {d["mac"]: d for d in known} for d in known: enrich(d, lease.get(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(enrich({ "_i": None, "mac": mac, "name": seen["lease_name"] or mac.replace(":", ""), "ip": "", "blocked": False, "note": "", "known": False, }, seen)) return {"sha": sha, "devices": known + unknown, "since": load_traffic().get("since", 0)} 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 device_traffic(): """Per-device totals, newest names first. Reads the deployed devices.toml rather than git so the traffic tab needs no Forgejo round-trip.""" data = load_traffic() names = {} doc = deployed("devices.toml") if doc: for d in doc.get("device", []): names[str(d.get("mac", "")).lower()] = str(d.get("name", "")) lease = leases() rows = [ { "key": key, "name": names.get(key) or lease.get(key, {}).get("lease_name") or "", "ip": t.get("ip", ""), "up": t.get("up", 0), "down": t.get("down", 0), "last_seen": t.get("last_seen", 0), } for key, t in data["totals"].items() ] rows.sort(key=lambda r: r["up"] + r["down"], reverse=True) return {"since": data.get("since", 0), "devices": rows} def traffic(): per_device = device_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": [], **per_device} 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:], **per_device} # --- 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.
NameLast seenMACVendor LeaseDownUp Reserved IPBlockNote

Rows marked unregistered are live DHCP leases that aren't in devices.toml. They're only written to git once you give one a reserved IP, a note, or tick Block — otherwise Save leaves them alone.

NamePort / rangeProtocol Destination

Last 24 hours

Last 30 days

Monthly

Blue = download, amber = upload. Hover a bar for exact figures.

Per device

DeviceAddressDown UpTotalLast seen

Takes ~30s, then refresh.
WhenDownUp PingServer
""").encode() if __name__ == "__main__": os.makedirs(STATE, exist_ok=True) if len(sys.argv) > 1 and sys.argv[1] == "tick": # router-accounting.service, as root: reading nft sets needs NET_ADMIN. accounting_tick() else: ThreadingHTTPServer(("127.0.0.1", PORT), Handler).serve_forever()