Files
3x-ui-pro/assets/diagnostics/mtr-backend.py
T
Ivan RazinandClaude Fable 5 de1d95e6e0 Replace speed test with LibreSpeed engine: fix 4x-slow upload
Single 512 MB POST over HTTP/2 was throttled by the per-stream h2
flow-control window, reading ~4x low. LibreSpeed uses parallel upload
streams measured via XHR progress events, which amortizes the window
limit (h2 must stay on for trojan-gRPC).

- Vendor speedtest.js + speedtest_worker.js (LGPL) into assets
- mtr-backend.py: ThreadingHTTPServer, /api/st/up upload sink,
  /api/st/getip; parallel streams need concurrent handling
- nginx: speedtest locations without limit_req (limit_conn instead),
  ping answered by nginx directly, h2 preread/body buffer tuning
- No telemetry, no database
- Drop 512 MB test file; patch script now also substitutes
  __SERVER_DOMAIN__/__SERVER_IP__ placeholders

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 14:15:22 +03:00

330 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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, ThreadingHTTPServer
from threading import Lock
from urllib.parse import parse_qs, urlparse
# ── Constants ──────────────────────────────────────────────────────────────────
MAX_PACKET_COUNT = 100
MIN_PACKET_COUNT = 1
DEFAULT_PACKET_COUNT = 5
MTR_TIMEOUT = 360 # seconds: mtr max run time (100 packets × ~3s margin)
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 _send_text(self, code: int, text: str) -> None:
payload = text.encode()
self.send_response(code)
self.send_header("Content-Type", "text/plain; charset=utf-8")
self.send_header("Content-Length", str(len(payload)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(payload)
def _discard_body(self, max_bytes: int) -> int:
"""Read and discard the request body as fast as possible. Returns bytes read."""
content_length = int(self.headers.get("Content-Length", "0"))
to_read = min(content_length, max_bytes)
received = 0
buf = 1024 * 1024
while received < to_read:
chunk = self.rfile.read(min(buf, to_read - received))
if not chunk:
break
received += len(chunk)
return received
def do_GET(self):
parsed = urlparse(self.path)
if parsed.path == "/health":
self._send_json(200, {"ok": True})
return
# ── LibreSpeed: client IP ──────────────────────────────────────────────
if parsed.path == "/api/st/getip" or parsed.path.endswith("/api/st/getip"):
self._send_text(200, self._client_ip())
return
# ── Clash subscription generator ───────────────────────────────────────
if parsed.path == "/api/clash" or parsed.path.endswith("/api/clash"):
tpl_path = "/var/www/subpage/clash.yaml.tpl"
if not os.path.isfile(tpl_path):
self._send_json(404, {"error": "clash template not found"})
return
params = parse_qs(parsed.query)
sub_id = params.get("sub_id", [""])[0].strip()
try:
with open(tpl_path, "r", encoding="utf-8") as f:
content = f.read()
content = content.replace("${SUB_ID}", sub_id)
payload = content.encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "text/yaml; charset=utf-8")
self.send_header("Content-Length", str(len(payload)))
self.send_header("Content-Disposition", "attachment; filename=clash.yaml")
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(payload)
except Exception as exc:
log.error("clash template error: %s", exc)
self._send_json(500, {"error": "internal error"})
return
self._send_json(404, {"error": "not found"})
def do_POST(self):
parsed = urlparse(self.path)
# ── LibreSpeed upload sink: discard body, empty 200 ────────────────
# Client measures via XHR upload progress; server just consumes bytes.
if parsed.path == "/api/st/up" or parsed.path.endswith("/api/st/up"):
self._discard_body(64 * 1024 * 1024) # librespeed blobs are ~20 MB
self._send_text(200, "")
return
# ── Legacy upload speed test receiver ──────────────────────────────
if parsed.path == "/api/upload" or parsed.path.endswith("/api/upload"):
received = self._discard_body(600 * 1024 * 1024)
self._send_json(200, {"received": received, "ok": True})
return
if not (parsed.path in ("/mtr", "/api/mtr") or parsed.path.endswith("/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")
# Threading: librespeed opens parallel download/upload streams
server = ThreadingHTTPServer(("127.0.0.1", args.port), Handler)
server.daemon_threads = True
log.info("MTR backend listening on 127.0.0.1:%d", args.port)
try:
server.serve_forever()
except KeyboardInterrupt:
pass
if __name__ == "__main__":
main()