2026-08-15 12:15:54 +01:00
|
|
|
"""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
|
|
|
|
|
|
2026-08-15 12:42:50 +01:00
|
|
|
# --- 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"]
|
|
|
|
|
|
2026-08-15 12:15:54 +01:00
|
|
|
# --- 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)
|