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:
co-authored by
Claude Sonnet 4.6
parent
411490ab82
commit
e2a6505909
@@ -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 (1–20)</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 1–20</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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user