From f222e016e1ec4323a1de735d578ca5db02b92201 Mon Sep 17 00:00:00 2001 From: Abolfazl Date: Mon, 4 May 2026 08:29:56 +0330 Subject: [PATCH] feat: add VPS exit node installer and server scripts --- apps_script/setup_vps_exit_node.sh | 243 +++++++++++++++++++++ apps_script/vps_exit_node.py | 340 +++++++++++++++++++++++++++++ src/relay/domain_fronter.py | 10 + 3 files changed, 593 insertions(+) create mode 100644 apps_script/setup_vps_exit_node.sh create mode 100644 apps_script/vps_exit_node.py diff --git a/apps_script/setup_vps_exit_node.sh b/apps_script/setup_vps_exit_node.sh new file mode 100644 index 0000000..7fbbe7a --- /dev/null +++ b/apps_script/setup_vps_exit_node.sh @@ -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" < "$SERVICE_FILE" </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 < 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() diff --git a/src/relay/domain_fronter.py b/src/relay/domain_fronter.py index 37606d7..7685ae1 100644 --- a/src/relay/domain_fronter.py +++ b/src/relay/domain_fronter.py @@ -1122,6 +1122,10 @@ class DomainFronter: "worker": "cloudflare", "cf": "cloudflare", "deno_deploy": "deno", + "self_hosted": "vps", + "self-hosted": "vps", + "selfhosted": "vps", + "server": "vps", } return aliases.get(provider, provider or "custom") @@ -1160,6 +1164,12 @@ class DomainFronter: selected = _pick_from(en_cfg, "deno_url") or _pick_from( providers, "deno", "deno_deploy", ) + elif provider == "vps": + selected = _pick_from( + en_cfg, "vps_url", "server_url", "self_hosted_url", + ) or _pick_from( + providers, "vps", "self_hosted", "server", + ) else: selected = ""