Files

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()