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()
|
||||||
+154
-2
@@ -74,6 +74,8 @@ trojan_path=$(gen_random_string 10)
|
|||||||
xhttp_path=$(gen_random_string 10)
|
xhttp_path=$(gen_random_string 10)
|
||||||
config_username=$(gen_random_string 10)
|
config_username=$(gen_random_string 10)
|
||||||
config_password=$(gen_random_string 10)
|
config_password=$(gen_random_string 10)
|
||||||
|
diag_path="/net-$(gen_random_string 12)/"
|
||||||
|
mtr_backend_port=$(make_port)
|
||||||
|
|
||||||
# ─── Argument parsing ────────────────────────────────────────────────────────
|
# ─── Argument parsing ────────────────────────────────────────────────────────
|
||||||
while [ "$#" -gt 0 ]; do
|
while [ "$#" -gt 0 ]; do
|
||||||
@@ -101,7 +103,12 @@ uninstall_xui() {
|
|||||||
$Pak -y purge nginx nginx-common nginx-core nginx-full python3-certbot-nginx
|
$Pak -y purge nginx nginx-common nginx-core nginx-full python3-certbot-nginx
|
||||||
$Pak -y autoremove
|
$Pak -y autoremove
|
||||||
$Pak -y autoclean
|
$Pak -y autoclean
|
||||||
rm -rf /var/www/html/ /etc/nginx/ /usr/share/nginx/
|
rm -rf /var/www/html/ /var/www/diagnostics/ /var/www/openspeedtest/ /etc/nginx/ /usr/share/nginx/
|
||||||
|
systemctl stop mtr-backend 2>/dev/null || true
|
||||||
|
systemctl disable mtr-backend 2>/dev/null || true
|
||||||
|
rm -f /etc/systemd/system/mtr-backend.service
|
||||||
|
rm -rf /usr/local/lib/3x-ui-pro/
|
||||||
|
systemctl daemon-reload 2>/dev/null || true
|
||||||
}
|
}
|
||||||
|
|
||||||
if [[ ${UNINSTALL} == *"y"* ]]; then
|
if [[ ${UNINSTALL} == *"y"* ]]; then
|
||||||
@@ -166,7 +173,7 @@ install_packages() {
|
|||||||
[[ "$version" == "20" || "$version" == "22" ]] && echo "System: Ubuntu $version"
|
[[ "$version" == "20" || "$version" == "22" ]] && echo "System: Ubuntu $version"
|
||||||
|
|
||||||
$Pak -y update
|
$Pak -y update
|
||||||
$Pak -y install curl wget jq bash sudo nginx-full certbot python3-certbot-nginx sqlite3 ufw netcat-openbsd
|
$Pak -y install curl wget jq bash sudo nginx-full certbot python3-certbot-nginx sqlite3 ufw netcat-openbsd mtr python3 libcap2-bin
|
||||||
systemctl daemon-reload && systemctl enable --now nginx
|
systemctl daemon-reload && systemctl enable --now nginx
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -351,6 +358,11 @@ EOF
|
|||||||
|
|
||||||
# Main domain vhost (TLS termination at 7443, proxy_protocol)
|
# Main domain vhost (TLS termination at 7443, proxy_protocol)
|
||||||
cat > "/etc/nginx/sites-available/${domain}" <<EOF
|
cat > "/etc/nginx/sites-available/${domain}" <<EOF
|
||||||
|
# Rate limiting zones (http context)
|
||||||
|
limit_req_zone \$binary_remote_addr zone=diag_api:10m rate=6r/m;
|
||||||
|
limit_req_zone \$binary_remote_addr zone=diag_page:10m rate=30r/m;
|
||||||
|
limit_conn_zone \$binary_remote_addr zone=per_ip:10m;
|
||||||
|
|
||||||
server {
|
server {
|
||||||
server_tokens off;
|
server_tokens off;
|
||||||
server_name ${domain};
|
server_name ${domain};
|
||||||
@@ -358,6 +370,8 @@ server {
|
|||||||
listen [::]:7443 ssl http2 proxy_protocol;
|
listen [::]:7443 ssl http2 proxy_protocol;
|
||||||
index index.html index.htm index.php;
|
index index.html index.htm index.php;
|
||||||
root /var/www/html/;
|
root /var/www/html/;
|
||||||
|
real_ip_header proxy_protocol;
|
||||||
|
set_real_ip_from 127.0.0.1;
|
||||||
ssl_protocols TLSv1.2 TLSv1.3;
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
ssl_ciphers HIGH:!aNULL:!eNULL:!MD5:!DES:!RC4:!ADH:!SSLv3:!EXP:!PSK:!DSS;
|
ssl_ciphers HIGH:!aNULL:!eNULL:!MD5:!DES:!RC4:!ADH:!SSLv3:!EXP:!PSK:!DSS;
|
||||||
ssl_certificate /etc/letsencrypt/live/${domain}/fullchain.pem;
|
ssl_certificate /etc/letsencrypt/live/${domain}/fullchain.pem;
|
||||||
@@ -395,6 +409,48 @@ server {
|
|||||||
proxy_pass https://127.0.0.1:${panel_port};
|
proxy_pass https://127.0.0.1:${panel_port};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ── Network diagnostics page ─────────────────────────────────────────────
|
||||||
|
location ^~ ${diag_path} {
|
||||||
|
limit_req zone=diag_page burst=10 nodelay;
|
||||||
|
limit_conn per_ip 5;
|
||||||
|
alias /var/www/diagnostics/;
|
||||||
|
index index.html;
|
||||||
|
try_files \$uri \$uri/ /index.html;
|
||||||
|
add_header Cache-Control "no-store" always;
|
||||||
|
add_header X-Robots-Tag "noindex, nofollow" always;
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Diagnostics MTR API ──────────────────────────────────────────────────
|
||||||
|
location ^~ ${diag_path}api/mtr {
|
||||||
|
limit_req zone=diag_api burst=2 nodelay;
|
||||||
|
limit_conn per_ip 2;
|
||||||
|
proxy_pass http://127.0.0.1:${mtr_backend_port};
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header X-Real-IP \$remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||||||
|
proxy_read_timeout 120s;
|
||||||
|
proxy_send_timeout 120s;
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── OpenSpeedTest (served locally, no external connections) ─────────────
|
||||||
|
location ^~ ${diag_path}speedtest/ {
|
||||||
|
limit_req zone=diag_page burst=30 nodelay;
|
||||||
|
alias /var/www/openspeedtest/;
|
||||||
|
index index.html;
|
||||||
|
try_files \$uri \$uri/ =404;
|
||||||
|
client_max_body_size 35m;
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
|
access_log off;
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Download test files ──────────────────────────────────────────────────
|
||||||
|
location ^~ ${diag_path}testfiles/ {
|
||||||
|
alias /var/www/diagnostics/testfiles/;
|
||||||
|
access_log off;
|
||||||
|
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
|
||||||
|
add_header Content-Disposition "attachment" always;
|
||||||
|
}
|
||||||
|
|
||||||
include /etc/nginx/snippets/includes.conf;
|
include /etc/nginx/snippets/includes.conf;
|
||||||
}
|
}
|
||||||
EOF
|
EOF
|
||||||
@@ -772,6 +828,99 @@ install_fake_site() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# INSTALL NETWORK DIAGNOSTICS PAGE
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
install_diagnostics() {
|
||||||
|
local diag_webroot="/var/www/diagnostics"
|
||||||
|
local backend_script="/usr/local/lib/3x-ui-pro/mtr-backend.py"
|
||||||
|
local openspeedtest_webroot="/var/www/openspeedtest"
|
||||||
|
|
||||||
|
# OpenSpeedTest static files
|
||||||
|
if [[ ! -f "${openspeedtest_webroot}/index.html" ]]; then
|
||||||
|
mkdir -p "${openspeedtest_webroot}"
|
||||||
|
curl -fsSL --retry 3 \
|
||||||
|
"https://github.com/openspeedtest/Speed-Test/archive/refs/heads/main.tar.gz" \
|
||||||
|
-o /tmp/openspeedtest.tar.gz
|
||||||
|
tar -xzf /tmp/openspeedtest.tar.gz -C "${openspeedtest_webroot}" --strip-components=1
|
||||||
|
rm -f /tmp/openspeedtest.tar.gz
|
||||||
|
chown -R www-data:www-data "${openspeedtest_webroot}" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Diagnostics HTML page
|
||||||
|
mkdir -p "${diag_webroot}"
|
||||||
|
curl -fsSL "${GITHUB_RAW}/assets/diagnostics/index.html" -o "${diag_webroot}/index.html"
|
||||||
|
sed -i \
|
||||||
|
-e "s|__DIAG_PATH__|${diag_path}|g" \
|
||||||
|
-e "s|__SERVER_DOMAIN__|${domain}|g" \
|
||||||
|
-e "s|__SERVER_IP__|${IP4}|g" \
|
||||||
|
"${diag_webroot}/index.html"
|
||||||
|
|
||||||
|
# Test download files
|
||||||
|
local testfiles="${diag_webroot}/testfiles"
|
||||||
|
mkdir -p "${testfiles}"
|
||||||
|
[[ -f "${testfiles}/test-15k.bin" ]] || dd if=/dev/zero bs=1024 count=15 of="${testfiles}/test-15k.bin" status=none
|
||||||
|
[[ -f "${testfiles}/test-17k.bin" ]] || dd if=/dev/zero bs=1024 count=17 of="${testfiles}/test-17k.bin" status=none
|
||||||
|
[[ -f "${testfiles}/test-100m.bin" ]] || dd if=/dev/zero bs=1048576 count=100 of="${testfiles}/test-100m.bin" status=none
|
||||||
|
[[ -f "${testfiles}/test-1g.bin" ]] || dd if=/dev/zero bs=1048576 count=1024 of="${testfiles}/test-1g.bin" status=none
|
||||||
|
chown -R www-data:www-data "${diag_webroot}" 2>/dev/null || true
|
||||||
|
|
||||||
|
# MTR backend Python script
|
||||||
|
mkdir -p "$(dirname "${backend_script}")"
|
||||||
|
curl -fsSL "${GITHUB_RAW}/assets/diagnostics/mtr-backend.py" -o "${backend_script}"
|
||||||
|
chmod 755 "${backend_script}"
|
||||||
|
|
||||||
|
# Grant mtr raw socket capability (runs as restricted user, no root needed)
|
||||||
|
command -v setcap &>/dev/null && setcap cap_net_raw+ep "$(command -v mtr)" 2>/dev/null || true
|
||||||
|
|
||||||
|
# Dedicated system user for mtr-backend
|
||||||
|
id mtr-backend &>/dev/null || \
|
||||||
|
useradd --system --no-create-home --shell /usr/sbin/nologin mtr-backend
|
||||||
|
|
||||||
|
# Systemd service for mtr-backend
|
||||||
|
cat > /etc/systemd/system/mtr-backend.service <<EOF
|
||||||
|
[Unit]
|
||||||
|
Description=3x-ui-pro MTR diagnostics backend
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=mtr-backend
|
||||||
|
Group=mtr-backend
|
||||||
|
ExecStart=/usr/bin/python3 ${backend_script} --port ${mtr_backend_port}
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5s
|
||||||
|
NoNewPrivileges=yes
|
||||||
|
PrivateTmp=yes
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=yes
|
||||||
|
ProtectKernelTunables=yes
|
||||||
|
ProtectKernelModules=yes
|
||||||
|
ProtectControlGroups=yes
|
||||||
|
RestrictAddressFamilies=AF_INET AF_INET6 AF_NETLINK
|
||||||
|
RestrictNamespaces=yes
|
||||||
|
LockPersonality=yes
|
||||||
|
MemoryDenyWriteExecute=yes
|
||||||
|
RestrictRealtime=yes
|
||||||
|
RestrictSUIDSGID=yes
|
||||||
|
RemoveIPC=yes
|
||||||
|
AmbientCapabilities=
|
||||||
|
CapabilityBoundingSet=
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
SyslogIdentifier=mtr-backend
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable mtr-backend
|
||||||
|
systemctl restart mtr-backend
|
||||||
|
|
||||||
|
msg_ok "Network diagnostics installed at https://${domain}${diag_path}"
|
||||||
|
}
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
# SYSTEM TUNING (BBR + kernel params)
|
# SYSTEM TUNING (BBR + kernel params)
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
@@ -831,6 +980,8 @@ show_results() {
|
|||||||
echo -e "Username: ${config_username}\n"
|
echo -e "Username: ${config_username}\n"
|
||||||
echo -e "Password: ${config_password}\n"
|
echo -e "Password: ${config_password}\n"
|
||||||
msg_inf "────────────────────────────────────────────────────────────────────────────────"
|
msg_inf "────────────────────────────────────────────────────────────────────────────────"
|
||||||
|
msg_inf "Network Diagnostics: https://${domain}${diag_path}\n"
|
||||||
|
msg_inf "────────────────────────────────────────────────────────────────────────────────"
|
||||||
msg_inf "Please save this screen!"
|
msg_inf "Please save this screen!"
|
||||||
else
|
else
|
||||||
nginx -t
|
nginx -t
|
||||||
@@ -857,6 +1008,7 @@ main() {
|
|||||||
configure_nginx
|
configure_nginx
|
||||||
configure_xui_db
|
configure_xui_db
|
||||||
install_fake_site
|
install_fake_site
|
||||||
|
install_diagnostics
|
||||||
tune_system
|
tune_system
|
||||||
setup_cron
|
setup_cron
|
||||||
setup_firewall
|
setup_firewall
|
||||||
|
|||||||
Reference in New Issue
Block a user