feat: add VPS exit node installer and server scripts

This commit is contained in:
Abolfazl
2026-05-04 08:29:56 +03:30
parent 818f6976a1
commit f222e016e1
3 changed files with 593 additions and 0 deletions
+243
View File
@@ -0,0 +1,243 @@
#!/usr/bin/env bash
# =============================================================================
# MasterHttpRelayVPN — VPS Exit Node Installer (Linux only)
# =============================================================================
# Installs and configures the vps_exit_node.py relay server as a systemd
# service on a fresh Linux VPS.
#
# What this script does:
# 1. Verifies the OS is Linux (aborts otherwise)
# 2. Installs Python 3 if not present
# 3. Creates /opt/exit-node/ and copies vps_exit_node.py there
# 4. Prompts for a PSK (or generates one automatically)
# 5. Prompts for the listen port (default: 8181)
# 6. Writes /etc/exit-node.env (holds EXIT_NODE_PSK — readable by root only)
# 7. Installs a systemd service that auto-starts on boot
# 8. Opens the chosen port in ufw / firewalld if either tool is present
# 9. Prints the final config snippet to paste into config.json
#
# Usage (run as root or with sudo):
# bash setup_vps_exit_node.sh
#
# =============================================================================
set -euo pipefail
# ── Colours ──────────────────────────────────────────────────────────────────
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'
CYAN='\033[0;36m'; BOLD='\033[1m'; NC='\033[0m'
info() { echo -e "${GREEN}[INFO]${NC} $*"; }
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
error() { echo -e "${RED}[ERROR]${NC} $*" >&2; }
header() { echo -e "\n${BOLD}${CYAN}$*${NC}"; }
# ── Platform check ────────────────────────────────────────────────────────────
if [[ "$(uname -s)" != "Linux" ]]; then
error "This installer only supports Linux. Detected: $(uname -s)"
exit 1
fi
# ── Root / sudo check ─────────────────────────────────────────────────────────
if [[ $EUID -ne 0 ]]; then
error "Please run as root or with sudo:"
echo " sudo bash $0"
exit 1
fi
# ── Detect script directory so we can find vps_exit_node.py ──────────────────
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PYTHON_SCRIPT="$SCRIPT_DIR/vps_exit_node.py"
if [[ ! -f "$PYTHON_SCRIPT" ]]; then
error "vps_exit_node.py not found at: $PYTHON_SCRIPT"
error "Run this script from the apps_script/ directory of MasterHttpRelayVPN."
exit 1
fi
# ─────────────────────────────────────────────────────────────────────────────
header "Step 1/7 — Checking Python 3"
# ─────────────────────────────────────────────────────────────────────────────
PYTHON_BIN=""
for candidate in python3 python3.12 python3.11 python3.10; do
if command -v "$candidate" &>/dev/null; then
VER=$("$candidate" -c 'import sys; print("%d%d" % sys.version_info[:2])')
if [[ "$VER" -ge 310 ]]; then
PYTHON_BIN=$(command -v "$candidate")
info "Found $PYTHON_BIN ($(\"$PYTHON_BIN\" --version 2>&1))"
break
fi
fi
done
if [[ -z "$PYTHON_BIN" ]]; then
info "Python 3.10+ not found — attempting install..."
if command -v apt-get &>/dev/null; then
apt-get update -qq
apt-get install -y python3 python3-minimal
elif command -v dnf &>/dev/null; then
dnf install -y python3
elif command -v yum &>/dev/null; then
yum install -y python3
elif command -v pacman &>/dev/null; then
pacman -Sy --noconfirm python
else
error "Cannot determine package manager. Please install Python 3.10+ manually."
exit 1
fi
PYTHON_BIN=$(command -v python3)
info "Installed: $PYTHON_BIN"
fi
# ─────────────────────────────────────────────────────────────────────────────
header "Step 2/7 — Collecting configuration"
# ─────────────────────────────────────────────────────────────────────────────
# Port
read -rp $'\n'"Listen port [default: 8181]: " INPUT_PORT
PORT="${INPUT_PORT:-8181}"
if ! [[ "$PORT" =~ ^[0-9]+$ ]] || [[ "$PORT" -lt 1 ]] || [[ "$PORT" -gt 65535 ]]; then
error "Invalid port: $PORT"
exit 1
fi
info "Port: $PORT"
# PSK
echo ""
read -rp "Pre-shared key (leave empty to auto-generate): " INPUT_PSK
if [[ -z "$INPUT_PSK" ]]; then
PSK=$("$PYTHON_BIN" -c "import secrets; print(secrets.token_hex(32))")
info "Auto-generated PSK: ${BOLD}${PSK}${NC}"
warn "Save this PSK — you will need it in config.json on your client."
else
PSK="$INPUT_PSK"
info "Using provided PSK."
fi
# ─────────────────────────────────────────────────────────────────────────────
header "Step 3/7 — Installing server files"
# ─────────────────────────────────────────────────────────────────────────────
INSTALL_DIR="/opt/exit-node"
mkdir -p "$INSTALL_DIR"
cp "$PYTHON_SCRIPT" "$INSTALL_DIR/vps_exit_node.py"
chmod 700 "$INSTALL_DIR/vps_exit_node.py"
info "Installed to $INSTALL_DIR/vps_exit_node.py"
# ─────────────────────────────────────────────────────────────────────────────
header "Step 4/7 — Writing environment file"
# ─────────────────────────────────────────────────────────────────────────────
ENV_FILE="/etc/exit-node.env"
cat > "$ENV_FILE" <<EOF
# MasterHttpRelayVPN VPS exit node — environment configuration
# Generated by setup_vps_exit_node.sh
EXIT_NODE_PSK=${PSK}
EOF
chmod 600 "$ENV_FILE"
info "Environment file written to $ENV_FILE (mode 600)"
# ─────────────────────────────────────────────────────────────────────────────
header "Step 5/7 — Installing systemd service"
# ─────────────────────────────────────────────────────────────────────────────
SERVICE_FILE="/etc/systemd/system/exit-node.service"
cat > "$SERVICE_FILE" <<EOF
[Unit]
Description=MasterHttpRelayVPN Exit Node
Documentation=https://github.com/masterking32/MasterHttpRelayVPN
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
EnvironmentFile=/etc/exit-node.env
ExecStart=${PYTHON_BIN} ${INSTALL_DIR}/vps_exit_node.py --port ${PORT}
Restart=always
RestartSec=5
StandardOutput=journal
StandardError=journal
# Security hardening
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=strict
ReadWritePaths=/var/log
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable exit-node
systemctl restart exit-node
info "Service installed and started."
# Give it a moment to confirm it really started.
sleep 2
if systemctl is-active --quiet exit-node; then
info "Service is ${GREEN}running${NC}."
else
warn "Service may not have started. Check: journalctl -u exit-node -n 30"
fi
# ─────────────────────────────────────────────────────────────────────────────
header "Step 6/7 — Firewall"
# ─────────────────────────────────────────────────────────────────────────────
if command -v ufw &>/dev/null; then
ufw allow "$PORT"/tcp comment "exit-node" || true
info "ufw rule added for port $PORT/tcp"
elif command -v firewall-cmd &>/dev/null; then
firewall-cmd --permanent --add-port="$PORT"/tcp || true
firewall-cmd --reload || true
info "firewalld rule added for port $PORT/tcp"
else
warn "No ufw or firewalld found. Make sure port $PORT/tcp is open in your VPS firewall panel."
fi
# ─────────────────────────────────────────────────────────────────────────────
header "Step 7/7 — Health check"
# ─────────────────────────────────────────────────────────────────────────────
HEALTH_URL="http://127.0.0.1:${PORT}/"
if command -v curl &>/dev/null; then
HEALTH=$(curl -sf --max-time 5 "$HEALTH_URL" 2>/dev/null || echo "")
if echo "$HEALTH" | grep -q '"ok"'; then
info "Health check OK: $HEALTH"
else
warn "Health check returned unexpected response. Check: journalctl -u exit-node -n 30"
fi
else
warn "curl not found — skipping health check. You can test manually:"
echo " curl http://127.0.0.1:${PORT}/"
fi
# ─────────────────────────────────────────────────────────────────────────────
PUBLIC_IP=$(curl -sf --max-time 5 https://ifconfig.me 2>/dev/null || echo "YOUR-VPS-IP")
echo ""
echo -e "${BOLD}${GREEN}============================================================${NC}"
echo -e "${BOLD} Installation complete!${NC}"
echo -e "${BOLD}${GREEN}============================================================${NC}"
echo ""
echo -e " Service status : ${CYAN}systemctl status exit-node${NC}"
echo -e " Live logs : ${CYAN}journalctl -fu exit-node${NC}"
echo -e " Uninstall : ${CYAN}systemctl disable --now exit-node && rm $SERVICE_FILE $ENV_FILE${NC}"
echo ""
echo -e "${BOLD}Add this to config.json on your client machine:${NC}"
echo ""
echo -e "${CYAN}"
cat <<JSON
"exit_node": {
"enabled": true,
"provider": "vps",
"url": "http://${PUBLIC_IP}:${PORT}",
"psk": "${PSK}",
"mode": "full",
"hosts": []
}
JSON
echo -e "${NC}"
warn "For production: put nginx/Caddy in front and use HTTPS."
warn "Replace the IP with your domain name if you have one."
+340
View File
@@ -0,0 +1,340 @@
#!/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",
]
)
# 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 = ""
# ---------------------------------------------------------------------------
# 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 _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 urllib.request.urlopen(request, timeout=_OUTBOUND_TIMEOUT) as resp:
data = resp.read(_MAX_RESPONSE_BODY)
resp_headers: dict[str, str] = {}
for k, v in resp.headers.items():
resp_headers[k] = v
return {
"s": resp.status,
"h": resp_headers,
"b": base64.b64encode(data).decode(),
}
except urllib.error.HTTPError as exc:
data = exc.read(_MAX_RESPONSE_BODY) if exc.fp else b""
resp_headers = {}
if exc.headers:
for k, v in exc.headers.items():
resp_headers[k] = v
return {
"s": exc.code,
"h": resp_headers,
"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()