router-ui: web management page for ports, devices, traffic, speedtest
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
3030d20034
commit
2f1e14495b
10 changed files with 1396 additions and 41 deletions
143
scripts/router-ui-check.py
Normal file
143
scripts/router-ui-check.py
Normal file
|
|
@ -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)
|
||||
962
scripts/router-ui.py
Normal file
962
scripts/router-ui.py
Normal file
|
|
@ -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 = ("""<!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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue