nixos/scripts/router-ui.py

963 lines
36 KiB
Python
Raw Normal View History

#!/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 = ("""<!doctype html>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Router</title>
<style>
:root{--bg:#12141a;--card:#1b1e27;--line:#2b3040;--fg:#e7e9f0;--dim:#9aa1b5;
--ok:#4ade80;--bad:#f87171;--warn:#fbbf24;--accent:#60a5fa}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--fg);font:14px/1.5 system-ui,sans-serif}
header{display:flex;gap:4px;padding:12px 16px;border-bottom:1px solid var(--line);
flex-wrap:wrap;align-items:center;position:sticky;top:0;background:var(--bg);z-index:5}
h1{font-size:15px;margin:0 16px 0 0;font-weight:600}
button{background:var(--card);color:var(--fg);border:1px solid var(--line);
border-radius:6px;padding:6px 12px;cursor:pointer;font:inherit}
button:hover{border-color:var(--accent)}
button.on{background:var(--accent);color:#06101f;border-color:var(--accent)}
button.go{background:var(--ok);color:#06210f;border-color:var(--ok);font-weight:600}
button.danger{border-color:var(--bad);color:var(--bad)}
main{padding:16px;max-width:1100px}
section{display:none}section.on{display:block}
.cards{display:flex;gap:12px;flex-wrap:wrap;margin-bottom:16px}
.card{background:var(--card);border:1px solid var(--line);border-radius:10px;
padding:12px 16px;min-width:150px}
.card .k{color:var(--dim);font-size:12px}.card .v{font-size:22px;font-weight:600}
table{border-collapse:collapse;width:100%;background:var(--card);
border:1px solid var(--line);border-radius:10px;overflow:hidden}
th,td{padding:7px 10px;text-align:left;border-bottom:1px solid var(--line);font-size:13px}
th{color:var(--dim);font-weight:500}tr:last-child td{border-bottom:none}
input[type=text]{background:#0e1016;color:var(--fg);border:1px solid var(--line);
border-radius:5px;padding:4px 7px;font:inherit;width:100%;min-width:70px}
select{background:#0e1016;color:var(--fg);border:1px solid var(--line);
border-radius:5px;padding:4px;font:inherit}
.dot{display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--dim)}
.dot.up{background:var(--ok)}
.msg{padding:10px 12px;border-radius:8px;margin:12px 0;white-space:pre-wrap;
border:1px solid var(--line);background:var(--card);display:none;font-size:13px}
.msg.bad{border-color:var(--bad);color:var(--bad)}
.msg.ok{border-color:var(--ok);color:var(--ok)}
.bar{display:flex;align-items:flex-end;gap:2px;height:70px;margin-top:8px}
.bar i{flex:1;background:var(--accent);border-radius:2px 2px 0 0;min-height:1px}
.bar i.up{background:var(--warn)}
pre{background:#0e1016;border:1px solid var(--line);border-radius:8px;padding:12px;
max-height:340px;overflow:auto;font-size:12px;white-space:pre-wrap}
.dim{color:var(--dim)}.right{text-align:right}
.row{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin:12px 0}
</style>
<header>
<h1>Router</h1>
<button data-tab="overview" class="on">Overview</button>
<button data-tab="devices">Devices</button>
<button data-tab="ports">Ports</button>
<button data-tab="traffic">Traffic</button>
<button data-tab="speed">Speedtest</button>
</header>
<main>
<div id="msg" class="msg"></div>
<section id="overview" class="on">
<div class="cards" id="ovcards"></div>
<div class="row">
<button class="go" id="applybtn">Apply pending changes</button>
<span class="dim" id="pendinfo"></span>
</div>
<pre id="applylog" style="display:none"></pre>
</section>
<section id="devices">
<div class="row"><button class="go" id="devsave">Save to git</button>
<span class="dim">Saving commits devices.toml. Nothing reaches the router until you Apply.</span></div>
<table><thead><tr><th></th><th>Name</th><th>MAC</th><th>Vendor</th><th>Lease</th>
<th>Reserved IP</th><th>Block</th><th>Note</th><th></th></tr></thead>
<tbody id="devrows"></tbody></table>
</section>
<section id="ports">
<div class="row"><button class="go" id="portsave">Save to git</button>
<button id="portadd">Add forward</button></div>
<table><thead><tr><th>Name</th><th>Port / range</th><th>Protocol</th>
<th>Destination</th><th></th></tr></thead>
<tbody id="portrows"></tbody></table>
</section>
<section id="traffic">
<div class="cards" id="livecards"></div>
<h3>Last 24 hours</h3><div class="bar" id="th"></div>
<h3>Last 30 days</h3><div class="bar" id="td"></div>
<h3>Monthly</h3><div class="bar" id="tm"></div>
<p class="dim">Blue = download, amber = upload.</p>
</section>
<section id="speed">
<div class="row"><button class="go" id="strun">Run speedtest now</button>
<span class="dim">Takes ~30s, then refresh.</span></div>
<div class="cards" id="stcards"></div>
<table><thead><tr><th>When</th><th class="right">Down</th><th class="right">Up</th>
<th class="right">Ping</th><th>Server</th></tr></thead>
<tbody id="strows"></tbody></table>
</section>
</main>
<script>
const $ = s => document.querySelector(s);
const el = (t, p = {}) => Object.assign(document.createElement(t), p);
let devSha = '', portSha = '', devs = [], ports = [], head = '';
function say(text, bad) {
const m = $('#msg'); m.textContent = text;
m.className = 'msg ' + (bad ? 'bad' : 'ok'); m.style.display = text ? 'block' : 'none';
}
async function api(path, opts) {
const r = await fetch(path, opts);
const j = await r.json().catch(() => ({ error: r.statusText }));
if (!r.ok) throw new Error(j.error || r.statusText);
return j;
}
const post = (p, b) => api(p, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(b)
});
document.querySelectorAll('header button').forEach(b => b.onclick = () => {
document.querySelectorAll('header button').forEach(x => x.classList.remove('on'));
document.querySelectorAll('section').forEach(x => x.classList.remove('on'));
b.classList.add('on'); $('#' + b.dataset.tab).classList.add('on'); say(''); load(b.dataset.tab);
});
function dur(s) {
const d = Math.floor(s / 86400), h = Math.floor(s % 86400 / 3600);
return d ? d + 'd ' + h + 'h' : h + 'h ' + Math.floor(s % 3600 / 60) + 'm';
}
const gib = b => (b / 1024 / 1024 / 1024).toFixed(1) + ' GiB';
async function load(tab) {
try {
if (tab === 'overview') {
const o = await api('/api/overview'), p = await api('/api/pending');
head = p.head;
const drift = Object.entries(o.drift).filter(([, v]) => v).map(([k]) => k);
$('#ovcards').innerHTML = '';
[['WAN address', o.wan_ip], ['Uptime', dur(o.uptime)],
['DHCP leases', o.leases], ['Deployed', o.last_rev ? o.last_rev.slice(0, 8) : 'unknown'],
['Config drift', drift.length ? drift.join(', ') : 'none']
].forEach(([k, v]) => {
const c = el('div', { className: 'card' });
c.append(el('div', { className: 'k', textContent: k }),
el('div', { className: 'v', textContent: v }));
$('#ovcards').append(c);
});
$('#pendinfo').textContent = p.unapplied.length
? p.unapplied.length + ' commit(s) not yet deployed: '
+ p.unapplied.map(c => c.sha.slice(0, 8) + ' ' + c.message).join(' · ')
: 'Nothing to deploy — git matches the last apply.';
}
if (tab === 'devices') {
const d = await api('/api/devices'); devSha = d.sha; devs = d.devices; drawDevs();
}
if (tab === 'ports') {
const p = await api('/api/ports'); portSha = p.sha; ports = p.forwards; drawPorts();
}
if (tab === 'traffic') { live(); drawTraffic(await api('/api/traffic')); }
if (tab === 'speed') drawSpeed(await api('/api/speedtest'));
} catch (e) { say(e.message, true); }
}
function bind(obj, key) {
const i = el('input', { type: 'text', value: obj[key] ?? '' });
i.oninput = () => obj[key] = i.value.trim();
return i;
}
function drawDevs() {
const body = $('#devrows'); body.innerHTML = '';
devs.forEach((d, n) => {
const tr = el('tr');
const dot = el('span', { className: 'dot' + (d.online ? ' up' : '') });
const blocked = el('input', { type: 'checkbox', checked: d.blocked });
blocked.onchange = () => d.blocked = blocked.checked;
const cells = [dot, bind(d, 'name'), d.mac, d.vendor || '',
d.lease_ip || '', bind(d, 'ip'), blocked, bind(d, 'note')];
cells.forEach(c => { const td = el('td'); td.append(c.nodeType ? c : document.createTextNode(c)); tr.append(td); });
const td = el('td');
if (d.known) {
const b = el('button', { className: 'danger', textContent: 'Forget' });
b.onclick = () => { devs.splice(n, 1); drawDevs(); };
td.append(b);
} else td.append(el('span', { className: 'dim', textContent: 'unregistered' }));
tr.append(td); body.append(tr);
});
}
function drawPorts() {
const body = $('#portrows'); body.innerHTML = '';
ports.forEach((f, n) => {
const tr = el('tr');
const proto = el('select');
['tcp', 'udp', 'both'].forEach(p => proto.append(el('option', { value: p, textContent: p, selected: f.protocol === p })));
proto.onchange = () => f.protocol = proto.value;
[bind(f, 'name'), bind(f, 'port'), proto, bind(f, 'dest')].forEach(c => {
const td = el('td'); td.append(c); tr.append(td);
});
const td = el('td'), b = el('button', { className: 'danger', textContent: 'Remove' });
b.onclick = () => { ports.splice(n, 1); drawPorts(); };
td.append(b); tr.append(td); body.append(tr);
});
}
function bars(node, rows) {
node.innerHTML = '';
const max = Math.max(1, ...rows.map(r => Math.max(r.rx, r.tx)));
rows.forEach(r => {
[['rx', ''], ['tx', ' up']].forEach(([k, cls]) => {
node.append(el('i', {
className: cls.trim(), title: r.label + ' ' + k + ' ' + gib(r[k]),
style: 'height:' + Math.max(1, r[k] / max * 100) + '%'
}));
});
});
}
function drawTraffic(t) { bars($('#th'), t.hour); bars($('#td'), t.day); bars($('#tm'), t.month); }
let prev = null, timer = null;
async function live() {
clearInterval(timer);
const tick = async () => {
if (!$('#traffic').classList.contains('on')) return;
const s = await api('/api/live');
if (prev) {
const dt = Math.max(0.5, s.t - prev.t);
const mbps = b => ((b) * 8 / dt / 1e6).toFixed(1) + ' Mbps';
$('#livecards').innerHTML = '';
[['Down now', mbps(s.rx - prev.rx)], ['Up now', mbps(s.tx - prev.tx)]]
.forEach(([k, v]) => {
const c = el('div', { className: 'card' });
c.append(el('div', { className: 'k', textContent: k }),
el('div', { className: 'v', textContent: v }));
$('#livecards').append(c);
});
}
prev = s;
};
await tick(); timer = setInterval(tick, 2000);
}
function drawSpeed(rows) {
const last = rows[rows.length - 1];
$('#stcards').innerHTML = '';
if (last) [['Download', last.down + ' Mbps'], ['Upload', last.up + ' Mbps'],
['Ping', last.ping + ' ms'], ['When', last.ts]].forEach(([k, v]) => {
const c = el('div', { className: 'card' });
c.append(el('div', { className: 'k', textContent: k }),
el('div', { className: 'v', textContent: v }));
$('#stcards').append(c);
});
const body = $('#strows'); body.innerHTML = '';
rows.slice().reverse().forEach(r => {
const tr = el('tr');
[r.ts, r.down, r.up, r.ping, r.server].forEach((v, i) => {
tr.append(el('td', { textContent: v, className: i > 0 && i < 4 ? 'right' : '' }));
});
body.append(tr);
});
}
$('#devsave').onclick = async () => {
try {
const r = await post('/api/devices', { sha: devSha, devices: devs });
head = r.rev; say('Committed ' + r.rev.slice(0, 8) + '. Go to Overview and Apply.');
load('devices');
} catch (e) { say(e.message, true); }
};
$('#portsave').onclick = async () => {
try {
const r = await post('/api/ports', { sha: portSha, forwards: ports });
head = r.rev; say('Committed ' + r.rev.slice(0, 8) + '. Go to Overview and Apply.');
load('ports');
} catch (e) { say(e.message, true); }
};
$('#portadd').onclick = () => {
ports.push({ _i: null, name: '', port: '', protocol: 'tcp', dest: '' }); drawPorts();
};
$('#strun').onclick = async () => {
try { await post('/api/speedtest/run', {}); say('Speedtest started — refresh in ~30s.'); }
catch (e) { say(e.message, true); }
};
$('#applybtn').onclick = async () => {
if (!head) { say('Nothing to apply.', true); return; }
await doApply(false);
};
async function doApply(confirm) {
try {
await post('/api/apply', { rev: head, confirm });
say('Applying ' + head.slice(0, 8) + ' — test, health check, then switch.');
tail(head);
} catch (e) {
if (e.message.startsWith('CONFIRM:')) {
if (window.confirm(e.message.slice(8) + '\\n\\nApply anyway?')) return doApply(true);
return say('Cancelled.', true);
}
say(e.message, true);
}
}
function tail(rev) {
const pre = $('#applylog'); pre.style.display = 'block'; pre.textContent = '';
const src = new EventSource('/api/apply/log?rev=' + rev);
src.onmessage = e => { pre.textContent += e.data + '\\n'; pre.scrollTop = pre.scrollHeight; };
src.onerror = () => src.close();
}
load('overview');
</script>
""").encode()
if __name__ == "__main__":
os.makedirs(STATE, exist_ok=True)
ThreadingHTTPServer(("127.0.0.1", PORT), Handler).serve_forever()