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
+593
-69
+593
-69
@@ -5,12 +5,15 @@
|
|||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import base64
|
import base64
|
||||||
|
import ipaddress
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
|
import signal
|
||||||
import ssl
|
import ssl
|
||||||
import subprocess
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
|
import time
|
||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.request
|
import urllib.request
|
||||||
import webbrowser
|
import webbrowser
|
||||||
@@ -27,7 +30,11 @@ ROUTER_HOST = "192.168.1.1"
|
|||||||
ROUTER_USERNAME = "abdus"
|
ROUTER_USERNAME = "abdus"
|
||||||
ROUTER_PASSWORD = "xAsametk50"
|
ROUTER_PASSWORD = "xAsametk50"
|
||||||
INTERNET_TARGET = "8.8.8.8"
|
INTERNET_TARGET = "8.8.8.8"
|
||||||
|
PUBLIC_IP_URL = "https://api.ipify.org"
|
||||||
UNIT_SECONDS = {"day": 86400, "hour": 3600, "min": 60, "sec": 1}
|
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>
|
PAGE = r"""<!doctype html>
|
||||||
<html lang="en">
|
<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 h1 { margin: 0; font-size: 20px; }
|
||||||
.banner p { margin: 0; color: var(--muted); }
|
.banner p { margin: 0; color: var(--muted); }
|
||||||
.ok .dot { background: var(--ok); } .warn .dot { background: var(--warn); } .bad .dot { background: var(--bad); }
|
.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 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 { color: var(--warn); margin: 0; padding-left: 34px; }
|
||||||
.notes:empty { display: none; }
|
.notes:empty { display: none; }
|
||||||
svg { width: 100%; flex: 1; min-height: 64px; display: block; margin-top: 12px; }
|
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.danger { color: var(--bad); border-color: var(--bad); }
|
||||||
button.armed { background: var(--bad); color: #fff; }
|
button.armed { background: var(--bad); color: #fff; }
|
||||||
button:disabled { opacity: .5; cursor: default; }
|
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); }
|
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; }
|
.row { display: flex; justify-content: space-between; align-items: center; gap: 12px; flex-wrap: wrap; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</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>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>
|
<table><thead><tr><th>Name</th><th>IP</th><th>Link</th><th>Signal</th></tr></thead><tbody id="hosts"></tbody></table>
|
||||||
</section>
|
</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>
|
</main>
|
||||||
|
|
||||||
<script>
|
<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);
|
const $ = (id) => document.getElementById(id);
|
||||||
|
/** Internet ping samples (ms, null = lost), newest last. @type {(?number)[]} */
|
||||||
const history = [];
|
const history = [];
|
||||||
|
/** Recent events shown above the cards, newest first. @type {string[]} */
|
||||||
const notes = [];
|
const notes = [];
|
||||||
|
/** Previous overview, used to detect changes between polls. @type {?Overview} */
|
||||||
let last = null;
|
let last = null;
|
||||||
|
/** True while the router is rebooting, so polling does not overwrite the countdown. */
|
||||||
let restarting = false;
|
let restarting = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escape text for safe use inside innerHTML.
|
||||||
|
* @param {unknown} value
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
function esc(value) {
|
function esc(value) {
|
||||||
return String(value ?? "").replace(/[&<>"']/g, (c) => ({"&": "&", "<": "<", ">": ">", '"': """, "'": "'"}[c]));
|
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) {
|
function duration(seconds) {
|
||||||
if (seconds == null) return "-";
|
if (seconds == null) return "-";
|
||||||
const d = Math.floor(seconds / 86400), h = Math.floor(seconds % 86400 / 3600), m = Math.floor(seconds % 3600 / 60);
|
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`;
|
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"; }
|
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; }
|
function num(value, unit) { return value == null ? "-" : value + " " + unit; }
|
||||||
|
|
||||||
|
/** @returns {string} Current local time, used to stamp events. */
|
||||||
function clock() { return new Date().toLocaleTimeString(); }
|
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) {
|
function setLevel(level) {
|
||||||
$("banner").classList.remove("ok", "warn", "bad");
|
$("banner").classList.remove("ok", "warn", "bad");
|
||||||
$("banner").classList.add(level);
|
$("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) {
|
function verdict(data) {
|
||||||
const { ping, dsl, wan } = data;
|
const { ping, dsl, wan } = data;
|
||||||
if (ping.router_ms == null) return ["bad", "Router unreachable", "No answer from 192.168.1.1"];
|
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`];
|
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) {
|
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.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.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("");
|
$("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>`; }
|
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() {
|
function drawSpark() {
|
||||||
const points = history.slice(-60);
|
const points = history.slice(-60);
|
||||||
const top = Math.max(50, ...points.filter((p) => p != null)) * 1.1;
|
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}`;
|
$("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) {
|
function render(data) {
|
||||||
const [level, title, subtitle] = verdict(data);
|
const [level, title, subtitle] = verdict(data);
|
||||||
setLevel(level);
|
setLevel(level);
|
||||||
@@ -187,12 +297,13 @@ function render(data) {
|
|||||||
stat("Up sync", mbps(dsl && dsl.up_kbps)),
|
stat("Up sync", mbps(dsl && dsl.up_kbps)),
|
||||||
stat("SNR down / up", dsl ? `${dsl.snr_down_db} / ${dsl.snr_up_db} dB` : "-"),
|
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("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)),
|
stat("DSL uptime", duration(dsl && dsl.uptime_s)),
|
||||||
].join("");
|
].join("");
|
||||||
$("statsConn").innerHTML = [
|
$("statsConn").innerHTML = [
|
||||||
|
stat("Public IP", data.public_ip || "-"),
|
||||||
stat("WAN IP", wan && wan.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)),
|
stat("Router uptime", duration(device && device.uptime_s)),
|
||||||
].join("");
|
].join("");
|
||||||
$("model").textContent = device ? device.model : "Router";
|
$("model").textContent = device ? device.model : "Router";
|
||||||
@@ -203,28 +314,203 @@ function render(data) {
|
|||||||
).join("");
|
).join("");
|
||||||
history.push(ping.internet_ms);
|
history.push(ping.internet_ms);
|
||||||
drawSpark();
|
drawSpark();
|
||||||
|
renderDns(data.dns);
|
||||||
last = data;
|
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() {
|
async function refresh() {
|
||||||
if (restarting) return;
|
if (restarting || refreshing) return;
|
||||||
|
refreshing = true;
|
||||||
try {
|
try {
|
||||||
render(await (await fetch("/api/overview")).json());
|
render(await (await fetch("/api/overview")).json());
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
$("title").textContent = "Dashboard server unreachable";
|
$("title").textContent = "Dashboard server unreachable";
|
||||||
setLevel("bad");
|
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() {
|
async function loadLog() {
|
||||||
|
$("logRefresh").disabled = true;
|
||||||
try {
|
try {
|
||||||
const rows = await (await fetch("/api/log")).json();
|
const response = await fetch("/api/log");
|
||||||
$("log").textContent = rows.length ? rows.map((r) => typeof r === "string" ? r : Object.values(r).join(" ")).join("\n") : "(empty)";
|
const body = await response.json();
|
||||||
|
if (!response.ok) throw new Error(body.error || response.statusText);
|
||||||
|
renderLog(body);
|
||||||
} catch (error) {
|
} 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() {
|
async function restartRouter() {
|
||||||
restarting = true;
|
restarting = true;
|
||||||
$("reboot").disabled = true;
|
$("reboot").disabled = true;
|
||||||
@@ -250,6 +536,7 @@ async function restartRouter() {
|
|||||||
}, 3000);
|
}, 3000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Timer that disarms the restart button if the second click does not come. @type {?number} */
|
||||||
let armTimer = null;
|
let armTimer = null;
|
||||||
$("reboot").addEventListener("click", () => {
|
$("reboot").addEventListener("click", () => {
|
||||||
const button = $("reboot");
|
const button = $("reboot");
|
||||||
@@ -266,8 +553,10 @@ $("reboot").addEventListener("click", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
$("logbox").addEventListener("toggle", () => { if ($("logbox").open) loadLog(); });
|
$("logbox").addEventListener("toggle", () => { if ($("logbox").open) loadLog(); });
|
||||||
|
$("logRefresh").addEventListener("click", loadLog);
|
||||||
|
$("logToggle").addEventListener("click", toggleLogging);
|
||||||
refresh();
|
refresh();
|
||||||
setInterval(refresh, 5000);
|
setInterval(refresh, 3000);
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -301,14 +590,21 @@ class Router:
|
|||||||
urllib.request.HTTPCookieProcessor(),
|
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 "?"
|
separator = "&" if "?" in path else "?"
|
||||||
suffix = f"{separator}sessionkey={self.session_key}" if self.session_key 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(
|
request = urllib.request.Request(
|
||||||
url=f"{self.base_url}{path}{suffix}",
|
url=f"{self.base_url}{path}{suffix}",
|
||||||
data=json.dumps(body).encode() if body is not None else None,
|
data=json.dumps(body).encode() if body is not None else None,
|
||||||
headers={"Content-Type": "application/json"},
|
headers=headers,
|
||||||
method="POST" if body is not None else "GET",
|
method=method,
|
||||||
)
|
)
|
||||||
with self.opener.open(request, timeout=self.timeout) as response:
|
with self.opener.open(request, timeout=self.timeout) as response:
|
||||||
raw = response.read().decode(errors="replace")
|
raw = response.read().decode(errors="replace")
|
||||||
@@ -331,16 +627,16 @@ class Router:
|
|||||||
self.session_key = reply.get("sessionkey", "")
|
self.session_key = reply.get("sessionkey", "")
|
||||||
logger.info("Logged in to router")
|
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:
|
with self.lock:
|
||||||
if not self.session_key:
|
if not self.session_key:
|
||||||
self.login()
|
self.login()
|
||||||
try:
|
try:
|
||||||
return self.request(path=path, body=body)
|
return self.request(path=path, body=body, method=method)
|
||||||
except (urllib.error.URLError, json.JSONDecodeError, ConnectionError):
|
except (urllib.error.URLError, json.JSONDecodeError, ConnectionError):
|
||||||
logger.info("Router call failed, logging in again")
|
logger.info("Router call failed, logging in again")
|
||||||
self.login()
|
self.login()
|
||||||
return self.request(path=path, body=body)
|
return self.request(path=path, body=body, method=method)
|
||||||
|
|
||||||
def reboot(self) -> object:
|
def reboot(self) -> object:
|
||||||
with self.lock:
|
with self.lock:
|
||||||
@@ -351,6 +647,25 @@ class Router:
|
|||||||
finally:
|
finally:
|
||||||
self.session_key = ""
|
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:
|
def find_number(pattern: str, text: str) -> float | None:
|
||||||
match = re.search(pattern, text)
|
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),
|
"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_down_db": find_number(pattern=r"attenuation downstream: ([\d.]+)", text=text),
|
||||||
"attn_up_db": find_number(pattern=r"attenuation upstream: ([\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]:
|
def read_hosts(router: Router) -> list[dict]:
|
||||||
hosts = router.call(path="/cgi-bin/DAL?oid=lanhosts")["Object"][0]["lanhosts"]
|
hosts = router.call(path="/cgi-bin/DAL?oid=lanhosts")["Object"][0]["lanhosts"]
|
||||||
return [
|
return [
|
||||||
@@ -427,56 +890,59 @@ def read_hosts(router: Router) -> list[dict]:
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def safely(reader, router: Router) -> object:
|
class Poller:
|
||||||
try:
|
"""Refreshes each value on its own interval in a daemon thread, so requests never wait on the slow router."""
|
||||||
return reader(router)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning("%s failed: %s", reader.__name__, exc)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
def __init__(self, router: Router) -> None:
|
||||||
def run_parallel(tasks: dict[str, Callable[[], object]]) -> dict[str, object]:
|
self.state: dict[str, object] = {}
|
||||||
# Daemon threads, so Ctrl+C does not wait for slow router calls or pings.
|
self.tasks: dict[str, tuple[Callable[[], object], float]] = {
|
||||||
results: dict[str, object] = {}
|
"router_ms": (lambda: ping(host=ROUTER_HOST), 3),
|
||||||
|
"internet_ms": (lambda: ping(host=INTERNET_TARGET), 3),
|
||||||
def worker(name: str, task: Callable[[], object]) -> None:
|
"dsl": (lambda: read_dsl(router=router), 10),
|
||||||
results[name] = task()
|
"device": (lambda: read_device(router=router), 15),
|
||||||
|
"wan": (lambda: read_wan(router=router), 15),
|
||||||
threads = [threading.Thread(target=worker, args=(name, task), daemon=True) for name, task in tasks.items()]
|
"isp_gateway_ms": (self.ping_gateway, 15),
|
||||||
for thread in threads:
|
"hosts": (lambda: read_hosts(router=router), 30),
|
||||||
thread.start()
|
"dns": (lambda: read_lan_dns(router=router), 30),
|
||||||
for thread in threads:
|
"public_ip": (lambda: fetch_public_ip(timeout=10), 120),
|
||||||
thread.join()
|
|
||||||
return results
|
|
||||||
|
|
||||||
|
|
||||||
def overview(router: Router) -> dict:
|
|
||||||
results = run_parallel(
|
|
||||||
tasks={
|
|
||||||
"device": lambda: safely(read_device, router),
|
|
||||||
"dsl": lambda: safely(read_dsl, router),
|
|
||||||
"wan": lambda: safely(read_wan, router),
|
|
||||||
"hosts": lambda: safely(read_hosts, router),
|
|
||||||
"router_ms": lambda: ping(host=ROUTER_HOST),
|
|
||||||
"internet_ms": lambda: ping(host=INTERNET_TARGET),
|
|
||||||
}
|
}
|
||||||
)
|
|
||||||
wan = results["wan"]
|
def ping_gateway(self) -> float | None:
|
||||||
gateway = wan["gateway"] if wan and wan["ip"] else 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 {
|
return {
|
||||||
"device": results["device"],
|
"device": state.get("device"),
|
||||||
"dsl": results["dsl"],
|
"dsl": state.get("dsl"),
|
||||||
"wan": wan,
|
"wan": state.get("wan"),
|
||||||
"hosts": results["hosts"],
|
"hosts": state.get("hosts"),
|
||||||
|
"dns": state.get("dns"),
|
||||||
|
"public_ip": state.get("public_ip"),
|
||||||
"ping": {
|
"ping": {
|
||||||
"router_ms": results["router_ms"],
|
"router_ms": state.get("router_ms"),
|
||||||
"isp_gateway_ms": ping(host=gateway),
|
"isp_gateway_ms": state.get("isp_gateway_ms"),
|
||||||
"internet_ms": results["internet_ms"],
|
"internet_ms": state.get("internet_ms"),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def build_app(router: Router) -> bottle.Bottle:
|
def build_app(router: Router, poller: Poller) -> bottle.Bottle:
|
||||||
app = bottle.Bottle()
|
app = bottle.Bottle()
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
@@ -485,17 +951,64 @@ def build_app(router: Router) -> bottle.Bottle:
|
|||||||
|
|
||||||
@app.get("/api/overview")
|
@app.get("/api/overview")
|
||||||
def api_overview() -> dict:
|
def api_overview() -> dict:
|
||||||
return overview(router=router)
|
return poller.overview()
|
||||||
|
|
||||||
@app.get("/api/log")
|
@app.get("/api/log")
|
||||||
def api_log() -> dict:
|
def api_log() -> str:
|
||||||
bottle.response.content_type = "application/json"
|
bottle.response.content_type = "application/json"
|
||||||
try:
|
try:
|
||||||
return json.dumps(router.call(path="/cgi-bin/DAL?oid=zlog")["Object"])
|
return json.dumps(read_log(router=router))
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("log failed: %s", exc)
|
logger.warning("log failed: %s", exc)
|
||||||
bottle.response.status = 502
|
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")
|
@app.post("/api/reboot")
|
||||||
def api_reboot() -> dict:
|
def api_reboot() -> dict:
|
||||||
@@ -519,13 +1032,24 @@ class QuietHandler(WSGIRequestHandler):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def raise_system_exit(signum: int, frame: object) -> None:
|
||||||
|
raise SystemExit(0)
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
args = parse_args()
|
args = parse_args()
|
||||||
router = Router(host=ROUTER_HOST, timeout=args.timeout)
|
|
||||||
|
# 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(
|
server = make_server(
|
||||||
host=args.host,
|
host=args.host,
|
||||||
port=args.port,
|
port=args.port,
|
||||||
app=build_app(router=router),
|
app=build_app(router=router, poller=poller),
|
||||||
server_class=ThreadingServer,
|
server_class=ThreadingServer,
|
||||||
handler_class=QuietHandler,
|
handler_class=QuietHandler,
|
||||||
)
|
)
|
||||||
@@ -537,7 +1061,7 @@ def main() -> None:
|
|||||||
opener.start()
|
opener.start()
|
||||||
try:
|
try:
|
||||||
server.serve_forever(poll_interval=0.2)
|
server.serve_forever(poll_interval=0.2)
|
||||||
except KeyboardInterrupt:
|
except (KeyboardInterrupt, SystemExit):
|
||||||
logger.info("Stopping")
|
logger.info("Stopping")
|
||||||
finally:
|
finally:
|
||||||
server.server_close()
|
server.server_close()
|
||||||
|
|||||||
Reference in new issue
Block a user