#!/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.
Name
Last seen
MAC
Vendor
Lease
Down
Up
Reserved IP
Block
Note
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.
Name
Port / range
Protocol
Destination
Last 24 hours
Last 30 days
Monthly
Blue = download, amber = upload. Hover a bar for exact figures.
Per device
Device
Address
Down
Up
Total
Last seen
Takes ~30s, then refresh.
When
Down
Up
Ping
Server
""").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()