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>
330 lines
14 KiB
Python
330 lines
14 KiB
Python
#!/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()
|