963 lines
36 KiB
Python
963 lines
36 KiB
Python
|
|
#!/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()
|