Add network diagnostics page with MTR backend and speed test

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Ivan Razin
2026-06-24 10:57:16 +03:00
co-authored by Claude Sonnet 4.6
parent 411490ab82
commit e2a6505909
3 changed files with 776 additions and 2 deletions
+364
View File
@@ -0,0 +1,364 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Network Diagnostics</title>
<meta name="robots" content="noindex, nofollow">
<style>
:root {
--bg: #0f1117;
--surface: #1a1d27;
--border: #2d3148;
--accent: #4f86f7;
--accent2: #2ecc71;
--warn: #e67e22;
--error: #e74c3c;
--text: #cdd6f4;
--text-dim: #6c7086;
--radius: 8px;
--mono: 'SFMono-Regular', 'Consolas', 'Liberation Mono', monospace;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
min-height: 100vh;
padding: 24px 16px;
}
.container { max-width: 900px; margin: 0 auto; }
h1 { font-size: 1.6rem; font-weight: 700; margin-bottom: 6px; color: #fff; }
.subtitle { color: var(--text-dim); font-size: 0.9rem; margin-bottom: 32px; }
.section {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 24px;
margin-bottom: 24px;
}
.section-title {
font-size: 1rem;
font-weight: 600;
margin-bottom: 16px;
display: flex;
align-items: center;
gap: 8px;
color: #fff;
}
.section-title::before {
content: '';
display: block;
width: 4px; height: 18px;
background: var(--accent);
border-radius: 2px;
}
/* Speed test embed */
#speedtest-frame {
width: 100%;
height: 520px;
border: none;
border-radius: var(--radius);
background: #000;
}
/* MTR form */
.mtr-form { display: flex; gap: 12px; align-items: flex-end; flex-wrap: wrap; }
.form-group { display: flex; flex-direction: column; gap: 6px; }
.form-group label { font-size: 0.82rem; color: var(--text-dim); }
.form-group input[type="number"] {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text);
padding: 8px 12px;
font-size: 0.95rem;
width: 120px;
}
.form-group input:focus { outline: none; border-color: var(--accent); }
.btn {
background: var(--accent);
border: none;
border-radius: 6px;
color: #fff;
cursor: pointer;
font-size: 0.95rem;
font-weight: 600;
padding: 10px 20px;
transition: opacity 0.15s;
}
.btn:hover { opacity: 0.85; }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
#mtr-result {
margin-top: 16px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
font-family: var(--mono);
font-size: 0.82rem;
line-height: 1.5;
padding: 14px 16px;
white-space: pre;
overflow-x: auto;
min-height: 48px;
color: var(--text);
display: none;
}
.status-tag {
display: inline-block;
border-radius: 4px;
font-size: 0.78rem;
font-weight: 600;
padding: 2px 8px;
}
.tag-info { background: rgba(79,134,247,0.2); color: var(--accent); }
.tag-ok { background: rgba(46,204,113,0.2); color: var(--accent2); }
.tag-warn { background: rgba(230,126,34,0.2); color: var(--warn); }
.tag-error { background: rgba(231,76,60,0.2); color: var(--error); }
#mtr-status { margin-top: 10px; font-size: 0.85rem; }
#client-ip { font-family: var(--mono); font-weight: 600; color: var(--accent2); }
/* Download test files */
.file-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; }
.file-card {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
padding: 14px 16px;
text-decoration: none;
color: var(--text);
transition: border-color 0.15s;
display: flex;
flex-direction: column;
gap: 4px;
}
.file-card:hover { border-color: var(--accent); }
.file-size { font-size: 1.3rem; font-weight: 700; color: #fff; }
.file-name { font-size: 0.8rem; color: var(--text-dim); font-family: var(--mono); }
/* WinMTR instructions */
.instruction-steps { list-style: none; counter-reset: steps; }
.instruction-steps li {
counter-increment: steps;
padding: 8px 0 8px 40px;
position: relative;
border-bottom: 1px solid var(--border);
font-size: 0.9rem;
}
.instruction-steps li:last-child { border-bottom: none; }
.instruction-steps li::before {
content: counter(steps);
position: absolute;
left: 0;
background: var(--accent);
border-radius: 50%;
width: 24px; height: 24px;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.75rem;
font-weight: 700;
color: #fff;
top: 8px;
}
.code-inline {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 4px;
font-family: var(--mono);
font-size: 0.85rem;
padding: 2px 6px;
}
#server-ip { font-family: var(--mono); color: var(--accent); }
a.link { color: var(--accent); text-decoration: none; }
a.link:hover { text-decoration: underline; }
.spinner { display: none; }
.spinner.active {
display: inline-block;
width: 14px; height: 14px;
border: 2px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
vertical-align: middle;
margin-left: 8px;
}
@keyframes spin { to { transform: rotate(360deg); } }
</style>
</head>
<body>
<div class="container">
<h1>Network Diagnostics</h1>
<p class="subtitle">Server diagnostics panel — <span class="status-tag tag-info">Private</span></p>
<!-- ── Speed Test ──────────────────────────────────────────────────── -->
<div class="section">
<div class="section-title">Speed Test</div>
<p style="font-size:0.85rem;color:var(--text-dim);margin-bottom:12px;">
Runs directly between your browser and this server. Served locally — no external connections.
</p>
<iframe id="speedtest-frame"
src="__DIAG_PATH__speedtest/"
allow="fullscreen"
loading="lazy"
title="OpenSpeedTest">
</iframe>
</div>
<!-- ── MTR Test ───────────────────────────────────────────────────── -->
<div class="section">
<div class="section-title">MTR Network Path Test</div>
<p style="font-size:0.85rem;color:var(--text-dim);margin-bottom:16px;">
Runs <code class="code-inline">mtr</code> from this server to your IP address
(<span id="client-ip">detecting…</span>). Shows hop-by-hop latency and packet loss.
</p>
<div class="mtr-form">
<div class="form-group">
<label for="pkt-count">Packet count (120)</label>
<input type="number" id="pkt-count" value="5" min="1" max="20">
</div>
<button class="btn" id="mtr-btn" onclick="runMtr()">Run MTR</button>
<span class="spinner" id="mtr-spinner"></span>
</div>
<div id="mtr-status"></div>
<pre id="mtr-result"></pre>
</div>
<!-- ── Download Test Files ────────────────────────────────────────── -->
<div class="section">
<div class="section-title">Download Test Files</div>
<p style="font-size:0.85rem;color:var(--text-dim);margin-bottom:16px;">
Static binary files for bandwidth testing. Generated from <code class="code-inline">/dev/zero</code>.
</p>
<div class="file-grid">
<a class="file-card" href="__DIAG_PATH__testfiles/test-15k.bin" download>
<span class="file-size">15 KB</span>
<span class="file-name">test-15k.bin</span>
</a>
<a class="file-card" href="__DIAG_PATH__testfiles/test-17k.bin" download>
<span class="file-size">17 KB</span>
<span class="file-name">test-17k.bin</span>
</a>
<a class="file-card" href="__DIAG_PATH__testfiles/test-100m.bin" download>
<span class="file-size">100 MB</span>
<span class="file-name">test-100m.bin</span>
</a>
<a class="file-card" href="__DIAG_PATH__testfiles/test-1g.bin" download>
<span class="file-size">1 GB</span>
<span class="file-name">test-1g.bin</span>
</a>
</div>
</div>
<!-- ── WinMTR Instructions ────────────────────────────────────────── -->
<div class="section">
<div class="section-title">Run MTR from Windows (WinMTR)</div>
<p style="font-size:0.85rem;color:var(--text-dim);margin-bottom:16px;">
Test the network path from <em>your PC</em> to this server at
<code class="code-inline" id="server-display">__SERVER_DOMAIN__</code>
(IP: <span id="server-ip">__SERVER_IP__</span>).
</p>
<ol class="instruction-steps">
<li>
Download WinMTR from the official site:
<a class="link" href="https://github.com/White-Tiger/WinMTR/releases" target="_blank" rel="noopener noreferrer">
github.com/White-Tiger/WinMTR</a>
— extract the ZIP and run <code class="code-inline">WinMTR.exe</code>.
</li>
<li>
In the <strong>Host</strong> field, enter either the server IP
(<code class="code-inline" id="server-ip-step">__SERVER_IP__</code>)
or the domain
(<code class="code-inline">__SERVER_DOMAIN__</code>).
</li>
<li>
Set <strong>Max hosts</strong> to <code class="code-inline">30</code> and
<strong>Interval</strong> to <code class="code-inline">1000 ms</code>.
</li>
<li>
Click <strong>Start</strong> and let it run for at least 60 seconds (100+ packets).
</li>
<li>
Click <strong>Export TEXT</strong> to save the results, then share them with your support team.
</li>
</ol>
</div>
</div>
<script>
// Detect client IP from server response header or API
(async function detectIP() {
try {
const r = await fetch('__DIAG_PATH__api/mtr', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: 'count=1&detect_only=1'
});
const d = await r.json();
if (d.target) {
document.getElementById('client-ip').textContent = d.target;
}
} catch { /* non-critical */ }
})();
async function runMtr() {
const btn = document.getElementById('mtr-btn');
const spinner = document.getElementById('mtr-spinner');
const resultEl = document.getElementById('mtr-result');
const statusEl = document.getElementById('mtr-status');
// Validate input
const countInput = document.getElementById('pkt-count');
const count = parseInt(countInput.value, 10);
if (isNaN(count) || count < 1 || count > 20) {
statusEl.innerHTML = '<span class="status-tag tag-error">Packet count must be 120</span>';
return;
}
btn.disabled = true;
spinner.classList.add('active');
resultEl.style.display = 'none';
statusEl.innerHTML = '<span class="status-tag tag-info">Running MTR… this may take up to ' +
(count * 3) + ' seconds</span>';
try {
const resp = await fetch('__DIAG_PATH__api/mtr', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({count})
});
const data = await resp.json();
spinner.classList.remove('active');
btn.disabled = false;
if (resp.status === 429) {
statusEl.innerHTML = '<span class="status-tag tag-warn">Rate limit reached — please wait 60 seconds</span>';
return;
}
if (data.success) {
document.getElementById('client-ip').textContent = data.target || '';
statusEl.innerHTML = '<span class="status-tag tag-ok">MTR complete — ' +
count + ' packets to ' + (data.target || 'your IP') + '</span>';
resultEl.textContent = data.output || '(no output)';
} else {
statusEl.innerHTML = '<span class="status-tag tag-error">Error: ' +
escHtml(data.error || 'unknown') + '</span>';
resultEl.textContent = data.output || '';
}
resultEl.style.display = 'block';
} catch (err) {
spinner.classList.remove('active');
btn.disabled = false;
statusEl.innerHTML = '<span class="status-tag tag-error">Request failed: ' + escHtml(String(err)) + '</span>';
}
}
function escHtml(s) {
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
</script>
</body>
</html>
+258
View File
@@ -0,0 +1,258 @@
#!/usr/bin/env python3
"""
MTR diagnostics backend — serves on 127.0.0.1:PORT only.
Accepts POST /mtr with packet_count parameter.
Client IP is taken from X-Real-IP header (set by nginx).
Never runs mtr to arbitrary hosts.
"""
import argparse
import ipaddress
import json
import logging
import os
import re
import subprocess
import sys
import time
from http.server import BaseHTTPRequestHandler, HTTPServer
from threading import Lock
from urllib.parse import parse_qs, urlparse
# ── Constants ──────────────────────────────────────────────────────────────────
MAX_PACKET_COUNT = 20
MIN_PACKET_COUNT = 1
DEFAULT_PACKET_COUNT = 5
MTR_TIMEOUT = 90 # seconds: mtr max run time
RATE_LIMIT_WINDOW = 60 # seconds
RATE_LIMIT_MAX = 3 # requests per window per IP
MTR_BIN = "/usr/bin/mtr"
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
stream=sys.stderr,
)
log = logging.getLogger("mtr-backend")
# ── Rate limiter ───────────────────────────────────────────────────────────────
_rate_lock = Lock()
_rate_store: dict[str, list[float]] = {}
def rate_check(client_ip: str) -> bool:
"""Returns True if request is allowed, False if rate-limited."""
now = time.monotonic()
with _rate_lock:
times = _rate_store.get(client_ip, [])
times = [t for t in times if now - t < RATE_LIMIT_WINDOW]
if len(times) >= RATE_LIMIT_MAX:
return False
times.append(now)
_rate_store[client_ip] = times
return True
# ── IP validation ──────────────────────────────────────────────────────────────
def validate_ip(raw: str) -> str:
"""
Strict IP validation. Rejects private/loopback/link-local/multicast ranges
to prevent SSRF and abuse.
Returns the normalized IP string or raises ValueError.
"""
raw = raw.strip()
# Strip IPv6 brackets
if raw.startswith("[") and raw.endswith("]"):
raw = raw[1:-1]
# Strip IPv4-mapped IPv6 prefix
if raw.startswith("::ffff:"):
raw = raw[7:]
try:
addr = ipaddress.ip_address(raw)
except ValueError as e:
raise ValueError(f"Invalid IP address: {raw!r}") from e
if addr.is_private:
raise ValueError(f"Private IP not allowed: {raw}")
if addr.is_loopback:
raise ValueError(f"Loopback IP not allowed: {raw}")
if addr.is_link_local:
raise ValueError(f"Link-local IP not allowed: {raw}")
if addr.is_multicast:
raise ValueError(f"Multicast IP not allowed: {raw}")
if addr.is_reserved:
raise ValueError(f"Reserved IP not allowed: {raw}")
return str(addr)
def validate_packet_count(raw: str) -> int:
"""Parse and validate packet count. Returns int or raises ValueError."""
if not re.fullmatch(r"[0-9]{1,2}", raw.strip()):
raise ValueError("Packet count must be a 1-2 digit integer")
n = int(raw.strip())
if not (MIN_PACKET_COUNT <= n <= MAX_PACKET_COUNT):
raise ValueError(f"Packet count must be between {MIN_PACKET_COUNT} and {MAX_PACKET_COUNT}")
return n
# ── MTR runner ─────────────────────────────────────────────────────────────────
def run_mtr(target_ip: str, count: int) -> dict:
"""
Run mtr against target_ip with exactly `count` cycles.
Returns a dict with keys: success, output, error.
"""
if not os.path.isfile(MTR_BIN):
return {"success": False, "error": "mtr not installed", "output": ""}
# Build command — note: NO shell=True, all args as list
cmd = [
MTR_BIN,
"--report",
"--report-wide",
"--no-dns",
"--max-ttl", "30",
"--report-cycles", str(count),
"--", # explicit end of options: prevents injection via IP
target_ip,
]
log.info("Running mtr: %s cycles → %s", count, target_ip)
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=MTR_TIMEOUT,
# Safety: drop stdin, clean environment
stdin=subprocess.DEVNULL,
env={"PATH": "/usr/bin:/bin", "HOME": "/tmp"},
)
output = result.stdout or ""
error = result.stderr or ""
if result.returncode != 0:
return {"success": False, "error": f"mtr exited {result.returncode}: {error[:500]}", "output": output}
return {"success": True, "output": output, "error": ""}
except subprocess.TimeoutExpired:
return {"success": False, "error": "mtr timed out", "output": ""}
except FileNotFoundError:
return {"success": False, "error": "mtr binary not found", "output": ""}
except Exception as exc: # noqa: BLE001
log.error("mtr exception: %s", exc)
return {"success": False, "error": "internal error", "output": ""}
# ── HTTP handler ───────────────────────────────────────────────────────────────
class Handler(BaseHTTPRequestHandler):
def log_message(self, fmt, *args): # suppress default access log to stdout
log.debug("http: " + fmt, *args)
def _send_json(self, code: int, body: dict) -> None:
payload = json.dumps(body).encode()
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(payload)))
self.send_header("Cache-Control", "no-store")
self.send_header("X-Content-Type-Options", "nosniff")
self.end_headers()
self.wfile.write(payload)
def _client_ip(self) -> str:
"""Extract real client IP. nginx sets X-Real-IP via proxy_protocol chain."""
# X-Real-IP: set by nginx real_ip module from proxy_protocol header
ip = self.headers.get("X-Real-IP", "").strip()
if ip:
log.debug("IP from X-Real-IP: %s", ip)
return ip
# X-Forwarded-For: fallback, take first (leftmost) address in chain
xff = self.headers.get("X-Forwarded-For", "").strip()
if xff:
ip = xff.split(",")[0].strip()
log.debug("IP from X-Forwarded-For: %s (full: %s)", ip, xff)
return ip
# Direct connection — will be 127.0.0.1 when proxied through nginx
ip = self.client_address[0]
log.debug("IP from direct connection: %s", ip)
return ip
def do_GET(self):
if self.path == "/health":
self._send_json(200, {"ok": True})
else:
self._send_json(404, {"error": "not found"})
def do_POST(self):
parsed = urlparse(self.path)
if parsed.path not in ("/mtr", "/api/mtr"):
self._send_json(404, {"error": "not found"})
return
raw_ip = self._client_ip()
log.info("MTR request from %s", raw_ip)
# ── Rate limit ────────────────────────────────────────────────────
if not rate_check(raw_ip):
self._send_json(429, {"error": "Rate limit exceeded. Please wait 60 seconds."})
return
# ── Validate client IP ────────────────────────────────────────────
try:
target_ip = validate_ip(raw_ip)
except ValueError as e:
self._send_json(400, {"error": str(e)})
return
# ── Parse body ────────────────────────────────────────────────────
content_length = int(self.headers.get("Content-Length", "0"))
if content_length > 200:
self._send_json(400, {"error": "Request body too large"})
return
body_raw = self.rfile.read(content_length).decode(errors="replace")
# Support both form-encoded and JSON body
packet_count = DEFAULT_PACKET_COUNT
try:
if self.headers.get("Content-Type", "").startswith("application/json"):
data = json.loads(body_raw) if body_raw else {}
raw_count = str(data.get("count", DEFAULT_PACKET_COUNT))
else:
params = parse_qs(body_raw)
raw_count = params.get("count", [str(DEFAULT_PACKET_COUNT)])[0]
packet_count = validate_packet_count(raw_count)
except (ValueError, json.JSONDecodeError) as e:
self._send_json(400, {"error": f"Invalid parameters: {e}"})
return
# ── Run mtr ───────────────────────────────────────────────────────
result = run_mtr(target_ip, packet_count)
code = 200 if result["success"] else 500
result["target"] = target_ip
result["count"] = packet_count
self._send_json(code, result)
# ── Entry point ────────────────────────────────────────────────────────────────
def main():
parser = argparse.ArgumentParser(description="MTR diagnostics backend")
parser.add_argument("--port", type=int, default=18080, help="Listen port (127.0.0.1 only)")
args = parser.parse_args()
if not (1024 <= args.port <= 65535):
sys.exit("Port must be between 1024 and 65535")
server = HTTPServer(("127.0.0.1", args.port), Handler)
log.info("MTR backend listening on 127.0.0.1:%d", args.port)
try:
server.serve_forever()
except KeyboardInterrupt:
pass
if __name__ == "__main__":
main()