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.
+ | Name | MAC | Vendor | Lease |
+ Reserved IP | Block | Note | |
+
+
+
+
+
+
+ | Name | Port / range | Protocol |
+ Destination | |
+
+
+
+
+
+ Last 24 hours
+ Last 30 days
+ Monthly
+ Blue = download, amber = upload.
+
+
+
+
+ Takes ~30s, then refresh.
+
+ | When | Down | Up |
+ Ping | Server |
+
+
+
+
+""").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;
};