feat(router): Add local dashboard with auto-login, status, devices and restart

This commit is contained in:
abdus committed 2026-10-18 22:50:39 +03:00
1 parent cc6fe142f0
commit 545098eceb
1 file changed
+547
Executable
+547
View File
@@ -0,0 +1,547 @@
#!/usr/bin/env -S uv run --script
# /// script
# dependencies = ["bottle"]
# ///
import argparse
import base64
import json
import logging
import re
import ssl
import subprocess
import threading
import urllib.error
import urllib.request
import webbrowser
from collections.abc import Callable
from socketserver import ThreadingMixIn
from wsgiref.simple_server import WSGIRequestHandler, WSGIServer, make_server
import bottle
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s: %(message)s")
logger = logging.getLogger(__name__)
ROUTER_HOST = "192.168.1.1"
ROUTER_USERNAME = "abdus"
ROUTER_PASSWORD = "xAsametk50"
INTERNET_TARGET = "8.8.8.8"
UNIT_SECONDS = {"day": 86400, "hour": 3600, "min": 60, "sec": 1}
PAGE = r"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Router</title>
<style>
:root {
--bg: #f6f7f9; --card: #ffffff; --text: #1b1f24; --muted: #6a737d; --line: #e3e6ea;
--ok: #1a7f37; --warn: #9a6700; --bad: #cf222e; --accent: #0969da;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0d1117; --card: #161b22; --text: #e6edf3; --muted: #8b949e; --line: #30363d;
--ok: #3fb950; --warn: #d29922; --bad: #f85149; --accent: #58a6ff;
}
}
* { box-sizing: border-box; }
body { margin: 0; background: var(--bg); color: var(--text); font: 15px/1.45 system-ui, sans-serif; }
main { padding: 16px; display: grid; gap: 12px; grid-template-columns: repeat(12, minmax(0, 1fr)); }
.s4 { grid-column: span 4; }
.s8 { grid-column: span 8; }
.wide { grid-column: 1 / -1; }
.card.stack { display: flex; flex-direction: column; }
.card.stack .grid { align-content: start; }
@media (max-width: 1000px) { .s4, .s8 { grid-column: span 6; } }
@media (max-width: 640px) { .s4, .s8 { grid-column: 1 / -1; } }
.card { background: var(--card); border: 1px solid var(--line); border-radius: 10px; padding: 14px 16px; }
.banner { display: flex; align-items: center; gap: 12px; }
.dot { width: 14px; height: 14px; border-radius: 50%; background: var(--muted); flex: none; }
.banner h1 { margin: 0; font-size: 20px; }
.banner p { margin: 0; color: var(--muted); }
.ok .dot { background: var(--ok); } .warn .dot { background: var(--warn); } .bad .dot { background: var(--bad); }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 12px; }
.stat small { display: block; color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: .04em; }
.stat b { font-size: 18px; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }
.notes { color: var(--warn); margin: 0; padding-left: 34px; }
.notes:empty { display: none; }
svg { width: 100%; flex: 1; min-height: 64px; display: block; margin-top: 12px; }
h2 { margin: 0 0 8px; font-size: 14px; color: var(--muted); text-transform: uppercase; letter-spacing: .04em; }
table { width: 100%; border-collapse: collapse; font-size: 14px; }
th, td { text-align: left; padding: 5px 6px; border-bottom: 1px solid var(--line); }
th { color: var(--muted); font-weight: 500; }
.off { color: var(--muted); }
button { font: inherit; padding: 8px 14px; border-radius: 8px; border: 1px solid var(--line); background: var(--card); color: var(--text); cursor: pointer; }
button.danger { color: var(--bad); border-color: var(--bad); }
button.armed { background: var(--bad); color: #fff; }
button:disabled { opacity: .5; cursor: default; }
details summary { cursor: pointer; color: var(--muted); }
pre { margin: 8px 0 0; max-height: 260px; overflow: auto; font-size: 12px; white-space: pre-wrap; }
.row { display: flex; justify-content: space-between; align-items: center; gap: 12px; flex-wrap: wrap; }
</style>
</head>
<body>
<main>
<section class="card banner s8" id="banner">
<span class="dot"></span>
<div><h1 id="title">Connecting...</h1><p id="subtitle"></p></div>
</section>
<section class="card row s4">
<div><b id="model">Router</b><div class="off" id="fw"></div></div>
<button id="reboot" class="danger">Restart router</button>
</section>
<ul class="card notes wide" id="notes"></ul>
<section class="card stack s4"><h2>Ping</h2>
<div class="grid" id="statsPing"></div>
<svg id="spark" viewBox="0 0 300 64" preserveAspectRatio="none"></svg>
</section>
<section class="card stack s4"><h2>Line</h2><div class="grid" id="statsLine"></div></section>
<section class="card stack s4"><h2>Connection</h2><div class="grid" id="statsConn"></div></section>
<section class="card wide"><h2>Connected devices</h2>
<table><thead><tr><th>Name</th><th>IP</th><th>Link</th><th>Signal</th></tr></thead><tbody id="hosts"></tbody></table>
</section>
<section class="card wide"><details id="logbox"><summary>Router log</summary><pre id="log">loading...</pre></details></section>
</main>
<script>
const $ = (id) => document.getElementById(id);
const history = [];
const notes = [];
let last = null;
let restarting = false;
function esc(value) {
return String(value ?? "").replace(/[&<>"']/g, (c) => ({"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;"}[c]));
}
function duration(seconds) {
if (seconds == null) return "-";
const d = Math.floor(seconds / 86400), h = Math.floor(seconds % 86400 / 3600), m = Math.floor(seconds % 3600 / 60);
return d ? `${d}d ${h}h` : h ? `${h}h ${m}m` : m ? `${m}m ${seconds % 60}s` : `${seconds}s`;
}
function mbps(kbps) { return kbps == null ? "-" : (kbps / 1000).toFixed(1) + " Mbps"; }
function num(value, unit) { return value == null ? "-" : value + " " + unit; }
function clock() { return new Date().toLocaleTimeString(); }
function setLevel(level) {
$("banner").classList.remove("ok", "warn", "bad");
$("banner").classList.add(level);
}
function verdict(data) {
const { ping, dsl, wan } = data;
if (ping.router_ms == null) return ["bad", "Router unreachable", "No answer from 192.168.1.1"];
if (!dsl || dsl.state !== "Up") return ["bad", "Line down", `DSL state: ${dsl ? dsl.state : "unknown"} (line / ISP side)`];
if (!wan || !wan.ip) return ["bad", "No internet session", "DSL is up but PPPoE has no IP (ISP side)"];
if (ping.internet_ms == null) return ["bad", "Internet unreachable", "Line and PPPoE are up, 8.8.8.8 does not answer"];
if (ping.internet_ms > 150) return ["warn", "Degraded", `Slow: ${ping.internet_ms.toFixed(0)} ms to 8.8.8.8`];
return ["ok", "Online", `${ping.internet_ms.toFixed(0)} ms to 8.8.8.8`];
}
function trackEvents(data) {
if (last && data.dsl && last.dsl && data.dsl.uptime_s < last.dsl.uptime_s) notes.unshift(`${clock()}: DSL line resynced`);
if (last && data.device && last.device && data.device.uptime_s < last.device.uptime_s) notes.unshift(`${clock()}: router rebooted`);
if (last && data.wan && last.wan && data.wan.ip !== last.wan.ip) notes.unshift(`${clock()}: WAN IP changed`);
if (last && last.ping.internet_ms != null && data.ping.internet_ms == null) notes.unshift(`${clock()}: internet ping lost`);
notes.length = Math.min(notes.length, 5);
$("notes").innerHTML = notes.map((n) => `<li>${esc(n)}</li>`).join("");
}
function stat(label, value) { return `<div class="stat"><small>${label}</small><b>${esc(value)}</b></div>`; }
function drawSpark() {
const points = history.slice(-60);
const top = Math.max(50, ...points.filter((p) => p != null)) * 1.1;
const step = 300 / (Math.max(points.length, 12) - 1);
let path = "", marks = "";
points.forEach((value, index) => {
const x = index * step;
if (value == null) { marks += `<line x1="${x}" x2="${x}" y1="0" y2="64" stroke="var(--bad)" stroke-width="2" vector-effect="non-scaling-stroke"/>`; return; }
path += `${path ? "L" : "M"}${x.toFixed(1)},${(62 - value / top * 60).toFixed(1)}`;
});
$("spark").innerHTML = `<path d="${path}" fill="none" stroke="var(--accent)" stroke-width="1.5" vector-effect="non-scaling-stroke"/>${marks}`;
}
function render(data) {
const [level, title, subtitle] = verdict(data);
setLevel(level);
$("title").textContent = title;
$("subtitle").textContent = subtitle;
trackEvents(data);
const { dsl, wan, device, ping } = data;
const ms = (value) => num(value == null ? null : value.toFixed(0), "ms");
$("statsPing").innerHTML = [
stat("Internet (8.8.8.8)", ms(ping.internet_ms)),
stat("Router", ms(ping.router_ms)),
stat("ISP gateway", ms(ping.isp_gateway_ms)),
].join("");
$("statsLine").innerHTML = [
stat("Down sync", mbps(dsl && dsl.down_kbps)),
stat("Up sync", mbps(dsl && dsl.up_kbps)),
stat("SNR down / up", dsl ? `${dsl.snr_down_db} / ${dsl.snr_up_db} dB` : "-"),
stat("Attenuation", dsl ? `${dsl.attn_down_db} / ${dsl.attn_up_db} dB` : "-"),
stat("Errors", dsl ? dsl.errors : "-"),
stat("DSL uptime", duration(dsl && dsl.uptime_s)),
].join("");
$("statsConn").innerHTML = [
stat("WAN IP", wan && wan.ip || "-"),
stat("DNS", wan && wan.dns ? wan.dns.split(",").join(", ") : "-"),
stat("Router uptime", duration(device && device.uptime_s)),
].join("");
$("model").textContent = device ? device.model : "Router";
$("fw").textContent = device ? "Firmware " + device.firmware : "";
const hosts = (data.hosts || []).slice().sort((a, b) => (b.active - a.active) || a.name.localeCompare(b.name));
$("hosts").innerHTML = hosts.map((h) =>
`<tr class="${h.active ? "" : "off"}"><td>${esc(h.name)}</td><td>${esc(h.ip)}</td><td>${esc(h.connection)}</td><td>${h.signal == null ? "" : esc(h.signal) + "/5"}</td></tr>`
).join("");
history.push(ping.internet_ms);
drawSpark();
last = data;
}
async function refresh() {
if (restarting) return;
try {
render(await (await fetch("/api/overview")).json());
} catch (error) {
$("title").textContent = "Dashboard server unreachable";
setLevel("bad");
}
}
async function loadLog() {
try {
const rows = await (await fetch("/api/log")).json();
$("log").textContent = rows.length ? rows.map((r) => typeof r === "string" ? r : Object.values(r).join(" ")).join("\n") : "(empty)";
} catch (error) {
$("log").textContent = "could not load log: " + error;
}
}
async function restartRouter() {
restarting = true;
$("reboot").disabled = true;
setLevel("warn");
$("title").textContent = "Restarting router...";
const started = Date.now();
fetch("/api/reboot", { method: "POST" }).catch(() => {});
const timer = setInterval(async () => {
const elapsed = Math.round((Date.now() - started) / 1000);
$("subtitle").textContent = `${elapsed}s, waiting for the router to come back`;
if (elapsed < 20) return;
try {
const data = await (await fetch("/api/overview")).json();
if (data.ping.router_ms != null && data.device) {
clearInterval(timer);
restarting = false;
$("reboot").disabled = false;
last = null;
notes.unshift(`${clock()}: router restarted from dashboard`);
render(data);
}
} catch (error) {}
}, 3000);
}
let armTimer = null;
$("reboot").addEventListener("click", () => {
const button = $("reboot");
if (!button.classList.contains("armed")) {
button.classList.add("armed");
button.textContent = "Click again to restart";
armTimer = setTimeout(() => { button.classList.remove("armed"); button.textContent = "Restart router"; }, 4000);
return;
}
clearTimeout(armTimer);
button.classList.remove("armed");
button.textContent = "Restart router";
restartRouter();
});
$("logbox").addEventListener("toggle", () => { if ($("logbox").open) loadLog(); });
refresh();
setInterval(refresh, 5000);
</script>
</body>
</html>
"""
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Local dashboard for the Zyxel router",
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
)
parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--port", type=int, default=0, help="0 picks a random free port")
parser.add_argument("--timeout", type=float, default=10.0, help="Router request timeout in seconds")
parser.add_argument("--no-browser", action="store_true", help="Do not open the page on start")
return parser.parse_args()
class Router:
def __init__(self, host: str, timeout: float) -> None:
self.base_url = f"https://{host}"
self.timeout = timeout
self.lock = threading.Lock()
self.session_key = ""
# Self-signed certificate; over HTTPS the router accepts a plain JSON login.
context = ssl.create_default_context()
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE
self.opener = urllib.request.build_opener(
urllib.request.HTTPSHandler(context=context),
urllib.request.HTTPCookieProcessor(),
)
def request(self, path: str, body: dict | None = None) -> object:
separator = "&" if "?" in path else "?"
suffix = f"{separator}sessionkey={self.session_key}" if self.session_key else ""
request = urllib.request.Request(
url=f"{self.base_url}{path}{suffix}",
data=json.dumps(body).encode() if body is not None else None,
headers={"Content-Type": "application/json"},
method="POST" if body is not None else "GET",
)
with self.opener.open(request, timeout=self.timeout) as response:
raw = response.read().decode(errors="replace")
return json.loads(raw) if raw.strip() else {}
def login(self) -> None:
self.session_key = ""
reply = self.request(
path="/UserLogin",
body={
"Input_Account": ROUTER_USERNAME,
"Input_Passwd": base64.b64encode(ROUTER_PASSWORD.encode()).decode(),
"currLang": "en",
"RememberPassword": 0,
"SHA512_password": False,
},
)
if reply.get("result") != "ZCFG_SUCCESS":
raise RuntimeError(f"Login rejected: {reply}")
self.session_key = reply.get("sessionkey", "")
logger.info("Logged in to router")
def call(self, path: str, body: dict | None = None) -> object:
with self.lock:
if not self.session_key:
self.login()
try:
return self.request(path=path, body=body)
except (urllib.error.URLError, json.JSONDecodeError, ConnectionError):
logger.info("Router call failed, logging in again")
self.login()
return self.request(path=path, body=body)
def reboot(self) -> object:
with self.lock:
if not self.session_key:
self.login()
try:
return self.request(path="/cgi-bin/Reboot", body={})
finally:
self.session_key = ""
def find_number(pattern: str, text: str) -> float | None:
match = re.search(pattern, text)
return float(match.group(1)) if match else None
def parse_duration(text: str) -> int:
return sum(int(number) * UNIT_SECONDS[unit] for number, unit in re.findall(r"(\d+)\s*(day|hour|min|sec)", text))
def parse_dsl(text: str) -> dict:
state = re.search(r"Line State:\s*(\S+)", text)
uptime = re.search(r"DSL uptime\s*:(.*)", text)
return {
"state": state.group(1) if state else None,
"uptime_s": parse_duration(text=uptime.group(1)) if uptime else None,
"down_kbps": find_number(pattern=r"near-end fast channel bit rate: (\d+)", text=text),
"up_kbps": find_number(pattern=r"far-end fast channel bit rate: (\d+)", text=text),
"snr_down_db": find_number(pattern=r"noise margin downstream: ([\d.]+)", text=text),
"snr_up_db": find_number(pattern=r"noise margin upstream: ([\d.]+)", text=text),
"attn_down_db": find_number(pattern=r"attenuation downstream: ([\d.]+)", text=text),
"attn_up_db": find_number(pattern=r"attenuation upstream: ([\d.]+)", text=text),
"errors": sum(int(count) for count in re.findall(r"(?:CRC|FEC|HEC) error \w+: (\d+)", text)),
}
def ping(host: str | None) -> float | None:
if not host:
return None
result = subprocess.run(
["ping", "-c", "1", "-W", "2000", host],
capture_output=True,
text=True,
check=False,
)
return find_number(pattern=r"time=([\d.]+)", text=result.stdout)
def read_device(router: Router) -> dict:
info = router.call(path="/cgi-bin/DAL?oid=status")["Object"][0]["DeviceInfo"]
return {
"model": info.get("ModelName"),
"firmware": info.get("SoftwareVersion"),
"uptime_s": info.get("UpTime"),
}
def read_dsl(router: Router) -> dict:
return parse_dsl(text=router.call(path="/cgi-bin/xDSLStatistics_handle?line=0")[0]["result"])
def read_wan(router: Router) -> dict:
entries = router.call(path="/cgi-bin/DAL?oid=wan")["Object"]
vdsl = next((entry for entry in entries if entry.get("Name") == "VDSL"), {})
return {
"ip": vdsl.get("IPAddress") or None,
"gateway": vdsl.get("GatewayIPAddress") or None,
"dns": vdsl.get("DNSServer") or None,
}
def read_hosts(router: Router) -> list[dict]:
hosts = router.call(path="/cgi-bin/DAL?oid=lanhosts")["Object"][0]["lanhosts"]
return [
{
"name": host.get("HostName") or host.get("DeviceName") or "unknown",
"ip": host.get("IPAddress"),
"mac": host.get("PhysAddress"),
"connection": host.get("X_ZYXEL_ConnectionType"),
"active": bool(host.get("Active")),
"signal": host.get("X_ZYXEL_SignalStrength"),
}
for host in hosts
]
def safely(reader, router: Router) -> object:
try:
return reader(router)
except Exception as exc:
logger.warning("%s failed: %s", reader.__name__, exc)
return None
def run_parallel(tasks: dict[str, Callable[[], object]]) -> dict[str, object]:
# Daemon threads, so Ctrl+C does not wait for slow router calls or pings.
results: dict[str, object] = {}
def worker(name: str, task: Callable[[], object]) -> None:
results[name] = task()
threads = [threading.Thread(target=worker, args=(name, task), daemon=True) for name, task in tasks.items()]
for thread in threads:
thread.start()
for thread in threads:
thread.join()
return results
def overview(router: Router) -> dict:
results = run_parallel(
tasks={
"device": lambda: safely(read_device, router),
"dsl": lambda: safely(read_dsl, router),
"wan": lambda: safely(read_wan, router),
"hosts": lambda: safely(read_hosts, router),
"router_ms": lambda: ping(host=ROUTER_HOST),
"internet_ms": lambda: ping(host=INTERNET_TARGET),
}
)
wan = results["wan"]
gateway = wan["gateway"] if wan and wan["ip"] else None
return {
"device": results["device"],
"dsl": results["dsl"],
"wan": wan,
"hosts": results["hosts"],
"ping": {
"router_ms": results["router_ms"],
"isp_gateway_ms": ping(host=gateway),
"internet_ms": results["internet_ms"],
},
}
def build_app(router: Router) -> bottle.Bottle:
app = bottle.Bottle()
@app.get("/")
def index() -> str:
return PAGE
@app.get("/api/overview")
def api_overview() -> dict:
return overview(router=router)
@app.get("/api/log")
def api_log() -> dict:
bottle.response.content_type = "application/json"
try:
return json.dumps(router.call(path="/cgi-bin/DAL?oid=zlog")["Object"])
except Exception as exc:
logger.warning("log failed: %s", exc)
bottle.response.status = 502
return json.dumps([])
@app.post("/api/reboot")
def api_reboot() -> dict:
logger.warning("Rebooting router on request")
try:
router.reboot()
except Exception as exc:
# The router may drop the connection while it goes down.
logger.info("Reboot call ended with: %s", exc)
return {"ok": True}
return app
class ThreadingServer(ThreadingMixIn, WSGIServer):
daemon_threads = True
class QuietHandler(WSGIRequestHandler):
def log_message(self, format: str, *args: object) -> None:
pass
def main() -> None:
args = parse_args()
router = Router(host=ROUTER_HOST, timeout=args.timeout)
server = make_server(
host=args.host,
port=args.port,
app=build_app(router=router),
server_class=ThreadingServer,
handler_class=QuietHandler,
)
url = f"http://{args.host}:{server.server_port}"
logger.info("Serving %s", url)
if not args.no_browser:
opener = threading.Timer(interval=0.5, function=webbrowser.open, args=[url])
opener.daemon = True
opener.start()
try:
server.serve_forever(poll_interval=0.2)
except KeyboardInterrupt:
logger.info("Stopping")
finally:
server.server_close()
if __name__ == "__main__":
main()