- 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)
1072 lines
43 KiB
Python
Executable File
1072 lines
43 KiB
Python
Executable File
#!/usr/bin/env -S uv run --script
|
|
# /// script
|
|
# dependencies = ["bottle"]
|
|
# ///
|
|
|
|
import argparse
|
|
import base64
|
|
import ipaddress
|
|
import json
|
|
import logging
|
|
import re
|
|
import signal
|
|
import ssl
|
|
import subprocess
|
|
import threading
|
|
import time
|
|
import urllib.error
|
|
import urllib.request
|
|
import webbrowser
|
|
from collections.abc import Callable
|
|
from socketserver import ThreadingMixIn
|
|
from wsgiref.simple_server import WSGIRequestHandler, WSGIServer, make_server
|
|
|
|
import bottle
|
|
|
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s: %(message)s")
|
|
logger = logging.getLogger(__name__)
|
|
|
|
ROUTER_HOST = "192.168.1.1"
|
|
ROUTER_USERNAME = "abdus"
|
|
ROUTER_PASSWORD = "xAsametk50"
|
|
INTERNET_TARGET = "8.8.8.8"
|
|
PUBLIC_IP_URL = "https://api.ipify.org"
|
|
UNIT_SECONDS = {"day": 86400, "hour": 3600, "min": 60, "sec": 1}
|
|
DNS_MODES = ("DNSProxy", "Static", "FromISP")
|
|
LOG_PATH = "/cgi-bin/Log?action=GET_LOG&oid=RDM_OID_LOG_CLASSIFY&iid=[1,0,0,0,0,0]"
|
|
LOG_LINE = re.compile(r"^\[(\w+)\]\s+(\w{3}\s+\d+\s+[\d:]+)\s+(\w+)\.(\w+)\s+(.*)$")
|
|
|
|
PAGE = r"""<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>Router</title>
|
|
<style>
|
|
:root {
|
|
--bg: #f6f7f9; --card: #ffffff; --text: #1b1f24; --muted: #6a737d; --line: #e3e6ea;
|
|
--ok: #1a7f37; --warn: #9a6700; --bad: #cf222e; --accent: #0969da;
|
|
}
|
|
@media (prefers-color-scheme: dark) {
|
|
:root {
|
|
--bg: #0d1117; --card: #161b22; --text: #e6edf3; --muted: #8b949e; --line: #30363d;
|
|
--ok: #3fb950; --warn: #d29922; --bad: #f85149; --accent: #58a6ff;
|
|
}
|
|
}
|
|
* { box-sizing: border-box; }
|
|
body { margin: 0; background: var(--bg); color: var(--text); font: 15px/1.45 system-ui, sans-serif; }
|
|
main { padding: 16px; display: grid; gap: 12px; grid-template-columns: repeat(12, minmax(0, 1fr)); }
|
|
.s4 { grid-column: span 4; }
|
|
.s8 { grid-column: span 8; }
|
|
.wide { grid-column: 1 / -1; }
|
|
.card.stack { display: flex; flex-direction: column; }
|
|
.card.stack .grid { align-content: start; }
|
|
@media (max-width: 1000px) { .s4, .s8 { grid-column: span 6; } }
|
|
@media (max-width: 640px) { .s4, .s8 { grid-column: 1 / -1; } }
|
|
.card { background: var(--card); border: 1px solid var(--line); border-radius: 10px; padding: 14px 16px; }
|
|
.banner { display: flex; align-items: center; gap: 12px; }
|
|
.dot { width: 14px; height: 14px; border-radius: 50%; background: var(--muted); flex: none; }
|
|
.banner h1 { margin: 0; font-size: 20px; }
|
|
.banner p { margin: 0; color: var(--muted); }
|
|
.ok .dot { background: var(--ok); } .warn .dot { background: var(--warn); } .bad .dot { background: var(--bad); }
|
|
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(175px, 1fr)); gap: 12px; }
|
|
.stat small { display: block; color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: .04em; }
|
|
.stat b { white-space: pre-line; font-size: 18px; font-variant-numeric: tabular-nums; overflow-wrap: break-word; }
|
|
.notes { color: var(--warn); margin: 0; padding-left: 34px; }
|
|
.notes:empty { display: none; }
|
|
svg { width: 100%; flex: 1; min-height: 64px; display: block; margin-top: 12px; }
|
|
h2 { margin: 0 0 8px; font-size: 14px; color: var(--muted); text-transform: uppercase; letter-spacing: .04em; }
|
|
table { width: 100%; border-collapse: collapse; font-size: 14px; }
|
|
th, td { text-align: left; padding: 5px 6px; border-bottom: 1px solid var(--line); }
|
|
th { color: var(--muted); font-weight: 500; }
|
|
.off { color: var(--muted); }
|
|
button { font: inherit; padding: 8px 14px; border-radius: 8px; border: 1px solid var(--line); background: var(--card); color: var(--text); cursor: pointer; }
|
|
button.danger { color: var(--bad); border-color: var(--bad); }
|
|
button.armed { background: var(--bad); color: #fff; }
|
|
button:disabled { opacity: .5; cursor: default; }
|
|
input, select { font: inherit; padding: 7px 10px; border-radius: 8px; border: 1px solid var(--line); background: var(--bg); color: var(--text); width: 100%; }
|
|
input:disabled { opacity: .5; }
|
|
label.field { display: block; margin-bottom: 10px; }
|
|
label.field small { display: block; color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: .04em; margin-bottom: 3px; }
|
|
.presets { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 12px; }
|
|
.presets button { padding: 4px 10px; font-size: 13px; }
|
|
button.primary { background: var(--accent); border-color: var(--accent); color: var(--bg); font-weight: 600; }
|
|
#dnsMsg, #newIpMsg { margin: 10px 0 0; min-height: 1.4em; font-size: 13px; }
|
|
#dnsMsg.error, #newIpMsg.error { color: var(--bad); } #dnsMsg.success, #newIpMsg.success { color: var(--ok); }
|
|
details summary { cursor: pointer; color: var(--muted); }
|
|
pre { margin: 8px 0 0; max-height: 320px; overflow: auto; font-size: 12px; white-space: pre-wrap; }
|
|
.logbar { margin-top: 10px; }
|
|
.logbar button { padding: 4px 10px; font-size: 13px; }
|
|
.row { display: flex; justify-content: space-between; align-items: center; gap: 12px; flex-wrap: wrap; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<main>
|
|
<section class="card banner s8" id="banner">
|
|
<span class="dot"></span>
|
|
<div><h1 id="title">Connecting...</h1><p id="subtitle"></p></div>
|
|
</section>
|
|
|
|
<section class="card row s4">
|
|
<div><b id="model">Router</b><div class="off" id="fw"></div></div>
|
|
<button id="reboot" class="danger">Restart router</button>
|
|
</section>
|
|
|
|
<ul class="card notes wide" id="notes"></ul>
|
|
|
|
<section class="card stack s4"><h2>Ping</h2>
|
|
<div class="grid" id="statsPing"></div>
|
|
<svg id="spark" viewBox="0 0 300 64" preserveAspectRatio="none"></svg>
|
|
</section>
|
|
|
|
<section class="card stack s4"><h2>Line</h2><div class="grid" id="statsLine"></div></section>
|
|
|
|
<section class="card stack s4"><h2>Connection</h2>
|
|
<div class="grid" id="statsConn"></div>
|
|
<div class="row" style="margin-top:12px"><button id="newIp">Get new public IP</button></div>
|
|
<p id="newIpMsg"></p>
|
|
</section>
|
|
|
|
<section class="card s4 stack" id="dnsCard"><h2>DNS for devices</h2>
|
|
<div class="presets">
|
|
<button type="button" data-dns="1.1.1.1,1.0.0.1">Cloudflare</button>
|
|
<button type="button" data-dns="8.8.8.8,8.8.4.4">Google</button>
|
|
<button type="button" data-dns="9.9.9.9,149.112.112.112">Quad9</button>
|
|
</div>
|
|
<label class="field"><small>Mode</small>
|
|
<select id="dnsMode">
|
|
<option value="DNSProxy">Router (proxy)</option>
|
|
<option value="Static">Custom</option>
|
|
<option value="FromISP">From ISP</option>
|
|
</select>
|
|
</label>
|
|
<label class="field"><small>Primary</small><input id="dns1" inputmode="decimal" placeholder="1.1.1.1" autocomplete="off"></label>
|
|
<label class="field"><small>Secondary (optional)</small><input id="dns2" inputmode="decimal" placeholder="1.0.0.1" autocomplete="off"></label>
|
|
<div><button id="dnsApply" class="primary">Apply</button></div>
|
|
<p id="dnsMsg"></p>
|
|
</section>
|
|
|
|
<section class="card s8"><h2>Connected devices</h2>
|
|
<table><thead><tr><th>Name</th><th>IP</th><th>Link</th><th>Signal</th></tr></thead><tbody id="hosts"></tbody></table>
|
|
</section>
|
|
|
|
<section class="card wide"><details id="logbox"><summary>Router log <span id="logState" class="off"></span></summary>
|
|
<div class="row logbar"><span id="logHint" class="off"></span>
|
|
<span><button id="logRefresh">Refresh</button> <button id="logToggle"></button></span>
|
|
</div>
|
|
<pre id="log">loading...</pre>
|
|
</details></section>
|
|
</main>
|
|
|
|
<script>
|
|
/**
|
|
* @typedef {{router_ms: ?number, isp_gateway_ms: ?number, internet_ms: ?number}} Pings
|
|
* @typedef {{state: ?string, uptime_s: ?number, down_kbps: ?number, up_kbps: ?number, snr_down_db: ?number, snr_up_db: ?number, attn_down_db: ?number, attn_up_db: ?number, crc_errors: number, fec_errors: number}} Dsl
|
|
* @typedef {{ip: ?string, gateway: ?string, dns: ?string}} Wan
|
|
* @typedef {{model: string, firmware: string, uptime_s: number}} Device
|
|
* @typedef {{name: string, ip: string, mac: string, connection: string, active: boolean, signal: ?number}} Host
|
|
* @typedef {{mode: "DNSProxy"|"Static"|"FromISP", server1: string, server2: string}} DnsSettings
|
|
* @typedef {{ping: Pings, dsl: ?Dsl, wan: ?Wan, device: ?Device, hosts: ?Host[], dns: ?DnsSettings, public_ip: ?string}} Overview
|
|
* @typedef {"ok"|"warn"|"bad"} Level
|
|
*/
|
|
|
|
/** @param {string} id @returns {HTMLElement} */
|
|
const $ = (id) => document.getElementById(id);
|
|
/** Internet ping samples (ms, null = lost), newest last. @type {(?number)[]} */
|
|
const history = [];
|
|
/** Recent events shown above the cards, newest first. @type {string[]} */
|
|
const notes = [];
|
|
/** Previous overview, used to detect changes between polls. @type {?Overview} */
|
|
let last = null;
|
|
/** True while the router is rebooting, so polling does not overwrite the countdown. */
|
|
let restarting = false;
|
|
|
|
/**
|
|
* Escape text for safe use inside innerHTML.
|
|
* @param {unknown} value
|
|
* @returns {string}
|
|
*/
|
|
function esc(value) {
|
|
return String(value ?? "").replace(/[&<>"']/g, (c) => ({"&": "&", "<": "<", ">": ">", '"': """, "'": "'"}[c]));
|
|
}
|
|
|
|
/**
|
|
* Format a number of seconds as a short duration such as "2d 3h", "5m 10s" or "42s".
|
|
* @param {?number} seconds
|
|
* @returns {string} "-" when the value is missing
|
|
*/
|
|
function duration(seconds) {
|
|
if (seconds == null) return "-";
|
|
const d = Math.floor(seconds / 86400), h = Math.floor(seconds % 86400 / 3600), m = Math.floor(seconds % 3600 / 60);
|
|
return d ? `${d}d ${h}h` : h ? `${h}h ${m}m` : m ? `${m}m ${seconds % 60}s` : `${seconds}s`;
|
|
}
|
|
|
|
/**
|
|
* @param {?number} kbps
|
|
* @returns {string} e.g. "41.2 Mbps", or "-" when the value is missing
|
|
*/
|
|
function mbps(kbps) { return kbps == null ? "-" : (kbps / 1000).toFixed(1) + " Mbps"; }
|
|
|
|
/**
|
|
* @param {?(number|string)} value
|
|
* @param {string} unit
|
|
* @returns {string} e.g. "24 ms", or "-" when the value is missing
|
|
*/
|
|
function num(value, unit) { return value == null ? "-" : value + " " + unit; }
|
|
|
|
/** @returns {string} Current local time, used to stamp events. */
|
|
function clock() { return new Date().toLocaleTimeString(); }
|
|
|
|
/**
|
|
* Set the status banner colour, replacing the previous level but keeping its other classes.
|
|
* @param {Level} level
|
|
*/
|
|
function setLevel(level) {
|
|
$("banner").classList.remove("ok", "warn", "bad");
|
|
$("banner").classList.add(level);
|
|
}
|
|
|
|
/**
|
|
* Decide which layer is failing, checking from the router outwards: router, DSL line, PPPoE session, internet.
|
|
* @param {Overview} data
|
|
* @returns {[Level, string, string]} level, headline and explanation
|
|
*/
|
|
function verdict(data) {
|
|
const { ping, dsl, wan } = data;
|
|
if (ping.router_ms == null) return ["bad", "Router unreachable", "No answer from 192.168.1.1"];
|
|
if (!dsl || dsl.state !== "Up") return ["bad", "Line down", `DSL state: ${dsl ? dsl.state : "unknown"} (line / ISP side)`];
|
|
if (!wan || !wan.ip) return ["bad", "No internet session", "DSL is up but PPPoE has no IP (ISP side)"];
|
|
if (ping.internet_ms == null) return ["bad", "Internet unreachable", "Line and PPPoE are up, 8.8.8.8 does not answer"];
|
|
if (ping.internet_ms > 150) return ["warn", "Degraded", `Slow: ${ping.internet_ms.toFixed(0)} ms to 8.8.8.8`];
|
|
return ["ok", "Online", `${ping.internet_ms.toFixed(0)} ms to 8.8.8.8`];
|
|
}
|
|
|
|
/**
|
|
* Compare with the previous overview and record DSL resyncs, router reboots, WAN IP changes and lost pings.
|
|
* @param {Overview} data
|
|
*/
|
|
function trackEvents(data) {
|
|
if (last && data.dsl && last.dsl && data.dsl.uptime_s < last.dsl.uptime_s) notes.unshift(`${clock()}: DSL line resynced`);
|
|
if (last && data.device && last.device && data.device.uptime_s < last.device.uptime_s) notes.unshift(`${clock()}: router rebooted`);
|
|
if (last && data.wan && last.wan && data.wan.ip !== last.wan.ip) notes.unshift(`${clock()}: WAN IP changed`);
|
|
if (last && last.ping.internet_ms != null && data.ping.internet_ms == null) notes.unshift(`${clock()}: internet ping lost`);
|
|
notes.length = Math.min(notes.length, 5);
|
|
$("notes").innerHTML = notes.map((n) => `<li>${esc(n)}</li>`).join("");
|
|
}
|
|
|
|
/**
|
|
* @param {string} label
|
|
* @param {unknown} value escaped before it is inserted
|
|
* @returns {string} HTML for one label and value cell
|
|
*/
|
|
function stat(label, value) { return `<div class="stat"><small>${label}</small><b>${esc(value)}</b></div>`; }
|
|
|
|
/** Draw the last 60 internet ping samples as a line, with a red tick for each lost ping. */
|
|
function drawSpark() {
|
|
const points = history.slice(-60);
|
|
const top = Math.max(50, ...points.filter((p) => p != null)) * 1.1;
|
|
const step = 300 / (Math.max(points.length, 12) - 1);
|
|
let path = "", marks = "";
|
|
points.forEach((value, index) => {
|
|
const x = index * step;
|
|
if (value == null) { marks += `<line x1="${x}" x2="${x}" y1="0" y2="64" stroke="var(--bad)" stroke-width="2" vector-effect="non-scaling-stroke"/>`; return; }
|
|
path += `${path ? "L" : "M"}${x.toFixed(1)},${(62 - value / top * 60).toFixed(1)}`;
|
|
});
|
|
$("spark").innerHTML = `<path d="${path}" fill="none" stroke="var(--accent)" stroke-width="1.5" vector-effect="non-scaling-stroke"/>${marks}`;
|
|
}
|
|
|
|
/**
|
|
* Update every card on the page from one overview.
|
|
* @param {Overview} data
|
|
*/
|
|
function render(data) {
|
|
const [level, title, subtitle] = verdict(data);
|
|
setLevel(level);
|
|
$("title").textContent = title;
|
|
$("subtitle").textContent = subtitle;
|
|
trackEvents(data);
|
|
const { dsl, wan, device, ping } = data;
|
|
const ms = (value) => num(value == null ? null : value.toFixed(0), "ms");
|
|
$("statsPing").innerHTML = [
|
|
stat("Internet (8.8.8.8)", ms(ping.internet_ms)),
|
|
stat("Router", ms(ping.router_ms)),
|
|
stat("ISP gateway", ms(ping.isp_gateway_ms)),
|
|
].join("");
|
|
$("statsLine").innerHTML = [
|
|
stat("Down sync", mbps(dsl && dsl.down_kbps)),
|
|
stat("Up sync", mbps(dsl && dsl.up_kbps)),
|
|
stat("SNR down / up", dsl ? `${dsl.snr_down_db} / ${dsl.snr_up_db} dB` : "-"),
|
|
stat("Attenuation", dsl ? `${dsl.attn_down_db} / ${dsl.attn_up_db} dB` : "-"),
|
|
stat("Errors: uncorrected / corrected", dsl ? `${dsl.crc_errors} / ${dsl.fec_errors}` : "-"),
|
|
stat("DSL uptime", duration(dsl && dsl.uptime_s)),
|
|
].join("");
|
|
$("statsConn").innerHTML = [
|
|
stat("Public IP", data.public_ip || "-"),
|
|
stat("WAN IP", wan && wan.ip || "-"),
|
|
stat("ISP DNS", wan && wan.dns ? wan.dns.split(",").join("\n") : "-"),
|
|
stat("Router uptime", duration(device && device.uptime_s)),
|
|
].join("");
|
|
$("model").textContent = device ? device.model : "Router";
|
|
$("fw").textContent = device ? "Firmware " + device.firmware : "";
|
|
const hosts = (data.hosts || []).slice().sort((a, b) => (b.active - a.active) || a.name.localeCompare(b.name));
|
|
$("hosts").innerHTML = hosts.map((h) =>
|
|
`<tr class="${h.active ? "" : "off"}"><td>${esc(h.name)}</td><td>${esc(h.ip)}</td><td>${esc(h.connection)}</td><td>${h.signal == null ? "" : esc(h.signal) + "/5"}</td></tr>`
|
|
).join("");
|
|
history.push(ping.internet_ms);
|
|
drawSpark();
|
|
renderDns(data.dns);
|
|
last = data;
|
|
}
|
|
|
|
/** True once the user edits the DNS form, so polling does not overwrite what they are typing. */
|
|
let dnsDirty = false;
|
|
|
|
/** Enable the server fields only in "Custom" mode. */
|
|
function syncDnsInputs() {
|
|
const custom = $("dnsMode").value === "Static";
|
|
$("dns1").disabled = $("dns2").disabled = !custom;
|
|
}
|
|
|
|
/**
|
|
* Fill the DNS form from the router's settings, unless the user has unsaved edits.
|
|
* @param {?DnsSettings} dns
|
|
*/
|
|
function renderDns(dns) {
|
|
if (!dns || dnsDirty) return;
|
|
$("dnsMode").value = dns.mode;
|
|
$("dns1").value = dns.server1;
|
|
$("dns2").value = dns.server2;
|
|
syncDnsInputs();
|
|
}
|
|
|
|
/**
|
|
* Show a status line under the DNS form.
|
|
* @param {string} text
|
|
* @param {"success"|"error"} [kind] omit for a neutral message
|
|
*/
|
|
function dnsMessage(text, kind) {
|
|
$("dnsMsg").textContent = text;
|
|
$("dnsMsg").className = kind || "";
|
|
}
|
|
|
|
/**
|
|
* Ask for confirmation, then send the DNS form to /api/dns and show the result.
|
|
* On success the form is refilled from the router's reply.
|
|
* @returns {Promise<void>}
|
|
*/
|
|
async function applyDns() {
|
|
const mode = $("dnsMode").value;
|
|
const summary = mode === "Static" ? [$("dns1").value, $("dns2").value].filter(Boolean).join(", ") : mode === "FromISP" ? "the ISP's servers" : "the router";
|
|
if (!confirm(`Hand out ${summary} as DNS to all devices?`)) return;
|
|
$("dnsApply").disabled = true;
|
|
dnsMessage("Applying...");
|
|
try {
|
|
const response = await fetch("/api/dns", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ mode, server1: $("dns1").value, server2: $("dns2").value }),
|
|
});
|
|
const body = await response.json();
|
|
if (!response.ok) throw new Error(body.error || response.statusText);
|
|
dnsDirty = false;
|
|
renderDns(body);
|
|
dnsMessage("Saved. Devices pick it up when they renew their lease (reconnect Wi-Fi).", "success");
|
|
} catch (error) {
|
|
dnsMessage(error.message, "error");
|
|
} finally {
|
|
$("dnsApply").disabled = false;
|
|
}
|
|
}
|
|
for (const id of ["dnsMode", "dns1", "dns2"]) {
|
|
$(id).addEventListener("input", () => { dnsDirty = true; syncDnsInputs(); });
|
|
}
|
|
for (const button of document.querySelectorAll("[data-dns]")) {
|
|
button.addEventListener("click", () => {
|
|
const [first, second] = button.dataset.dns.split(",");
|
|
$("dnsMode").value = "Static";
|
|
$("dns1").value = first;
|
|
$("dns2").value = second;
|
|
dnsDirty = true;
|
|
syncDnsInputs();
|
|
});
|
|
}
|
|
$("dnsApply").addEventListener("click", applyDns);
|
|
|
|
/**
|
|
* Show a status line under the "Get new public IP" button.
|
|
* @param {string} text
|
|
* @param {"success"|"error"} [kind]
|
|
*/
|
|
function newIpMessage(text, kind) {
|
|
$("newIpMsg").textContent = text;
|
|
$("newIpMsg").className = kind || "";
|
|
}
|
|
|
|
/**
|
|
* Drop and re-establish the PPPoE session to request a new CGNAT public IP from the ISP.
|
|
* Briefly interrupts the internet for everyone on the network; not guaranteed to change the IP.
|
|
* @returns {Promise<void>}
|
|
*/
|
|
async function requestNewIp() {
|
|
if (!confirm("This drops and reconnects the internet for everyone for about 10-30s, and the ISP may hand back the same IP. Continue?")) return;
|
|
$("newIp").disabled = true;
|
|
newIpMessage(`Reconnecting... (current: ${$("statsConn").querySelector("b")?.textContent || "unknown"})`);
|
|
try {
|
|
const response = await fetch("/api/new-ip", { method: "POST" });
|
|
const body = await response.json();
|
|
if (!response.ok) throw new Error(body.error || response.statusText);
|
|
newIpMessage(body.public_ip ? `New public IP: ${body.public_ip}` : "Reconnected, but could not confirm the new public IP.", "success");
|
|
refresh();
|
|
} catch (error) {
|
|
newIpMessage(error.message, "error");
|
|
} finally {
|
|
$("newIp").disabled = false;
|
|
}
|
|
}
|
|
$("newIp").addEventListener("click", requestNewIp);
|
|
|
|
/** True while an overview request is in flight, so slow responses do not pile up. */
|
|
let refreshing = false;
|
|
|
|
/**
|
|
* Fetch the latest overview and render it. Skipped while restarting or while a request is still pending.
|
|
* @returns {Promise<void>}
|
|
*/
|
|
async function refresh() {
|
|
if (restarting || refreshing) return;
|
|
refreshing = true;
|
|
try {
|
|
render(await (await fetch("/api/overview")).json());
|
|
} catch (error) {
|
|
$("title").textContent = "Dashboard server unreachable";
|
|
setLevel("bad");
|
|
} finally {
|
|
refreshing = false;
|
|
}
|
|
}
|
|
|
|
/** @typedef {{time: string, category: string, level: string, message: string}} LogEntry */
|
|
|
|
/** Whether the router is currently recording its log. */
|
|
let logEnabled = false;
|
|
|
|
/**
|
|
* Show the router log, newest first, and the state of the logging switch.
|
|
* @param {{enabled: boolean, entries: LogEntry[]}} log
|
|
*/
|
|
function renderLog(log) {
|
|
logEnabled = log.enabled;
|
|
$("logState").textContent = log.enabled ? "(on)" : "(off)";
|
|
$("logToggle").textContent = log.enabled ? "Turn logging off" : "Turn logging on";
|
|
$("logHint").textContent = log.enabled
|
|
? "The log lives in the router's memory and is cleared by a reboot."
|
|
: "Logging is off, so the router records nothing. Turn it on to see line drops and PPPoE reconnects.";
|
|
$("log").textContent = log.entries.length
|
|
? log.entries.map((e) => e.time ? `${e.time} ${e.level.padEnd(7)} [${e.category}] ${e.message}` : e.message).join("\n")
|
|
: log.enabled ? "(no entries yet)" : "(logging is off)";
|
|
}
|
|
|
|
/**
|
|
* Load the router log into the log panel.
|
|
* @returns {Promise<void>}
|
|
*/
|
|
async function loadLog() {
|
|
$("logRefresh").disabled = true;
|
|
try {
|
|
const response = await fetch("/api/log");
|
|
const body = await response.json();
|
|
if (!response.ok) throw new Error(body.error || response.statusText);
|
|
renderLog(body);
|
|
} catch (error) {
|
|
$("log").textContent = "could not load log: " + error.message;
|
|
} finally {
|
|
$("logRefresh").disabled = false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Switch the router's logging on or off, then show the resulting log.
|
|
* @returns {Promise<void>}
|
|
*/
|
|
async function toggleLogging() {
|
|
$("logToggle").disabled = true;
|
|
try {
|
|
const response = await fetch("/api/log", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ enabled: !logEnabled }),
|
|
});
|
|
const body = await response.json();
|
|
if (!response.ok) throw new Error(body.error || response.statusText);
|
|
renderLog(body);
|
|
} catch (error) {
|
|
$("logHint").textContent = "could not change logging: " + error.message;
|
|
} finally {
|
|
$("logToggle").disabled = false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Send the reboot request, then show a countdown and poll until the router answers again.
|
|
* Polling starts after 20 s, once the router has actually gone down.
|
|
* @returns {Promise<void>}
|
|
*/
|
|
async function restartRouter() {
|
|
restarting = true;
|
|
$("reboot").disabled = true;
|
|
setLevel("warn");
|
|
$("title").textContent = "Restarting router...";
|
|
const started = Date.now();
|
|
fetch("/api/reboot", { method: "POST" }).catch(() => {});
|
|
const timer = setInterval(async () => {
|
|
const elapsed = Math.round((Date.now() - started) / 1000);
|
|
$("subtitle").textContent = `${elapsed}s, waiting for the router to come back`;
|
|
if (elapsed < 20) return;
|
|
try {
|
|
const data = await (await fetch("/api/overview")).json();
|
|
if (data.ping.router_ms != null && data.device) {
|
|
clearInterval(timer);
|
|
restarting = false;
|
|
$("reboot").disabled = false;
|
|
last = null;
|
|
notes.unshift(`${clock()}: router restarted from dashboard`);
|
|
render(data);
|
|
}
|
|
} catch (error) {}
|
|
}, 3000);
|
|
}
|
|
|
|
/** Timer that disarms the restart button if the second click does not come. @type {?number} */
|
|
let armTimer = null;
|
|
$("reboot").addEventListener("click", () => {
|
|
const button = $("reboot");
|
|
if (!button.classList.contains("armed")) {
|
|
button.classList.add("armed");
|
|
button.textContent = "Click again to restart";
|
|
armTimer = setTimeout(() => { button.classList.remove("armed"); button.textContent = "Restart router"; }, 4000);
|
|
return;
|
|
}
|
|
clearTimeout(armTimer);
|
|
button.classList.remove("armed");
|
|
button.textContent = "Restart router";
|
|
restartRouter();
|
|
});
|
|
|
|
$("logbox").addEventListener("toggle", () => { if ($("logbox").open) loadLog(); });
|
|
$("logRefresh").addEventListener("click", loadLog);
|
|
$("logToggle").addEventListener("click", toggleLogging);
|
|
refresh();
|
|
setInterval(refresh, 3000);
|
|
</script>
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
|
|
def parse_args() -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser(
|
|
description="Local dashboard for the Zyxel router",
|
|
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
|
|
)
|
|
parser.add_argument("--host", default="127.0.0.1")
|
|
parser.add_argument("--port", type=int, default=0, help="0 picks a random free port")
|
|
parser.add_argument("--timeout", type=float, default=10.0, help="Router request timeout in seconds")
|
|
parser.add_argument("--no-browser", action="store_true", help="Do not open the page on start")
|
|
return parser.parse_args()
|
|
|
|
|
|
class Router:
|
|
def __init__(self, host: str, timeout: float) -> None:
|
|
self.base_url = f"https://{host}"
|
|
self.timeout = timeout
|
|
self.lock = threading.Lock()
|
|
self.session_key = ""
|
|
# Self-signed certificate; over HTTPS the router accepts a plain JSON login.
|
|
context = ssl.create_default_context()
|
|
context.check_hostname = False
|
|
context.verify_mode = ssl.CERT_NONE
|
|
self.opener = urllib.request.build_opener(
|
|
urllib.request.HTTPSHandler(context=context),
|
|
urllib.request.HTTPCookieProcessor(),
|
|
)
|
|
|
|
def request(self, path: str, body: dict | None = None, method: str | None = None) -> object:
|
|
method = method or ("POST" if body is not None else "GET")
|
|
separator = "&" if "?" in path else "?"
|
|
suffix = f"{separator}sessionkey={self.session_key}" if self.session_key else ""
|
|
headers = {"Content-Type": "application/json"}
|
|
if self.session_key and method != "GET":
|
|
# Since the ABSB.5 firmware, state-changing requests (POST/PUT/DELETE) are only
|
|
# accepted with the session key repeated in this header; the ?sessionkey= query
|
|
# param alone (still required for GETs) gets a bare 403 on everything else.
|
|
headers["CSRFToken"] = self.session_key
|
|
request = urllib.request.Request(
|
|
url=f"{self.base_url}{path}{suffix}",
|
|
data=json.dumps(body).encode() if body is not None else None,
|
|
headers=headers,
|
|
method=method,
|
|
)
|
|
with self.opener.open(request, timeout=self.timeout) as response:
|
|
raw = response.read().decode(errors="replace")
|
|
return json.loads(raw) if raw.strip() else {}
|
|
|
|
def login(self) -> None:
|
|
self.session_key = ""
|
|
reply = self.request(
|
|
path="/UserLogin",
|
|
body={
|
|
"Input_Account": ROUTER_USERNAME,
|
|
"Input_Passwd": base64.b64encode(ROUTER_PASSWORD.encode()).decode(),
|
|
"currLang": "en",
|
|
"RememberPassword": 0,
|
|
"SHA512_password": False,
|
|
},
|
|
)
|
|
if reply.get("result") != "ZCFG_SUCCESS":
|
|
raise RuntimeError(f"Login rejected: {reply}")
|
|
self.session_key = reply.get("sessionkey", "")
|
|
logger.info("Logged in to router")
|
|
|
|
def call(self, path: str, body: dict | None = None, method: str | None = None) -> object:
|
|
with self.lock:
|
|
if not self.session_key:
|
|
self.login()
|
|
try:
|
|
return self.request(path=path, body=body, method=method)
|
|
except (urllib.error.URLError, json.JSONDecodeError, ConnectionError):
|
|
logger.info("Router call failed, logging in again")
|
|
self.login()
|
|
return self.request(path=path, body=body, method=method)
|
|
|
|
def reboot(self) -> object:
|
|
with self.lock:
|
|
if not self.session_key:
|
|
self.login()
|
|
try:
|
|
return self.request(path="/cgi-bin/Reboot", body={})
|
|
finally:
|
|
self.session_key = ""
|
|
|
|
def logout(self) -> None:
|
|
with self.lock:
|
|
if not self.session_key:
|
|
return
|
|
try:
|
|
self.request(path="/cgi-bin/UserLogout", body={})
|
|
logger.info("Logged out, session released")
|
|
except Exception as exc:
|
|
logger.warning("Logout failed, the router may hold this session until it times out: %s", exc)
|
|
finally:
|
|
self.session_key = ""
|
|
|
|
def __enter__(self) -> "Router":
|
|
self.login()
|
|
return self
|
|
|
|
def __exit__(self, exc_type: object, exc_value: object, traceback: object) -> None:
|
|
self.logout()
|
|
|
|
|
|
def find_number(pattern: str, text: str) -> float | None:
|
|
match = re.search(pattern, text)
|
|
return float(match.group(1)) if match else None
|
|
|
|
|
|
def parse_duration(text: str) -> int:
|
|
return sum(int(number) * UNIT_SECONDS[unit] for number, unit in re.findall(r"(\d+)\s*(day|hour|min|sec)", text))
|
|
|
|
|
|
def parse_dsl(text: str) -> dict:
|
|
state = re.search(r"Line State:\s*(\S+)", text)
|
|
uptime = re.search(r"DSL uptime\s*:(.*)", text)
|
|
return {
|
|
"state": state.group(1) if state else None,
|
|
"uptime_s": parse_duration(text=uptime.group(1)) if uptime else None,
|
|
"down_kbps": find_number(pattern=r"near-end fast channel bit rate: (\d+)", text=text),
|
|
"up_kbps": find_number(pattern=r"far-end fast channel bit rate: (\d+)", text=text),
|
|
"snr_down_db": find_number(pattern=r"noise margin downstream: ([\d.]+)", text=text),
|
|
"snr_up_db": find_number(pattern=r"noise margin upstream: ([\d.]+)", text=text),
|
|
"attn_down_db": find_number(pattern=r"attenuation downstream: ([\d.]+)", text=text),
|
|
"attn_up_db": find_number(pattern=r"attenuation upstream: ([\d.]+)", text=text),
|
|
"crc_errors": sum(int(count) for count in re.findall(r"(?:CRC|HEC) error \w+: (\d+)", text)),
|
|
"fec_errors": sum(int(count) for count in re.findall(r"FEC error \w+: (\d+)", text)),
|
|
}
|
|
|
|
|
|
def ping(host: str | None) -> float | None:
|
|
if not host:
|
|
return None
|
|
result = subprocess.run(
|
|
["ping", "-c", "1", "-W", "2000", host],
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
return find_number(pattern=r"time=([\d.]+)", text=result.stdout)
|
|
|
|
|
|
def read_device(router: Router) -> dict:
|
|
info = router.call(path="/cgi-bin/DAL?oid=status")["Object"][0]["DeviceInfo"]
|
|
return {
|
|
"model": info.get("ModelName"),
|
|
"firmware": info.get("SoftwareVersion"),
|
|
"uptime_s": info.get("UpTime"),
|
|
}
|
|
|
|
|
|
def read_dsl(router: Router) -> dict:
|
|
return parse_dsl(text=router.call(path="/cgi-bin/xDSLStatistics_handle?line=0")[0]["result"])
|
|
|
|
|
|
def read_wan(router: Router) -> dict:
|
|
entries = router.call(path="/cgi-bin/DAL?oid=wan")["Object"]
|
|
vdsl = next((entry for entry in entries if entry.get("Name") == "VDSL"), {})
|
|
return {
|
|
"ip": vdsl.get("IPAddress") or None,
|
|
"gateway": vdsl.get("GatewayIPAddress") or None,
|
|
"dns": vdsl.get("DNSServer") or None,
|
|
}
|
|
|
|
|
|
def read_lan_dns(router: Router) -> dict:
|
|
lan = router.call(path="/cgi-bin/DAL?oid=lan")["Object"][0]
|
|
return {
|
|
"mode": lan.get("DNS_Type"),
|
|
"server1": lan.get("DNS_Servers1") or "",
|
|
"server2": lan.get("DNS_Servers2") or "",
|
|
}
|
|
|
|
|
|
def validate_dns_server(value: str) -> str:
|
|
try:
|
|
address = ipaddress.IPv4Address(value.strip())
|
|
except ValueError:
|
|
raise ValueError(f"{value!r} is not a valid IPv4 address") from None
|
|
if address.is_unspecified or address.is_loopback or address.is_multicast or address.is_reserved:
|
|
raise ValueError(f"{address} cannot be used as a DNS server")
|
|
return str(address)
|
|
|
|
|
|
def set_lan_dns(router: Router, mode: str, server1: str, server2: str) -> dict:
|
|
if mode not in DNS_MODES:
|
|
raise ValueError(f"Unknown DNS mode {mode!r}")
|
|
servers = ["", ""]
|
|
if mode == "Static":
|
|
servers[0] = validate_dns_server(value=server1 or "")
|
|
if server2:
|
|
servers[1] = validate_dns_server(value=server2)
|
|
if servers[0] == servers[1]:
|
|
raise ValueError("Primary and secondary DNS must differ")
|
|
|
|
lan = router.call(path="/cgi-bin/DAL?oid=lan")["Object"][0]
|
|
lan["DNS_Type"] = mode
|
|
lan["DNS_Servers1"], lan["DNS_Servers2"] = servers
|
|
if mode == "Static" and not servers[1]:
|
|
del lan["DNS_Servers2"]
|
|
reply = router.call(path="/cgi-bin/DAL?oid=lan", body=lan, method="PUT")
|
|
if reply.get("result") != "ZCFG_SUCCESS":
|
|
raise RuntimeError(f"Router rejected the change: {reply.get('ReplyMsg') or reply}")
|
|
logger.warning("LAN DNS set to %s %s %s", mode, servers[0], servers[1])
|
|
return read_lan_dns(router=router)
|
|
|
|
|
|
def read_log(router: Router) -> dict:
|
|
enabled = bool(router.call(path="/cgi-bin/DAL?oid=logset")["Object"][0]["Enable"])
|
|
reply = router.call(path=LOG_PATH)
|
|
entries = []
|
|
for raw in reversed(reply[0].get("result") or []):
|
|
match = LOG_LINE.match(raw.strip())
|
|
if match:
|
|
category, time_text, facility, level, message = match.groups()
|
|
entries.append({"time": time_text, "category": category, "level": level, "message": message})
|
|
else:
|
|
entries.append({"time": "", "category": "", "level": "", "message": raw})
|
|
return {"enabled": enabled, "entries": entries}
|
|
|
|
|
|
def set_logging(router: Router, enabled: bool) -> dict:
|
|
logset = router.call(path="/cgi-bin/DAL?oid=logset")["Object"][0]
|
|
logset["Enable"] = enabled
|
|
reply = router.call(path="/cgi-bin/DAL?oid=logset", body=logset, method="PUT")
|
|
if reply.get("result") != "ZCFG_SUCCESS":
|
|
raise RuntimeError(f"Router rejected the change: {reply.get('ReplyMsg') or reply}")
|
|
logger.warning("Router logging %s", "enabled" if enabled else "disabled")
|
|
return read_log(router=router)
|
|
|
|
|
|
def fetch_public_ip(timeout: float) -> str | None:
|
|
try:
|
|
with urllib.request.urlopen(PUBLIC_IP_URL, timeout=timeout) as response:
|
|
return response.read().decode().strip()
|
|
except Exception as exc:
|
|
logger.info("Could not fetch public IP: %s", exc)
|
|
return None
|
|
|
|
|
|
def find_active_pppoe(status: dict) -> dict:
|
|
candidates = [
|
|
entry
|
|
for entry in status["Object"][0]["WanLanInfo"]
|
|
if entry.get("Encapsulation") == "PPPoE" and entry.get("Status") == "Up"
|
|
]
|
|
if not candidates:
|
|
raise RuntimeError("No active PPPoE WAN session found")
|
|
return candidates[0]
|
|
|
|
|
|
def wan_ip_of(entry: dict) -> str | None:
|
|
return next(iter(entry.get("IPv4Address") or []), {}).get("IPAddress")
|
|
|
|
|
|
def wan_action(router: Router, wanppp_idx: int, action: str) -> None:
|
|
# Disconnect/Connect tear down or rebuild the PPPoE link, so the router often doesn't answer in
|
|
# time (or at all, if the link it was answering over just dropped). That doesn't mean the action
|
|
# failed: treat it as "sent, outcome unknown" and let the caller confirm by polling the real state.
|
|
try:
|
|
reply = router.call(path="/cgi-bin/DAL?oid=status", body={"action": action, "wanpppIdx": wanppp_idx}, method="PUT")
|
|
except Exception as exc:
|
|
logger.info("%s request did not get a response (%s), checking the actual state instead", action, exc)
|
|
return
|
|
if reply.get("result") not in (None, "ZCFG_SUCCESS"):
|
|
raise RuntimeError(f"Router rejected {action}: {reply}")
|
|
|
|
|
|
def find_wan_entry(status: dict, wanppp_idx: int) -> dict | None:
|
|
return next(
|
|
(e for e in status["Object"][0]["WanLanInfo"] if e.get("wanpppIdx") == wanppp_idx and e.get("Encapsulation") == "PPPoE"),
|
|
None,
|
|
)
|
|
|
|
|
|
def poll_until_up(router: Router, wanppp_idx: int, timeout: float) -> dict | None:
|
|
deadline = time.monotonic() + timeout
|
|
while time.monotonic() < deadline:
|
|
try:
|
|
entry = find_wan_entry(status=router.call(path="/cgi-bin/DAL?oid=status"), wanppp_idx=wanppp_idx)
|
|
if entry and entry.get("Status") == "Up" and wan_ip_of(entry=entry):
|
|
return entry
|
|
except Exception as exc:
|
|
logger.info("Still reconnecting: %s", exc)
|
|
time.sleep(2.0)
|
|
return None
|
|
|
|
|
|
def refresh_public_ip(router: Router, wait_after_disconnect: float = 5.0, max_wait: float = 60.0, connect_retries: int = 2) -> dict:
|
|
"""Drop and re-establish the PPPoE session, so the ISP's CGNAT pool may hand out a different public IP.
|
|
|
|
The router's own auto-reconnect doesn't always kick in on its own (it can get stuck at N/A), so
|
|
a Connect is retried if the link isn't back up yet instead of leaving it down.
|
|
"""
|
|
wanppp_idx = find_active_pppoe(status=router.call(path="/cgi-bin/DAL?oid=status"))["wanpppIdx"]
|
|
|
|
wan_action(router=router, wanppp_idx=wanppp_idx, action="Disconnect")
|
|
time.sleep(wait_after_disconnect)
|
|
wan_action(router=router, wanppp_idx=wanppp_idx, action="Connect")
|
|
|
|
per_attempt = max(max_wait / (connect_retries + 1), 15.0)
|
|
for attempt in range(connect_retries + 1):
|
|
entry = poll_until_up(router=router, wanppp_idx=wanppp_idx, timeout=per_attempt)
|
|
if entry:
|
|
logger.warning("PPPoE session refreshed, new WAN IP %s", wan_ip_of(entry=entry))
|
|
return {"wan_ip": wan_ip_of(entry=entry), "public_ip": fetch_public_ip(timeout=10)}
|
|
if attempt < connect_retries:
|
|
logger.warning("Not back up yet, retrying Connect (%d/%d)...", attempt + 1, connect_retries)
|
|
wan_action(router=router, wanppp_idx=wanppp_idx, action="Connect")
|
|
raise TimeoutError(f"WAN session did not come back up within {max_wait:.0f}s, even after retrying Connect")
|
|
|
|
|
|
def read_hosts(router: Router) -> list[dict]:
|
|
hosts = router.call(path="/cgi-bin/DAL?oid=lanhosts")["Object"][0]["lanhosts"]
|
|
return [
|
|
{
|
|
"name": host.get("HostName") or host.get("DeviceName") or "unknown",
|
|
"ip": host.get("IPAddress"),
|
|
"mac": host.get("PhysAddress"),
|
|
"connection": host.get("X_ZYXEL_ConnectionType"),
|
|
"active": bool(host.get("Active")),
|
|
"signal": host.get("X_ZYXEL_SignalStrength"),
|
|
}
|
|
for host in hosts
|
|
]
|
|
|
|
|
|
class Poller:
|
|
"""Refreshes each value on its own interval in a daemon thread, so requests never wait on the slow router."""
|
|
|
|
def __init__(self, router: Router) -> None:
|
|
self.state: dict[str, object] = {}
|
|
self.tasks: dict[str, tuple[Callable[[], object], float]] = {
|
|
"router_ms": (lambda: ping(host=ROUTER_HOST), 3),
|
|
"internet_ms": (lambda: ping(host=INTERNET_TARGET), 3),
|
|
"dsl": (lambda: read_dsl(router=router), 10),
|
|
"device": (lambda: read_device(router=router), 15),
|
|
"wan": (lambda: read_wan(router=router), 15),
|
|
"isp_gateway_ms": (self.ping_gateway, 15),
|
|
"hosts": (lambda: read_hosts(router=router), 30),
|
|
"dns": (lambda: read_lan_dns(router=router), 30),
|
|
"public_ip": (lambda: fetch_public_ip(timeout=10), 120),
|
|
}
|
|
|
|
def ping_gateway(self) -> float | None:
|
|
wan = self.state.get("wan")
|
|
return ping(host=wan["gateway"] if wan and wan["ip"] else None)
|
|
|
|
def loop(self, name: str, task: Callable[[], object], interval: float) -> None:
|
|
while True:
|
|
started = time.monotonic()
|
|
try:
|
|
self.state[name] = task()
|
|
except Exception as exc:
|
|
logger.warning("%s failed: %s", name, exc)
|
|
self.state[name] = None
|
|
time.sleep(max(0.5, interval - (time.monotonic() - started)))
|
|
|
|
def start(self) -> None:
|
|
for name, (task, interval) in self.tasks.items():
|
|
threading.Thread(target=self.loop, args=(name, task, interval), daemon=True).start()
|
|
|
|
def overview(self) -> dict:
|
|
state = dict(self.state)
|
|
return {
|
|
"device": state.get("device"),
|
|
"dsl": state.get("dsl"),
|
|
"wan": state.get("wan"),
|
|
"hosts": state.get("hosts"),
|
|
"dns": state.get("dns"),
|
|
"public_ip": state.get("public_ip"),
|
|
"ping": {
|
|
"router_ms": state.get("router_ms"),
|
|
"isp_gateway_ms": state.get("isp_gateway_ms"),
|
|
"internet_ms": state.get("internet_ms"),
|
|
},
|
|
}
|
|
|
|
|
|
def build_app(router: Router, poller: Poller) -> bottle.Bottle:
|
|
app = bottle.Bottle()
|
|
|
|
@app.get("/")
|
|
def index() -> str:
|
|
return PAGE
|
|
|
|
@app.get("/api/overview")
|
|
def api_overview() -> dict:
|
|
return poller.overview()
|
|
|
|
@app.get("/api/log")
|
|
def api_log() -> str:
|
|
bottle.response.content_type = "application/json"
|
|
try:
|
|
return json.dumps(read_log(router=router))
|
|
except Exception as exc:
|
|
logger.warning("log failed: %s", exc)
|
|
bottle.response.status = 502
|
|
return json.dumps({"error": str(exc)})
|
|
|
|
@app.post("/api/log")
|
|
def api_log_toggle() -> str:
|
|
bottle.response.content_type = "application/json"
|
|
enabled = (bottle.request.json or {}).get("enabled")
|
|
if not isinstance(enabled, bool):
|
|
bottle.response.status = 400
|
|
return json.dumps({"error": "enabled must be true or false"})
|
|
try:
|
|
return json.dumps(set_logging(router=router, enabled=enabled))
|
|
except Exception as exc:
|
|
logger.warning("logging toggle failed: %s", exc)
|
|
bottle.response.status = 502
|
|
return json.dumps({"error": str(exc)})
|
|
|
|
@app.post("/api/dns")
|
|
def api_dns() -> str:
|
|
bottle.response.content_type = "application/json"
|
|
payload = bottle.request.json or {}
|
|
try:
|
|
result = set_lan_dns(
|
|
router=router,
|
|
mode=payload.get("mode", ""),
|
|
server1=payload.get("server1", ""),
|
|
server2=payload.get("server2", ""),
|
|
)
|
|
except ValueError as exc:
|
|
bottle.response.status = 400
|
|
return json.dumps({"error": str(exc)})
|
|
except Exception as exc:
|
|
logger.warning("DNS change failed: %s", exc)
|
|
bottle.response.status = 502
|
|
return json.dumps({"error": str(exc)})
|
|
poller.state["dns"] = result
|
|
return json.dumps(result)
|
|
|
|
@app.post("/api/new-ip")
|
|
def api_new_ip() -> str:
|
|
bottle.response.content_type = "application/json"
|
|
try:
|
|
result = refresh_public_ip(router=router)
|
|
except Exception as exc:
|
|
logger.warning("Public IP refresh failed: %s", exc)
|
|
bottle.response.status = 502
|
|
return json.dumps({"error": str(exc)})
|
|
poller.state["public_ip"] = result["public_ip"]
|
|
return json.dumps(result)
|
|
|
|
@app.post("/api/reboot")
|
|
def api_reboot() -> dict:
|
|
logger.warning("Rebooting router on request")
|
|
try:
|
|
router.reboot()
|
|
except Exception as exc:
|
|
# The router may drop the connection while it goes down.
|
|
logger.info("Reboot call ended with: %s", exc)
|
|
return {"ok": True}
|
|
|
|
return app
|
|
|
|
|
|
class ThreadingServer(ThreadingMixIn, WSGIServer):
|
|
daemon_threads = True
|
|
|
|
|
|
class QuietHandler(WSGIRequestHandler):
|
|
def log_message(self, format: str, *args: object) -> None:
|
|
pass
|
|
|
|
|
|
def raise_system_exit(signum: int, frame: object) -> None:
|
|
raise SystemExit(0)
|
|
|
|
|
|
def main() -> None:
|
|
args = parse_args()
|
|
|
|
# pkill sends SIGTERM by default; without this it kills the process before the `with` block
|
|
# below can log out, leaking the router session until it times out on its own.
|
|
signal.signal(signal.SIGTERM, raise_system_exit)
|
|
|
|
with Router(host=ROUTER_HOST, timeout=args.timeout) as router:
|
|
poller = Poller(router=router)
|
|
poller.start()
|
|
server = make_server(
|
|
host=args.host,
|
|
port=args.port,
|
|
app=build_app(router=router, poller=poller),
|
|
server_class=ThreadingServer,
|
|
handler_class=QuietHandler,
|
|
)
|
|
url = f"http://{args.host}:{server.server_port}"
|
|
logger.info("Serving %s", url)
|
|
if not args.no_browser:
|
|
opener = threading.Timer(interval=0.5, function=webbrowser.open, args=[url])
|
|
opener.daemon = True
|
|
opener.start()
|
|
try:
|
|
server.serve_forever(poll_interval=0.2)
|
|
except (KeyboardInterrupt, SystemExit):
|
|
logger.info("Stopping")
|
|
finally:
|
|
server.server_close()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|