From e2a65059093b13b5d75ca5f1cbeae105b79b6f94 Mon Sep 17 00:00:00 2001 From: Ivan Razin Date: Wed, 24 Jun 2026 10:57:16 +0300 Subject: [PATCH] Add network diagnostics page with MTR backend and speed test Co-Authored-By: Claude Sonnet 4.6 --- assets/diagnostics/index.html | 364 ++++++++++++++++++++++++++++++ assets/diagnostics/mtr-backend.py | 258 +++++++++++++++++++++ x-ui-latest.sh | 156 ++++++++++++- 3 files changed, 776 insertions(+), 2 deletions(-) create mode 100644 assets/diagnostics/index.html create mode 100644 assets/diagnostics/mtr-backend.py diff --git a/assets/diagnostics/index.html b/assets/diagnostics/index.html new file mode 100644 index 0000000..0b4fdb6 --- /dev/null +++ b/assets/diagnostics/index.html @@ -0,0 +1,364 @@ + + + + + +Network Diagnostics + + + + +
+

Network Diagnostics

+

Server diagnostics panel — Private

+ + +
+
Speed Test
+

+ Runs directly between your browser and this server. Served locally — no external connections. +

+ +
+ + +
+
MTR Network Path Test
+

+ Runs mtr from this server to your IP address + (detecting…). Shows hop-by-hop latency and packet loss. +

+
+
+ + +
+ + +
+
+

+  
+ + +
+
Download Test Files
+

+ Static binary files for bandwidth testing. Generated from /dev/zero. +

+ +
+ + +
+
Run MTR from Windows (WinMTR)
+

+ Test the network path from your PC to this server at + __SERVER_DOMAIN__ + (IP: __SERVER_IP__). +

+
    +
  1. + Download WinMTR from the official site: + + github.com/White-Tiger/WinMTR + — extract the ZIP and run WinMTR.exe. +
  2. +
  3. + In the Host field, enter either the server IP + (__SERVER_IP__) + or the domain + (__SERVER_DOMAIN__). +
  4. +
  5. + Set Max hosts to 30 and + Interval to 1000 ms. +
  6. +
  7. + Click Start and let it run for at least 60 seconds (100+ packets). +
  8. +
  9. + Click Export TEXT to save the results, then share them with your support team. +
  10. +
+
+
+ + + + diff --git a/assets/diagnostics/mtr-backend.py b/assets/diagnostics/mtr-backend.py new file mode 100644 index 0000000..353a431 --- /dev/null +++ b/assets/diagnostics/mtr-backend.py @@ -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() diff --git a/x-ui-latest.sh b/x-ui-latest.sh index 58dd332..67e19b5 100644 --- a/x-ui-latest.sh +++ b/x-ui-latest.sh @@ -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}" </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 <