Files
playground/router_ui.py
T
abdus 969046dec2 feat(router): Rework dashboard layout, add public IP/log controls, fix writes on ABSB.5
- Full-width CSS grid layout with Ping/Line/Connection cards at equal height
- Show public IP (via api.ipify.org) alongside WAN IP, with a "Get new public IP"
  button that drops and re-establishes the PPPoE session
- Replace the wrong oid=zlog log endpoint with the real one the stock UI uses,
  and add a logging on/off toggle (router logging was off by default)
- Poll the router in background threads with cached responses, since a live
  /api/overview request took ~16s and made the page hang on "Connecting..."
- Fix: writes (DNS, logging toggle, reboot, WAN disconnect/connect) started
  returning 403 after the ABSB.5 firmware upgrade; it now requires the session
  key repeated in a CSRFToken header on every POST/PUT/DELETE, not just the
  ?sessionkey= query param GETs still accept
- Fix: Router is now a context manager that always logs out in __exit__, and
  SIGTERM (what pkill sends) now triggers that cleanup too -- previously every
  restart during dev leaked a session until the router hit its concurrent
  login limit and locked out all further logins
- Fix: WAN Disconnect/Connect often don't return an HTTP response before the
  link they're answering over drops; treat that timeout as "sent, unknown
  outcome" and verify by polling instead of crashing, and retry Connect if
  the router's own auto-reconnect doesn't kick in (it can get stuck at N/A)
2026-10-19 00:33:39 +03:00

1072 lines
43 KiB
Python
Executable File

