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:
1 parent
c781ad84c4
commit
969046dec2
1 file changed
+610
-86
+610
-86
@@ -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) => ({"&": "&", "<": "<", ">": ">", '"': """, "'": "'"}[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__":
|
||||
|
||||
Reference in new issue
Block a user