mirror of
https://github.com/masterking32/MasterHttpRelayVPN.git
synced 2026-05-17 21:24:37 +03:00
378 lines
12 KiB
Python
378 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
MasterHttpRelayVPN — VPS Exit Node Server (Linux only)
|
|
|
|
A lightweight HTTP relay server you can run on your own Linux VPS.
|
|
It receives relay requests forwarded by Apps Script (on behalf of
|
|
MasterHttpRelayVPN) and makes the actual outbound HTTP/HTTPS connections
|
|
using your VPS's IP address.
|
|
|
|
Traffic path with this server:
|
|
Browser → Local Proxy → Apps Script (Google) → THIS SERVER → Target website
|
|
|
|
Usage:
|
|
python3 vps_exit_node.py --psk YOUR_STRONG_SECRET [--host 0.0.0.0] [--port 8181]
|
|
|
|
Or use the environment variable instead of --psk:
|
|
export EXIT_NODE_PSK=YOUR_STRONG_SECRET
|
|
python3 vps_exit_node.py
|
|
|
|
For easy installation on a fresh Linux VPS, use the provided installer:
|
|
bash setup_vps_exit_node.sh
|
|
|
|
For production use, run behind a reverse proxy (nginx / Caddy) that
|
|
handles TLS so the endpoint is reachable over HTTPS.
|
|
|
|
NOTE: This script is designed for Linux only. It will refuse to start
|
|
on Windows or macOS.
|
|
"""
|
|
|
|
import argparse
|
|
import base64
|
|
import http.server
|
|
import json
|
|
import logging
|
|
import os
|
|
import re
|
|
import socketserver
|
|
import sys
|
|
import urllib.error
|
|
import urllib.request
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Logging
|
|
# ---------------------------------------------------------------------------
|
|
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format="%(asctime)s [%(levelname)s] %(message)s",
|
|
stream=sys.stdout,
|
|
)
|
|
log = logging.getLogger("exit-node")
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Constants
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# Headers that must never be forwarded to the upstream target because they
|
|
# are connection-local, injected by the relay chain, or could leak caller
|
|
# information.
|
|
_STRIP_HEADERS = frozenset(
|
|
[
|
|
"host",
|
|
"connection",
|
|
"content-length",
|
|
"transfer-encoding",
|
|
"keep-alive",
|
|
"te",
|
|
"trailer",
|
|
"upgrade",
|
|
"proxy-connection",
|
|
"proxy-authorization",
|
|
"proxy-authenticate",
|
|
"x-forwarded-for",
|
|
"x-forwarded-host",
|
|
"x-forwarded-proto",
|
|
"x-forwarded-port",
|
|
"x-real-ip",
|
|
"forwarded",
|
|
"via",
|
|
# urllib.request cannot decompress gzip/br/deflate — stripping this
|
|
# forces targets to reply with plain bodies the server can forward.
|
|
"accept-encoding",
|
|
]
|
|
)
|
|
|
|
# Maximum request body accepted from the relay chain (32 MiB).
|
|
_MAX_REQUEST_BODY = 32 * 1024 * 1024
|
|
|
|
# Maximum response body forwarded back (64 MiB).
|
|
_MAX_RESPONSE_BODY = 64 * 1024 * 1024
|
|
|
|
# Outbound request timeout in seconds.
|
|
_OUTBOUND_TIMEOUT = 30
|
|
|
|
# Pre-shared key loaded at startup.
|
|
_PSK: str = ""
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# HTTP client — no-redirect opener
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_NO_REDIRECT_OPENER = urllib.request.OpenerDirector()
|
|
for _h in (
|
|
urllib.request.UnknownHandler(),
|
|
urllib.request.HTTPDefaultErrorHandler(),
|
|
urllib.request.HTTPErrorProcessor(),
|
|
urllib.request.HTTPHandler(),
|
|
urllib.request.HTTPSHandler(),
|
|
):
|
|
_NO_REDIRECT_OPENER.add_handler(_h)
|
|
del _h
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _sanitize_headers(raw: object) -> dict[str, str]:
|
|
"""Return a clean header dict, dropping hop-by-hop and proxy headers."""
|
|
if not isinstance(raw, dict):
|
|
return {}
|
|
out: dict[str, str] = {}
|
|
for k, v in raw.items():
|
|
if not k or not isinstance(k, str):
|
|
continue
|
|
if k.lower() in _STRIP_HEADERS:
|
|
continue
|
|
out[k] = str(v) if v is not None else ""
|
|
return out
|
|
|
|
|
|
def _safe_url(url: str) -> bool:
|
|
"""Return True only for plain http:// or https:// URLs (no localhost / LAN)."""
|
|
if not re.match(r"^https?://", url, re.IGNORECASE):
|
|
return False
|
|
# Block requests to loopback / private addresses to prevent SSRF.
|
|
from urllib.parse import urlparse
|
|
|
|
host = urlparse(url).hostname or ""
|
|
host = host.lower().rstrip(".")
|
|
# Reject empty, numeric localhost, and obviously private hostnames.
|
|
_PRIVATE = re.compile(
|
|
r"^("
|
|
r"localhost"
|
|
r"|127\.\d+\.\d+\.\d+"
|
|
r"|::1"
|
|
r"|0\.0\.0\.0"
|
|
r"|10\.\d+\.\d+\.\d+"
|
|
r"|172\.(1[6-9]|2\d|3[01])\.\d+\.\d+"
|
|
r"|192\.168\.\d+\.\d+"
|
|
r"|169\.254\.\d+\.\d+"
|
|
r"|fc[0-9a-f]{2}:.*"
|
|
r"|fd[0-9a-f]{2}:.*"
|
|
r")$"
|
|
)
|
|
if _PRIVATE.match(host):
|
|
return False
|
|
return True
|
|
|
|
|
|
def _collect_headers(raw_headers) -> dict:
|
|
"""Collect HTTP response headers, preserving all values for duplicate names.
|
|
|
|
Python's http.client.HTTPMessage yields duplicate header names (e.g. multiple
|
|
Set-Cookie lines) as separate items when iterated. A plain dict assignment
|
|
silently overwrites earlier values, so sites like auth.openai.com that set
|
|
several Set-Cookie headers in one response would lose all but the last one.
|
|
Accumulate duplicates into a list so every value reaches the browser.
|
|
"""
|
|
out: dict = {}
|
|
key_map: dict[str, str] = {} # lowercase name → first-seen canonical case
|
|
for k, v in raw_headers.items():
|
|
kl = k.lower()
|
|
if kl not in key_map:
|
|
key_map[kl] = k
|
|
out[k] = v
|
|
else:
|
|
canonical = key_map[kl]
|
|
cur = out[canonical]
|
|
if isinstance(cur, list):
|
|
cur.append(v)
|
|
else:
|
|
out[canonical] = [cur, v]
|
|
return out
|
|
|
|
|
|
def _relay_request(
|
|
url: str, method: str, headers: dict[str, str], body: bytes
|
|
) -> dict:
|
|
"""Perform the outbound HTTP/HTTPS request and return a relay-JSON dict."""
|
|
request = urllib.request.Request(url, method=method, headers=headers)
|
|
if body:
|
|
request.data = body
|
|
|
|
try:
|
|
with _NO_REDIRECT_OPENER.open(request, timeout=_OUTBOUND_TIMEOUT) as resp:
|
|
data = resp.read(_MAX_RESPONSE_BODY)
|
|
return {
|
|
"s": resp.status,
|
|
"h": _collect_headers(resp.headers),
|
|
"b": base64.b64encode(data).decode(),
|
|
}
|
|
except urllib.error.HTTPError as exc:
|
|
data = exc.read(_MAX_RESPONSE_BODY) if exc.fp else b""
|
|
return {
|
|
"s": exc.code,
|
|
"h": _collect_headers(exc.headers) if exc.headers else {},
|
|
"b": base64.b64encode(data).decode(),
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# HTTP request handler
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class _ExitNodeHandler(http.server.BaseHTTPRequestHandler):
|
|
# Suppress the default per-request access log lines; we emit our own.
|
|
def log_message(self, fmt, *args): # noqa: D102
|
|
pass
|
|
|
|
def _send_json(self, status: int, obj: dict) -> None:
|
|
body = json.dumps(obj).encode()
|
|
self.send_response(status)
|
|
self.send_header("Content-Type", "application/json")
|
|
self.send_header("Content-Length", str(len(body)))
|
|
self.end_headers()
|
|
self.wfile.write(body)
|
|
|
|
def do_GET(self): # noqa: N802
|
|
"""Health-check endpoint — returns a friendly JSON status."""
|
|
self._send_json(
|
|
200,
|
|
{
|
|
"ok": True,
|
|
"status": "healthy",
|
|
"message": "VPS exit node is running.",
|
|
"usage": "Send POST with relay payload for actual proxy requests.",
|
|
},
|
|
)
|
|
|
|
def do_POST(self): # noqa: N802
|
|
"""Relay endpoint — receives a JSON relay payload, fetches the URL."""
|
|
content_length = int(self.headers.get("Content-Length") or 0)
|
|
if content_length <= 0:
|
|
self._send_json(400, {"e": "empty_body"})
|
|
return
|
|
if content_length > _MAX_REQUEST_BODY:
|
|
self._send_json(413, {"e": "request_too_large"})
|
|
return
|
|
|
|
raw = self.rfile.read(content_length)
|
|
try:
|
|
body = json.loads(raw)
|
|
except Exception:
|
|
self._send_json(400, {"e": "bad_json"})
|
|
return
|
|
|
|
if not isinstance(body, dict):
|
|
self._send_json(400, {"e": "bad_json"})
|
|
return
|
|
|
|
k = str(body.get("k") or "")
|
|
u = str(body.get("u") or "")
|
|
m = str(body.get("m") or "GET").upper()
|
|
h = _sanitize_headers(body.get("h"))
|
|
b64 = body.get("b")
|
|
|
|
if not _PSK:
|
|
self._send_json(500, {"e": "server_psk_missing"})
|
|
return
|
|
|
|
if k != _PSK:
|
|
log.warning("Rejected unauthorized request from %s", self.client_address[0])
|
|
self._send_json(401, {"e": "unauthorized"})
|
|
return
|
|
|
|
if not _safe_url(u):
|
|
self._send_json(400, {"e": "bad_url"})
|
|
return
|
|
|
|
payload_bytes = b""
|
|
if isinstance(b64, str) and b64:
|
|
try:
|
|
payload_bytes = base64.b64decode(b64)
|
|
except Exception:
|
|
self._send_json(400, {"e": "bad_base64"})
|
|
return
|
|
|
|
log.info("Relaying %s %s", m, u[:100])
|
|
try:
|
|
result = _relay_request(u, m, h, payload_bytes)
|
|
except Exception as exc:
|
|
log.warning("Relay error for %s: %s", u[:80], exc)
|
|
self._send_json(500, {"e": str(exc) or type(exc).__name__})
|
|
return
|
|
|
|
log.info("Relay OK %s → HTTP %d (%d B)", u[:80], result["s"], len(result.get("b", "")))
|
|
self._send_json(200, result)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Server entry-point
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class _ThreadedHTTPServer(socketserver.ThreadingMixIn, http.server.HTTPServer):
|
|
"""HTTP server that handles each request in a separate thread."""
|
|
|
|
allow_reuse_address = True
|
|
daemon_threads = True
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser(
|
|
description="MasterHttpRelayVPN — VPS Exit Node Server",
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
epilog=__doc__,
|
|
)
|
|
parser.add_argument(
|
|
"--psk",
|
|
default="",
|
|
metavar="SECRET",
|
|
help="Pre-shared key for authentication (or set EXIT_NODE_PSK env var).",
|
|
)
|
|
parser.add_argument(
|
|
"--host",
|
|
default="0.0.0.0",
|
|
help="Host/IP to listen on (default: 0.0.0.0).",
|
|
)
|
|
parser.add_argument(
|
|
"--port",
|
|
type=int,
|
|
default=8181,
|
|
help="TCP port to listen on (default: 8181).",
|
|
)
|
|
parser.add_argument(
|
|
"--log-level",
|
|
default="INFO",
|
|
choices=["DEBUG", "INFO", "WARNING", "ERROR"],
|
|
help="Logging verbosity (default: INFO).",
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
logging.getLogger().setLevel(args.log_level)
|
|
|
|
if sys.platform != "linux":
|
|
log.error(
|
|
"This VPS exit node is designed for Linux only. "
|
|
"Current platform: %s", sys.platform
|
|
)
|
|
sys.exit(1)
|
|
|
|
global _PSK
|
|
_PSK = (args.psk or os.environ.get("EXIT_NODE_PSK", "")).strip()
|
|
if not _PSK:
|
|
log.error(
|
|
"No PSK configured. Pass --psk YOUR_SECRET or set the "
|
|
"EXIT_NODE_PSK environment variable."
|
|
)
|
|
sys.exit(1)
|
|
|
|
server = _ThreadedHTTPServer((args.host, args.port), _ExitNodeHandler)
|
|
log.info(
|
|
"VPS exit node listening on %s:%d (press Ctrl+C to stop)",
|
|
args.host,
|
|
args.port,
|
|
)
|
|
try:
|
|
server.serve_forever()
|
|
except KeyboardInterrupt:
|
|
log.info("Shutting down.")
|
|
server.shutdown()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|