#!/usr/bin/env -S uv run --script
# /// script
# dependencies = ["bottle"]
# ///
import argparse
import base64
import ipaddress
import json
import logging
import re
import signal
import ssl
import subprocess
import threading
import time
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"
PUBLIC_IP_URL = "https://api.ipify.org"
UNIT_SECONDS = {"day": 86400, "hour": 3600, "min": 60, "sec": 1}
DNS_MODES = ("DNSProxy", "Static", "FromISP")
LOG_PATH = "/cgi-bin/Log?action=GET_LOG&oid=RDM_OID_LOG_CLASSIFY&iid=[1,0,0,0,0,0]"
LOG_LINE = re.compile(r"^\[(\w+)\]\s+(\w{3}\s+\d+\s+[\d:]+)\s+(\w+)\.(\w+)\s+(.*)$")
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(175px, 1fr)); gap: 12px; }
.stat small { display: block; color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: .04em; }
.stat b { white-space: pre-line; font-size: 18px; font-variant-numeric: tabular-nums; overflow-wrap: break-word; }
.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; }
input, select { font: inherit; padding: 7px 10px; border-radius: 8px; border: 1px solid var(--line); background: var(--bg); color: var(--text); width: 100%; }
input:disabled { opacity: .5; }
label.field { display: block; margin-bottom: 10px; }
label.field small { display: block; color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: .04em; margin-bottom: 3px; }
.presets { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 12px; }
.presets button { padding: 4px 10px; font-size: 13px; }
button.primary { background: var(--accent); border-color: var(--accent); color: var(--bg); font-weight: 600; }
#dnsMsg, #newIpMsg { margin: 10px 0 0; min-height: 1.4em; font-size: 13px; }
#dnsMsg.error, #newIpMsg.error { color: var(--bad); } #dnsMsg.success, #newIpMsg.success { color: var(--ok); }
details summary { cursor: pointer; color: var(--muted); }
pre { margin: 8px 0 0; max-height: 320px; overflow: auto; font-size: 12px; white-space: pre-wrap; }
.logbar { margin-top: 10px; }
.logbar button { padding: 4px 10px; font-size: 13px; }
.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>
<div class="row" style="margin-top:12px"><button id="newIp">Get new public IP</button></div>
<p id="newIpMsg"></p>
</section>
<section class="card s4 stack" id="dnsCard"><h2>DNS for devices</h2>
<div class="presets">
<button type="button" data-dns="1.1.1.1,1.0.0.1">Cloudflare</button>
<button type="button" data-dns="8.8.8.8,8.8.4.4">Google</button>
<button type="button" data-dns="9.9.9.9,149.112.112.112">Quad9</button>
</div>
<label class="field"><small>Mode</small>
<select id="dnsMode">
<option value="DNSProxy">Router (proxy)</option>
<option value="Static">Custom</option>
<option value="FromISP">From ISP</option>
</select>
</label>
<label class="field"><small>Primary</small><input id="dns1" inputmode="decimal" placeholder="1.1.1.1" autocomplete="off"></label>
<label class="field"><small>Secondary (optional)</small><input id="dns2" inputmode="decimal" placeholder="1.0.0.1" autocomplete="off"></label>
<div><button id="dnsApply" class="primary">Apply</button></div>
<p id="dnsMsg"></p>
</section>
<section class="card s8"><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 <span id="logState" class="off"></span></summary>
<div class="row logbar"><span id="logHint" class="off"></span>
<span><button id="logRefresh">Refresh</button> <button id="logToggle"></button></span>
</div>
<pre id="log">loading...</pre>
</details></section>
</main>
<script>
/**
* @typedef {{router_ms: ?number, isp_gateway_ms: ?number, internet_ms: ?number}} Pings
* @typedef {{state: ?string, uptime_s: ?number, down_kbps: ?number, up_kbps: ?number, snr_down_db: ?number, snr_up_db: ?number, attn_down_db: ?number, attn_up_db: ?number, crc_errors: number, fec_errors: number}} Dsl
* @typedef {{ip: ?string, gateway: ?string, dns: ?string}} Wan
* @typedef {{model: string, firmware: string, uptime_s: number}} Device
* @typedef {{name: string, ip: string, mac: string, connection: string, active: boolean, signal: ?number}} Host
* @typedef {{mode: "DNSProxy"|"Static"|"FromISP", server1: string, server2: string}} DnsSettings
* @typedef {{ping: Pings, dsl: ?Dsl, wan: ?Wan, device: ?Device, hosts: ?Host[], dns: ?DnsSettings, public_ip: ?string}} Overview
* @typedef {"ok"|"warn"|"bad"} Level
*/
/** @param {string} id @returns {HTMLElement} */
const $ = (id) => document.getElementById(id);
/** Internet ping samples (ms, null = lost), newest last. @type {(?number)[]} */
const history = [];
/** Recent events shown above the cards, newest first. @type {string[]} */
const notes = [];
/** Previous overview, used to detect changes between polls. @type {?Overview} */
let last = null;
/** True while the router is rebooting, so polling does not overwrite the countdown. */
let restarting = false;
/**
* Escape text for safe use inside innerHTML.
* @param {unknown} value
* @returns {string}
*/
function esc(value) {
return String(value ?? "").replace(/[&<>"']/g, (c) => ({"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;"}[c]));
}
/**
* Format a number of seconds as a short duration such as "2d 3h", "5m 10s" or "42s".
* @param {?number} seconds
* @returns {string} "-" when the value is missing
*/
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`;
}
/**
* @param {?number} kbps
* @returns {string} e.g. "41.2 Mbps", or "-" when the value is missing
*/
function mbps(kbps) { return kbps == null ? "-" : (kbps / 1000).toFixed(1) + " Mbps"; }
/**
* @param {?(number|string)} value
* @param {string} unit
* @returns {string} e.g. "24 ms", or "-" when the value is missing
*/
function num(value, unit) { return value == null ? "-" : value + " " + unit; }
/** @returns {string} Current local time, used to stamp events. */
function clock() { return new Date().toLocaleTimeString(); }
/**
* Set the status banner colour, replacing the previous level but keeping its other classes.
* @param {Level} level
*/
function setLevel(level) {
$("banner").classList.remove("ok", "warn", "bad");
$("banner").classList.add(level);
}
/**
* Decide which layer is failing, checking from the router outwards: router, DSL line, PPPoE session, internet.
* @param {Overview} data
* @returns {[Level, string, string]} level, headline and explanation
*/
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`];
}
/**
* Compare with the previous overview and record DSL resyncs, router reboots, WAN IP changes and lost pings.
* @param {Overview} data
*/
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("");
}
/**
* @param {string} label
* @param {unknown} value escaped before it is inserted
* @returns {string} HTML for one label and value cell
*/
function stat(label, value) { return `<div class="stat"><small>${label}</small><b>${esc(value)}</b></div>`; }
/** Draw the last 60 internet ping samples as a line, with a red tick for each lost ping. */
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}`;
}
/**
* Update every card on the page from one overview.
* @param {Overview} data
*/
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: uncorrected / corrected", dsl ? `${dsl.crc_errors} / ${dsl.fec_errors}` : "-"),
stat("DSL uptime", duration(dsl && dsl.uptime_s)),
].join("");
$("statsConn").innerHTML = [
stat("Public IP", data.public_ip || "-"),
stat("WAN IP", wan && wan.ip || "-"),
stat("ISP DNS", wan && wan.dns ? wan.dns.split(",").join("\n") : "-"),
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();
renderDns(data.dns);
last = data;
}
/** True once the user edits the DNS form, so polling does not overwrite what they are typing. */
let dnsDirty = false;
/** Enable the server fields only in "Custom" mode. */
function syncDnsInputs() {
const custom = $("dnsMode").value === "Static";
$("dns1").disabled = $("dns2").disabled = !custom;
}
/**
* Fill the DNS form from the router's settings, unless the user has unsaved edits.
* @param {?DnsSettings} dns
*/
function renderDns(dns) {
if (!dns || dnsDirty) return;
$("dnsMode").value = dns.mode;
$("dns1").value = dns.server1;
$("dns2").value = dns.server2;
syncDnsInputs();
}
/**
* Show a status line under the DNS form.
* @param {string} text
* @param {"success"|"error"} [kind] omit for a neutral message
*/
function dnsMessage(text, kind) {
$("dnsMsg").textContent = text;
$("dnsMsg").className = kind || "";
}
/**
* Ask for confirmation, then send the DNS form to /api/dns and show the result.
* On success the form is refilled from the router's reply.
* @returns {Promise<void>}
*/
async function applyDns() {
const mode = $("dnsMode").value;
const summary = mode === "Static" ? [$("dns1").value, $("dns2").value].filter(Boolean).join(", ") : mode === "FromISP" ? "the ISP's servers" : "the router";
if (!confirm(`Hand out ${summary} as DNS to all devices?`)) return;
$("dnsApply").disabled = true;
dnsMessage("Applying...");
try {
const response = await fetch("/api/dns", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ mode, server1: $("dns1").value, server2: $("dns2").value }),
});
const body = await response.json();
if (!response.ok) throw new Error(body.error || response.statusText);
dnsDirty = false;
renderDns(body);
dnsMessage("Saved. Devices pick it up when they renew their lease (reconnect Wi-Fi).", "success");
} catch (error) {
dnsMessage(error.message, "error");
} finally {
$("dnsApply").disabled = false;
}
}
for (const id of ["dnsMode", "dns1", "dns2"]) {
$(id).addEventListener("input", () => { dnsDirty = true; syncDnsInputs(); });
}
for (const button of document.querySelectorAll("[data-dns]")) {
button.addEventListener("click", () => {
const [first, second] = button.dataset.dns.split(",");
$("dnsMode").value = "Static";
$("dns1").value = first;
$("dns2").value = second;
dnsDirty = true;
syncDnsInputs();
});
}
$("dnsApply").addEventListener("click", applyDns);
/**
* Show a status line under the "Get new public IP" button.
* @param {string} text
* @param {"success"|"error"} [kind]
*/
function newIpMessage(text, kind) {
$("newIpMsg").textContent = text;
$("newIpMsg").className = kind || "";
}
/**
* Drop and re-establish the PPPoE session to request a new CGNAT public IP from the ISP.
* Briefly interrupts the internet for everyone on the network; not guaranteed to change the IP.
* @returns {Promise<void>}
*/
async function requestNewIp() {
if (!confirm("This drops and reconnects the internet for everyone for about 10-30s, and the ISP may hand back the same IP. Continue?")) return;
$("newIp").disabled = true;
newIpMessage(`Reconnecting... (current: ${$("statsConn").querySelector("b")?.textContent || "unknown"})`);
try {
const response = await fetch("/api/new-ip", { method: "POST" });
const body = await response.json();
if (!response.ok) throw new Error(body.error || response.statusText);
newIpMessage(body.public_ip ? `New public IP: ${body.public_ip}` : "Reconnected, but could not confirm the new public IP.", "success");
refresh();
} catch (error) {
newIpMessage(error.message, "error");
} finally {
$("newIp").disabled = false;
}
}
$("newIp").addEventListener("click", requestNewIp);
/** True while an overview request is in flight, so slow responses do not pile up. */
let refreshing = false;
/**
* Fetch the latest overview and render it. Skipped while restarting or while a request is still pending.
* @returns {Promise<void>}
*/
async function refresh() {
if (restarting || refreshing) return;
refreshing = true;
try {
render(await (await fetch("/api/overview")).json());
} catch (error) {
$("title").textContent = "Dashboard server unreachable";
setLevel("bad");
} finally {
refreshing = false;
}
}
/** @typedef {{time: string, category: string, level: string, message: string}} LogEntry */
/** Whether the router is currently recording its log. */
let logEnabled = false;
/**
* Show the router log, newest first, and the state of the logging switch.
* @param {{enabled: boolean, entries: LogEntry[]}} log
*/
function renderLog(log) {
logEnabled = log.enabled;
$("logState").textContent = log.enabled ? "(on)" : "(off)";
$("logToggle").textContent = log.enabled ? "Turn logging off" : "Turn logging on";
$("logHint").textContent = log.enabled
? "The log lives in the router's memory and is cleared by a reboot."
: "Logging is off, so the router records nothing. Turn it on to see line drops and PPPoE reconnects.";
$("log").textContent = log.entries.length
? log.entries.map((e) => e.time ? `${e.time} ${e.level.padEnd(7)} [${e.category}] ${e.message}` : e.message).join("\n")
: log.enabled ? "(no entries yet)" : "(logging is off)";
}
/**
* Load the router log into the log panel.
* @returns {Promise<void>}
*/
async function loadLog() {
$("logRefresh").disabled = true;
try {
const response = await fetch("/api/log");
const body = await response.json();
if (!response.ok) throw new Error(body.error || response.statusText);
renderLog(body);
} catch (error) {
$("log").textContent = "could not load log: " + error.message;
} finally {
$("logRefresh").disabled = false;
}
}
/**
* Switch the router's logging on or off, then show the resulting log.
* @returns {Promise<void>}
*/
async function toggleLogging() {
$("logToggle").disabled = true;
try {
const response = await fetch("/api/log", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled: !logEnabled }),
});
const body = await response.json();
if (!response.ok) throw new Error(body.error || response.statusText);
renderLog(body);
} catch (error) {
$("logHint").textContent = "could not change logging: " + error.message;
} finally {
$("logToggle").disabled = false;
}
}
/**
* Send the reboot request, then show a countdown and poll until the router answers again.
* Polling starts after 20 s, once the router has actually gone down.
* @returns {Promise<void>}
*/
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);
}
/** Timer that disarms the restart button if the second click does not come. @type {?number} */
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(); });
$("logRefresh").addEventListener("click", loadLog);
$("logToggle").addEventListener("click", toggleLogging);
refresh();
setInterval(refresh, 3000);
</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, method: str | None = None) -> object:
method = method or ("POST" if body is not None else "GET")
separator = "&" if "?" in path else "?"
suffix = f"{separator}sessionkey={self.session_key}" if self.session_key else ""
headers = {"Content-Type": "application/json"}
if self.session_key and method != "GET":
# Since the ABSB.5 firmware, state-changing requests (POST/PUT/DELETE) are only
# accepted with the session key repeated in this header; the ?sessionkey= query
# param alone (still required for GETs) gets a bare 403 on everything else.
headers["CSRFToken"] = self.session_key
request = urllib.request.Request(
url=f"{self.base_url}{path}{suffix}",
data=json.dumps(body).encode() if body is not None else None,
headers=headers,
method=method,
)
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, method: str | None = None) -> object:
with self.lock:
if not self.session_key:
self.login()
try:
return self.request(path=path, body=body, method=method)
except (urllib.error.URLError, json.JSONDecodeError, ConnectionError):
logger.info("Router call failed, logging in again")
self.login()
return self.request(path=path, body=body, method=method)
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 logout(self) -> None:
with self.lock:
if not self.session_key:
return
try:
self.request(path="/cgi-bin/UserLogout", body={})
logger.info("Logged out, session released")
except Exception as exc:
logger.warning("Logout failed, the router may hold this session until it times out: %s", exc)
finally:
self.session_key = ""
def __enter__(self) -> "Router":
self.login()
return self
def __exit__(self, exc_type: object, exc_value: object, traceback: object) -> None:
self.logout()
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),
"crc_errors": sum(int(count) for count in re.findall(r"(?:CRC|HEC) error \w+: (\d+)", text)),
"fec_errors": sum(int(count) for count in re.findall(r"FEC 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_lan_dns(router: Router) -> dict:
lan = router.call(path="/cgi-bin/DAL?oid=lan")["Object"][0]
return {
"mode": lan.get("DNS_Type"),
"server1": lan.get("DNS_Servers1") or "",
"server2": lan.get("DNS_Servers2") or "",
}
def validate_dns_server(value: str) -> str:
try:
address = ipaddress.IPv4Address(value.strip())
except ValueError:
raise ValueError(f"{value!r} is not a valid IPv4 address") from None
if address.is_unspecified or address.is_loopback or address.is_multicast or address.is_reserved:
raise ValueError(f"{address} cannot be used as a DNS server")
return str(address)
def set_lan_dns(router: Router, mode: str, server1: str, server2: str) -> dict:
if mode not in DNS_MODES:
raise ValueError(f"Unknown DNS mode {mode!r}")
servers = ["", ""]
if mode == "Static":
servers[0] = validate_dns_server(value=server1 or "")
if server2:
servers[1] = validate_dns_server(value=server2)
if servers[0] == servers[1]:
raise ValueError("Primary and secondary DNS must differ")
lan = router.call(path="/cgi-bin/DAL?oid=lan")["Object"][0]
lan["DNS_Type"] = mode
lan["DNS_Servers1"], lan["DNS_Servers2"] = servers
if mode == "Static" and not servers[1]:
del lan["DNS_Servers2"]
reply = router.call(path="/cgi-bin/DAL?oid=lan", body=lan, method="PUT")
if reply.get("result") != "ZCFG_SUCCESS":
raise RuntimeError(f"Router rejected the change: {reply.get('ReplyMsg') or reply}")
logger.warning("LAN DNS set to %s %s %s", mode, servers[0], servers[1])
return read_lan_dns(router=router)
def read_log(router: Router) -> dict:
enabled = bool(router.call(path="/cgi-bin/DAL?oid=logset")["Object"][0]["Enable"])
reply = router.call(path=LOG_PATH)
entries = []
for raw in reversed(reply[0].get("result") or []):
match = LOG_LINE.match(raw.strip())
if match:
category, time_text, facility, level, message = match.groups()
entries.append({"time": time_text, "category": category, "level": level, "message": message})
else:
entries.append({"time": "", "category": "", "level": "", "message": raw})
return {"enabled": enabled, "entries": entries}
def set_logging(router: Router, enabled: bool) -> dict:
logset = router.call(path="/cgi-bin/DAL?oid=logset")["Object"][0]
logset["Enable"] = enabled
reply = router.call(path="/cgi-bin/DAL?oid=logset", body=logset, method="PUT")
if reply.get("result") != "ZCFG_SUCCESS":
raise RuntimeError(f"Router rejected the change: {reply.get('ReplyMsg') or reply}")
logger.warning("Router logging %s", "enabled" if enabled else "disabled")
return read_log(router=router)
def fetch_public_ip(timeout: float) -> str | None:
try:
with urllib.request.urlopen(PUBLIC_IP_URL, timeout=timeout) as response:
return response.read().decode().strip()
except Exception as exc:
logger.info("Could not fetch public IP: %s", exc)
return None
def find_active_pppoe(status: dict) -> dict:
candidates = [
entry
for entry in status["Object"][0]["WanLanInfo"]
if entry.get("Encapsulation") == "PPPoE" and entry.get("Status") == "Up"
]
if not candidates:
raise RuntimeError("No active PPPoE WAN session found")
return candidates[0]
def wan_ip_of(entry: dict) -> str | None:
return next(iter(entry.get("IPv4Address") or []), {}).get("IPAddress")
def wan_action(router: Router, wanppp_idx: int, action: str) -> None:
# Disconnect/Connect tear down or rebuild the PPPoE link, so the router often doesn't answer in
# time (or at all, if the link it was answering over just dropped). That doesn't mean the action
# failed: treat it as "sent, outcome unknown" and let the caller confirm by polling the real state.
try:
reply = router.call(path="/cgi-bin/DAL?oid=status", body={"action": action, "wanpppIdx": wanppp_idx}, method="PUT")
except Exception as exc:
logger.info("%s request did not get a response (%s), checking the actual state instead", action, exc)
return
if reply.get("result") not in (None, "ZCFG_SUCCESS"):
raise RuntimeError(f"Router rejected {action}: {reply}")
def find_wan_entry(status: dict, wanppp_idx: int) -> dict | None:
return next(
(e for e in status["Object"][0]["WanLanInfo"] if e.get("wanpppIdx") == wanppp_idx and e.get("Encapsulation") == "PPPoE"),
None,
)
def poll_until_up(router: Router, wanppp_idx: int, timeout: float) -> dict | None:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
try:
entry = find_wan_entry(status=router.call(path="/cgi-bin/DAL?oid=status"), wanppp_idx=wanppp_idx)
if entry and entry.get("Status") == "Up" and wan_ip_of(entry=entry):
return entry
except Exception as exc:
logger.info("Still reconnecting: %s", exc)
time.sleep(2.0)
return None
def refresh_public_ip(router: Router, wait_after_disconnect: float = 5.0, max_wait: float = 60.0, connect_retries: int = 2) -> dict:
"""Drop and re-establish the PPPoE session, so the ISP's CGNAT pool may hand out a different public IP.
The router's own auto-reconnect doesn't always kick in on its own (it can get stuck at N/A), so
a Connect is retried if the link isn't back up yet instead of leaving it down.
"""
wanppp_idx = find_active_pppoe(status=router.call(path="/cgi-bin/DAL?oid=status"))["wanpppIdx"]
wan_action(router=router, wanppp_idx=wanppp_idx, action="Disconnect")
time.sleep(wait_after_disconnect)
wan_action(router=router, wanppp_idx=wanppp_idx, action="Connect")
per_attempt = max(max_wait / (connect_retries + 1), 15.0)
for attempt in range(connect_retries + 1):
entry = poll_until_up(router=router, wanppp_idx=wanppp_idx, timeout=per_attempt)
if entry:
logger.warning("PPPoE session refreshed, new WAN IP %s", wan_ip_of(entry=entry))
return {"wan_ip": wan_ip_of(entry=entry), "public_ip": fetch_public_ip(timeout=10)}
if attempt < connect_retries:
logger.warning("Not back up yet, retrying Connect (%d/%d)...", attempt + 1, connect_retries)
wan_action(router=router, wanppp_idx=wanppp_idx, action="Connect")
raise TimeoutError(f"WAN session did not come back up within {max_wait:.0f}s, even after retrying Connect")
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
]
class Poller:
"""Refreshes each value on its own interval in a daemon thread, so requests never wait on the slow router."""
def __init__(self, router: Router) -> None:
self.state: dict[str, object] = {}
self.tasks: dict[str, tuple[Callable[[], object], float]] = {
"router_ms": (lambda: ping(host=ROUTER_HOST), 3),
"internet_ms": (lambda: ping(host=INTERNET_TARGET), 3),
"dsl": (lambda: read_dsl(router=router), 10),
"device": (lambda: read_device(router=router), 15),
"wan": (lambda: read_wan(router=router), 15),
"isp_gateway_ms": (self.ping_gateway, 15),
"hosts": (lambda: read_hosts(router=router), 30),
"dns": (lambda: read_lan_dns(router=router), 30),
"public_ip": (lambda: fetch_public_ip(timeout=10), 120),
}
def ping_gateway(self) -> float | None:
wan = self.state.get("wan")
return ping(host=wan["gateway"] if wan and wan["ip"] else None)
def loop(self, name: str, task: Callable[[], object], interval: float) -> None:
while True:
started = time.monotonic()
try:
self.state[name] = task()
except Exception as exc:
logger.warning("%s failed: %s", name, exc)
self.state[name] = None
time.sleep(max(0.5, interval - (time.monotonic() - started)))
def start(self) -> None:
for name, (task, interval) in self.tasks.items():
threading.Thread(target=self.loop, args=(name, task, interval), daemon=True).start()
def overview(self) -> dict:
state = dict(self.state)
return {
"device": state.get("device"),
"dsl": state.get("dsl"),
"wan": state.get("wan"),
"hosts": state.get("hosts"),
"dns": state.get("dns"),
"public_ip": state.get("public_ip"),
"ping": {
"router_ms": state.get("router_ms"),
"isp_gateway_ms": state.get("isp_gateway_ms"),
"internet_ms": state.get("internet_ms"),
},
}
def build_app(router: Router, poller: Poller) -> bottle.Bottle:
app = bottle.Bottle()
@app.get("/")
def index() -> str:
return PAGE
@app.get("/api/overview")
def api_overview() -> dict:
return poller.overview()
@app.get("/api/log")
def api_log() -> str:
bottle.response.content_type = "application/json"
try:
return json.dumps(read_log(router=router))
except Exception as exc:
logger.warning("log failed: %s", exc)
bottle.response.status = 502
return json.dumps({"error": str(exc)})
@app.post("/api/log")
def api_log_toggle() -> str:
bottle.response.content_type = "application/json"
enabled = (bottle.request.json or {}).get("enabled")
if not isinstance(enabled, bool):
bottle.response.status = 400
return json.dumps({"error": "enabled must be true or false"})
try:
return json.dumps(set_logging(router=router, enabled=enabled))
except Exception as exc:
logger.warning("logging toggle failed: %s", exc)
bottle.response.status = 502
return json.dumps({"error": str(exc)})
@app.post("/api/dns")
def api_dns() -> str:
bottle.response.content_type = "application/json"
payload = bottle.request.json or {}
try:
result = set_lan_dns(
router=router,
mode=payload.get("mode", ""),
server1=payload.get("server1", ""),
server2=payload.get("server2", ""),
)
except ValueError as exc:
bottle.response.status = 400
return json.dumps({"error": str(exc)})
except Exception as exc:
logger.warning("DNS change failed: %s", exc)
bottle.response.status = 502
return json.dumps({"error": str(exc)})
poller.state["dns"] = result
return json.dumps(result)
@app.post("/api/new-ip")
def api_new_ip() -> str:
bottle.response.content_type = "application/json"
try:
result = refresh_public_ip(router=router)
except Exception as exc:
logger.warning("Public IP refresh failed: %s", exc)
bottle.response.status = 502
return json.dumps({"error": str(exc)})
poller.state["public_ip"] = result["public_ip"]
return json.dumps(result)
@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 raise_system_exit(signum: int, frame: object) -> None:
raise SystemExit(0)
def main() -> None:
args = parse_args()
# pkill sends SIGTERM by default; without this it kills the process before the `with` block
# below can log out, leaking the router session until it times out on its own.
signal.signal(signal.SIGTERM, raise_system_exit)
with Router(host=ROUTER_HOST, timeout=args.timeout) as router:
poller = Poller(router=router)
poller.start()
server = make_server(
host=args.host,
port=args.port,
app=build_app(router=router, poller=poller),
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, SystemExit):
logger.info("Stopping")
finally:
server.server_close()
if __name__ == "__main__":
main()