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)
This commit is contained in:
abdus committed 2026-10-19 00:33:39 +03:00
1 parent c781ad84c4
commit 969046dec2
1 file changed
+610 -86
+610 -86
View File
@@ -5,12 +5,15 @@
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
@@ -27,7 +30,11 @@ 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">
@@ -62,9 +69,9 @@ main { padding: 16px; display: grid; gap: 12px; grid-template-columns: repeat(12
.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; }
.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 { font-size: 18px; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }
.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; }
@@ -77,8 +84,19 @@ button { font: inherit; padding: 8px 14px; border-radius: 8px; border: 1px solid
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: 260px; overflow: auto; font-size: 12px; white-space: pre-wrap; }
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>
@@ -103,38 +121,116 @@ pre { margin: 8px 0 0; max-height: 260px; overflow: auto; font-size: 12px; white
<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 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 wide"><h2>Connected devices</h2>
<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</summary><pre id="log">loading...</pre></details></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"];
@@ -145,6 +241,10 @@ function verdict(data) {
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`);
@@ -154,8 +254,14 @@ function trackEvents(data) {
$("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;
@@ -169,6 +275,10 @@ function drawSpark() {
$("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);
@@ -187,12 +297,13 @@ function render(data) {
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("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("DNS", wan && wan.dns ? wan.dns.split(",").join(", ") : "-"),
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";
@@ -203,28 +314,203 @@ function render(data) {
).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) return;
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 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)";
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;
$("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;
@@ -250,6 +536,7 @@ async function restartRouter() {
}, 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");
@@ -266,8 +553,10 @@ $("reboot").addEventListener("click", () => {
});
$("logbox").addEventListener("toggle", () => { if ($("logbox").open) loadLog(); });
$("logRefresh").addEventListener("click", loadLog);
$("logToggle").addEventListener("click", toggleLogging);
refresh();
setInterval(refresh, 5000);
setInterval(refresh, 3000);
</script>
</body>
</html>
@@ -301,14 +590,21 @@ class Router:
urllib.request.HTTPCookieProcessor(),
)
def request(self, path: str, body: dict | None = None) -> object:
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={"Content-Type": "application/json"},
method="POST" if body is not None else "GET",
headers=headers,
method=method,
)
with self.opener.open(request, timeout=self.timeout) as response:
raw = response.read().decode(errors="replace")
@@ -331,16 +627,16 @@ class Router:
self.session_key = reply.get("sessionkey", "")
logger.info("Logged in to router")
def call(self, path: str, body: dict | None = None) -> object:
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)
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)
return self.request(path=path, body=body, method=method)
def reboot(self) -> object:
with self.lock:
@@ -351,6 +647,25 @@ class Router:
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)
@@ -373,7 +688,8 @@ def parse_dsl(text: str) -> dict:
"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)),
"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)),
}
@@ -412,6 +728,153 @@ def read_wan(router: Router) -> dict:
}
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 [
@@ -427,56 +890,59 @@ def read_hosts(router: Router) -> list[dict]:
]
def safely(reader, router: Router) -> object:
try:
return reader(router)
except Exception as exc:
logger.warning("%s failed: %s", reader.__name__, exc)
return None
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 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 ping_gateway(self) -> float | None:
wan = self.state.get("wan")
return ping(host=wan["gateway"] if wan and wan["ip"] else None)
def worker(name: str, task: Callable[[], object]) -> None:
results[name] = task()
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)))
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 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(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),
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"),
},
}
)
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:
def build_app(router: Router, poller: Poller) -> bottle.Bottle:
app = bottle.Bottle()
@app.get("/")
@@ -485,17 +951,64 @@ def build_app(router: Router) -> bottle.Bottle:
@app.get("/api/overview")
def api_overview() -> dict:
return overview(router=router)
return poller.overview()
@app.get("/api/log")
def api_log() -> dict:
def api_log() -> str:
bottle.response.content_type = "application/json"
try:
return json.dumps(router.call(path="/cgi-bin/DAL?oid=zlog")["Object"])
return json.dumps(read_log(router=router))
except Exception as exc:
logger.warning("log failed: %s", exc)
bottle.response.status = 502
return json.dumps([])
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:
@@ -519,28 +1032,39 @@ class QuietHandler(WSGIRequestHandler):
pass
def raise_system_exit(signum: int, frame: object) -> None:
raise SystemExit(0)
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()
# 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__":