"""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 # --- accounting: deltas, and counter resets on ruleset reload ---------------- state = tempfile.mkdtemp() r.TRAFFIC_FILE = os.path.join(state, "traffic.json") r.LEASES = os.path.join(state, "leases") open(r.LEASES, "w").write("999 aa:bb:cc:dd:ee:01 10.0.0.50 phone *\n") samples = [] r.nft_counters = lambda: samples[-1] # noqa: E731 - stand in for the live sets def tick(up, down): samples.append({"10.0.0.50": {"up": up, "down": down}}) return r.accounting_tick()["totals"]["aa:bb:cc:dd:ee:01"] t = tick(100, 200) assert (t["up"], t["down"]) == (100, 200), t t = tick(150, 700) # normal growth -> delta added assert (t["up"], t["down"]) == (150, 700), t t = tick(10, 5) # counters reset -> whole sample counts assert (t["up"], t["down"]) == (160, 705), t t = tick(10, 5) # unchanged -> nothing added assert (t["up"], t["down"]) == (160, 705), t assert t["ip"] == "10.0.0.50" seen = t["last_seen"] t = tick(10, 5) # idle tick must not refresh last_seen assert t["last_seen"] == seen, (t["last_seen"], seen) # an address with no lease is keyed by IP so it still shows up samples.append({"10.0.0.77": {"up": 5, "down": 5}}) assert "10.0.0.77" in r.accounting_tick()["totals"] # --- 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)