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()
+154 -2
View File
@@ -74,6 +74,8 @@ trojan_path=$(gen_random_string 10)
xhttp_path=$(gen_random_string 10)
config_username=$(gen_random_string 10)
config_password=$(gen_random_string 10)
diag_path="/net-$(gen_random_string 12)/"
mtr_backend_port=$(make_port)
# ─── Argument parsing ────────────────────────────────────────────────────────
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 autoremove
$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
@@ -166,7 +173,7 @@ install_packages() {
[[ "$version" == "20" || "$version" == "22" ]] && echo "System: Ubuntu $version"
$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
fi
@@ -351,6 +358,11 @@ EOF
# Main domain vhost (TLS termination at 7443, proxy_protocol)
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_tokens off;
server_name ${domain};
@@ -358,6 +370,8 @@ server {
listen [::]:7443 ssl http2 proxy_protocol;
index index.html index.htm index.php;
root /var/www/html/;
real_ip_header proxy_protocol;
set_real_ip_from 127.0.0.1;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!eNULL:!MD5:!DES:!RC4:!ADH:!SSLv3:!EXP:!PSK:!DSS;
ssl_certificate /etc/letsencrypt/live/${domain}/fullchain.pem;
@@ -395,6 +409,48 @@ server {
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;
}
EOF
@@ -772,6 +828,99 @@ install_fake_site() {
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)
# ─────────────────────────────────────────────────────────────────────────────
@@ -831,6 +980,8 @@ show_results() {
echo -e "Username: ${config_username}\n"
echo -e "Password: ${config_password}\n"
msg_inf "────────────────────────────────────────────────────────────────────────────────"
msg_inf "Network Diagnostics: https://${domain}${diag_path}\n"
msg_inf "────────────────────────────────────────────────────────────────────────────────"
msg_inf "Please save this screen!"
else
nginx -t
@@ -857,6 +1008,7 @@ main() {
configure_nginx
configure_xui_db
install_fake_site
install_diagnostics
tune_system
setup_cron
setup_firewall