From 173bd8743791346eb5ef072ef0a86262eae0c88f Mon Sep 17 00:00:00 2001 From: Malin Date: Thu, 12 Mar 2026 07:23:19 +0100 Subject: [PATCH] feat: manual connect mode + env -i sudo wrapper to fix pkg bootstrap crash - MANUAL_CONNECT=true: container waits for tun0, user connects via docker exec - MANUAL_CONNECT=false: auto mode (current), now with env -i in sudo wrapper - sudo wrapper logs inherited env key names so we can see what parent injects - monitor_loop extracted as shared function used by both modes - auto mode connect logic cleaned up into a single while-true rotation loop --- docker-compose.yml | 3 + vpn-node/Dockerfile | 11 +- vpn-node/entrypoint.sh | 348 +++++++++++++++++++---------------------- 3 files changed, 169 insertions(+), 193 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 42acfa3..bd2526d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -36,6 +36,9 @@ services: # Example: "United States,United Kingdom,Germany,Netherlands,France" - PUREVPN_LOCATIONS=${PUREVPN_LOCATIONS:-} - SOCKS5_INNER_PORT=1080 + # MANUAL_CONNECT=true → container waits; you run purevpn-cli manually via docker exec + # MANUAL_CONNECT=false → auto login + connect (default) + - MANUAL_CONNECT=${MANUAL_CONNECT:-false} env_file: - .env networks: diff --git a/vpn-node/Dockerfile b/vpn-node/Dockerfile index e40689f..df30703 100644 --- a/vpn-node/Dockerfile +++ b/vpn-node/Dockerfile @@ -35,11 +35,12 @@ RUN echo "=== binary type ===" \ && echo "=== bin/purevpn-cli header ===" \ && head -3 /opt/purevpn-cli/bin/purevpn-cli 2>/dev/null || true -# ── Fake sudo — last-resort safety net ─────────────────────────────────────── -# If purevpn-cli still calls sudo despite the stub above, this wrapper runs -# the command minus --install-missing-components so it doesn't crash the -# pkg/Node.js bootstrap. The echo lets us see in logs if it fires. -RUN printf '#!/bin/bash\nnew=()\nfor a in "$@"; do\n [[ "$a" == "--install-missing-components" ]] && { echo "[sudo] stripped --install-missing-components"; continue; }\n new+=("$a")\ndone\necho "[sudo] exec: ${new[*]}"\nexec "${new[@]}"\n' \ +# ── Fake sudo wrapper ──────────────────────────────────────────────────────── +# Strips --install-missing-components (crashes pkg bootstrap when combined with +# --connect) then re-runs the binary with a CLEAN environment (env -i) so that +# any env vars set by the parent purevpn-cli don't corrupt the child's pkg +# bootstrap. Prints env key names and exec'd command for diagnosis. +RUN printf '#!/bin/bash\necho "[sudo] env: $(env | cut -d= -f1 | tr "\\n" " ")"\nnew=()\nfor a in "$@"; do\n [[ "$a" == "--install-missing-components" ]] && { echo "[sudo] stripped --install-missing-components"; continue; }\n new+=("$a")\ndone\necho "[sudo] exec (clean env): ${new[*]}"\nexec env -i PATH="$PATH" HOME=/root USER=root "${new[@]}"\n' \ > /usr/local/bin/sudo && chmod +x /usr/local/bin/sudo # ── PATH ────────────────────────────────────────────────────────────────────── diff --git a/vpn-node/entrypoint.sh b/vpn-node/entrypoint.sh index f770482..b6ed760 100755 --- a/vpn-node/entrypoint.sh +++ b/vpn-node/entrypoint.sh @@ -1,70 +1,29 @@ #!/usr/bin/env bash # entrypoint.sh -# Starts purevpn-cli (randomly selected location, rotates on reconnect) -# then starts dante SOCKS5 proxy bound to 0.0.0.0, routing out through tun0. +# +# Two modes (set via docker-compose env var): +# +# MANUAL_CONNECT=false (default) — auto login + connect via purevpn-cli +# MANUAL_CONNECT=true — wait for tun0 to appear; user connects +# manually with: docker exec -it bash +# then: purevpn-cli --login +# purevpn-cli --connect "Germany" +# set -euo pipefail export PATH=/opt/purevpn-cli/bin:/opt/purevpn-cli:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin SOCKS5_PORT="${SOCKS5_INNER_PORT:-1080}" +MANUAL_CONNECT="${MANUAL_CONNECT:-false}" VPN_IF="tun0" -VPN_WAIT=60 # max seconds to wait for tun0 to appear +VPN_WAIT=60 -# ── Logging ─────────────────────────────────────────────────────────────────── log() { echo "[$(date '+%H:%M:%S')] [$(hostname)] $*"; } die() { log "FATAL: $*" >&2; exit 1; } -# ── Build location pool ─────────────────────────────────────────────────────── -# Priority: env var PUREVPN_LOCATIONS (comma-separated) > servers.txt -declare -a ALL_LOCATIONS -if [[ -n "${PUREVPN_LOCATIONS:-}" ]]; then - IFS=',' read -ra ALL_LOCATIONS <<< "$PUREVPN_LOCATIONS" - # Trim whitespace from each element - for i in "${!ALL_LOCATIONS[@]}"; do - ALL_LOCATIONS[$i]="${ALL_LOCATIONS[$i]#"${ALL_LOCATIONS[$i]%%[![:space:]]*}"}" - ALL_LOCATIONS[$i]="${ALL_LOCATIONS[$i]%"${ALL_LOCATIONS[$i]##*[![:space:]]}"}" - done -else - mapfile -t ALL_LOCATIONS < <( - grep -v '^\s*#' /etc/vpndock/servers.txt | grep -v '^\s*$' - ) -fi - -[[ ${#ALL_LOCATIONS[@]} -eq 0 ]] && die "No locations found. Set PUREVPN_LOCATIONS or populate servers.txt" -[[ -z "${PUREVPN_USER:-}" ]] && die "PUREVPN_USER is not set" -[[ -z "${PUREVPN_PASS:-}" ]] && die "PUREVPN_PASS is not set" - -log "Location pool (${#ALL_LOCATIONS[@]}): ${ALL_LOCATIONS[*]}" - -# ── Rotation state ──────────────────────────────────────────────────────────── -declare -a USED_LOCATIONS=() -CURRENT_LOCATION="" -VPN_PID="" SOCKS_PID="" -# Pick a random location that hasn't been used yet. -# Resets the used list when all locations are exhausted. -pick_location() { - local available=() - for loc in "${ALL_LOCATIONS[@]}"; do - local already_used=false - for used in "${USED_LOCATIONS[@]:-}"; do - [[ "$loc" == "$used" ]] && already_used=true && break - done - $already_used || available+=("$loc") - done - - if [[ ${#available[@]} -eq 0 ]]; then - log "All locations tried — resetting rotation pool" - USED_LOCATIONS=() - available=("${ALL_LOCATIONS[@]}") - fi - - local idx=$(( RANDOM % ${#available[@]} )) - echo "${available[$idx]}" -} - -# ── iptables: let HAProxy reach danted despite VPN kill-switch ─────────────── +# ── iptables: let HAProxy reach danted regardless of VPN kill-switch ───────── whitelist_eth0() { local ip ip=$(ip -4 addr show eth0 2>/dev/null | grep -oP '(?<=inet\s)\d+(\.\d+){3}' || true) @@ -75,170 +34,183 @@ whitelist_eth0() { fi } -# ── Non-interactive login via expect ───────────────────────────────────────── -do_login() { - log "Logging into PureVPN …" - expect -c " - set timeout 30 - spawn purevpn-cli --login - expect -re {[Ee]mail|[Uu]sername|[Ll]ogin} { send \"$PUREVPN_USER\r\" } - expect -re {[Pp]assword} { send \"$PUREVPN_PASS\r\" } - expect eof - " && log "Login OK" || { - # Fallback: pipe credentials (works on some CLI versions) - log "expect login failed, trying stdin …" - printf '%s\n%s\n' "$PUREVPN_USER" "$PUREVPN_PASS" | purevpn-cli --login || true - } -} - -# ── Disconnect any existing VPN session ────────────────────────────────────── -vpn_disconnect() { - purevpn-cli --disconnect 2>/dev/null || true - [[ -n "$VPN_PID" ]] && kill "$VPN_PID" 2>/dev/null || true - VPN_PID="" - # Give the tunnel time to tear down - sleep 3 -} - -# ── Connect to a new (random, unused) location ─────────────────────────────── -vpn_connect() { - CURRENT_LOCATION="$(pick_location)" - USED_LOCATIONS+=("$CURRENT_LOCATION") - log "Connecting → '$CURRENT_LOCATION' (used so far: ${USED_LOCATIONS[*]})" - purevpn-cli --connect "$CURRENT_LOCATION" & - VPN_PID=$! -} - -# ── Wait for tun0 to appear; return 1 on timeout ──────────────────────────── -wait_for_tunnel() { - local waited=0 - while ! ip link show "$VPN_IF" &>/dev/null; do - sleep 2; waited=$(( waited + 2 )) - [[ $waited -ge $VPN_WAIT ]] && return 1 - done - return 0 -} - -# ── Start / restart dante SOCKS5 proxy ─────────────────────────────────────── -# dante's `external: tun0` means all proxied traffic leaves via the VPN — -# no iptables tricks needed for outbound routing. +# ── Start / restart danted ──────────────────────────────────────────────────── start_socks() { [[ -n "$SOCKS_PID" ]] && kill "$SOCKS_PID" 2>/dev/null || true - - # Generate config fresh each time (tun0 must already be up) cat > /etc/danted.conf << EOF logoutput: stderr user.privileged: root user.unprivileged: nobody - -# Listen on the Docker network interface so HAProxy can reach us internal: 0.0.0.0 port = ${SOCKS5_PORT} - -# Route ALL proxied traffic out through the VPN tunnel external: ${VPN_IF} - socksmethod: none clientmethod: none - client pass { from: 0.0.0.0/0 to: 0.0.0.0/0 log: error } - socks pass { from: 0.0.0.0/0 to: 0.0.0.0/0 command: bind connect udpassociate log: error } EOF - log "Starting danted on 0.0.0.0:${SOCKS5_PORT} → external: ${VPN_IF}" danted -f /etc/danted.conf & SOCKS_PID=$! } -# ── Connect with automatic fallback to next location on failure ─────────────── -connect_with_fallback() { - local attempts=0 - local max_attempts=${#ALL_LOCATIONS[@]} - - while [[ $attempts -lt $max_attempts ]]; do - vpn_connect - if wait_for_tunnel; then - local ext_ip - ext_ip=$(curl -sf --max-time 8 https://api4.my-ip.io/ip || echo "unknown") - log "VPN up — location: '$CURRENT_LOCATION' | exit IP: $ext_ip" - return 0 - fi - log "'$CURRENT_LOCATION' timed out after ${VPN_WAIT}s — trying next location …" - vpn_disconnect - attempts=$(( attempts + 1 )) +# ── Wait for tun0; return 1 on timeout ─────────────────────────────────────── +wait_for_tunnel() { + local waited=0 limit="${1:-$VPN_WAIT}" + while ! ip link show "$VPN_IF" &>/dev/null; do + sleep 2; waited=$(( waited + 2 )) + [[ $waited -ge $limit ]] && return 1 done - - die "Could not connect to any location after $attempts attempts" -} - -# ═══════════════════════════════════════════════════════════════════════════════ -# Main -# ═══════════════════════════════════════════════════════════════════════════════ -whitelist_eth0 -do_login -connect_with_fallback -start_socks - -# ── Reconnect helper (shared by monitor triggers) ──────────────────────────── -do_reconnect() { - local reason="$1" - log "Reconnecting — reason: $reason (was '$CURRENT_LOCATION')" - - kill "$SOCKS_PID" 2>/dev/null || true - SOCKS_PID="" - - vpn_disconnect - connect_with_fallback - start_socks -} - -# ── Tunnel liveness: interface up AND traffic actually flows ────────────────── -# Returns 0 if healthy, 1 if dead/stalled. -tunnel_alive() { - # 1. Interface must exist - ip link show "$VPN_IF" &>/dev/null || return 1 - - # 2. purevpn-cli process must still be running - [[ -n "$VPN_PID" ]] && kill -0 "$VPN_PID" 2>/dev/null || return 1 - - # 3. Traffic must actually flow through the tunnel (ghost-interface check). - # Sends 2 pings via the VPN interface with a 5-second timeout each. - ping -c 2 -W 5 -I "$VPN_IF" 1.1.1.1 &>/dev/null || return 1 - return 0 } -# ── Monitor loop ────────────────────────────────────────────────────────────── -log "Entering monitor loop (checks every 15s) …" -FAIL_COUNT=0 -MAX_FAILS=2 # consecutive failed checks before reconnecting - -while true; do - sleep 15 - - # ── VPN health check (interface + process + traffic) ───────────────────── - if ! tunnel_alive; then - FAIL_COUNT=$(( FAIL_COUNT + 1 )) - log "Health check failed ($FAIL_COUNT/$MAX_FAILS) for '$CURRENT_LOCATION'" - - if [[ $FAIL_COUNT -ge $MAX_FAILS ]]; then - FAIL_COUNT=0 - do_reconnect "server drop / tunnel stalled" +# ── Monitor: watch tunnel + danted; reconnect on drop ──────────────────────── +monitor_loop() { + log "Monitor loop started (15s interval)" + local fail=0 + while true; do + sleep 15 + # Tunnel health + if ! ip link show "$VPN_IF" &>/dev/null \ + || ! ping -c 1 -W 5 -I "$VPN_IF" 1.1.1.1 &>/dev/null; then + fail=$(( fail + 1 )) + log "Tunnel check failed ($fail/2)" + if [[ $fail -ge 2 ]]; then + fail=0 + log "Tunnel lost — stopping danted" + kill "$SOCKS_PID" 2>/dev/null || true + SOCKS_PID="" + return 1 # caller decides what to do next + fi + else + fail=0 fi - else - FAIL_COUNT=0 # reset on any healthy check - fi + # danted health + if ! kill -0 "$SOCKS_PID" 2>/dev/null; then + log "danted died — restarting" + start_socks + fi + done +} - # ── danted process check ────────────────────────────────────────────────── - if ! kill -0 "$SOCKS_PID" 2>/dev/null; then - log "danted died — restarting" - start_socks +# ════════════════════════════════════════════════════════════════════════════════ +# ── MANUAL MODE ────────────────────────────────────────────────────────────── +# ════════════════════════════════════════════════════════════════════════════════ +manual_mode() { + log "═══ MANUAL CONNECT MODE ═══" + log "Connect to this container with:" + log " docker exec -it $(hostname) bash" + log "Then run:" + log " purevpn-cli --login" + log " purevpn-cli --connect \"Germany\" # or any location" + + while true; do + log "Waiting for $VPN_IF to appear …" + # Wait indefinitely (3600s per loop iteration) + if wait_for_tunnel 3600; then + local ext_ip + ext_ip=$(curl -sf --max-time 8 https://api4.my-ip.io/ip || echo "unknown") + log "Tunnel UP — exit IP: $ext_ip" + start_socks + # Monitor until tunnel drops, then loop back and wait again + monitor_loop || log "Tunnel dropped — waiting for reconnect" + fi + done +} + +# ════════════════════════════════════════════════════════════════════════════════ +# ── AUTO MODE ──────────────────────────────────────────────────────────────── +# ════════════════════════════════════════════════════════════════════════════════ +auto_mode() { + # ── Location pool ──────────────────────────────────────────────────────── + declare -a ALL_LOCATIONS + if [[ -n "${PUREVPN_LOCATIONS:-}" ]]; then + IFS=',' read -ra ALL_LOCATIONS <<< "$PUREVPN_LOCATIONS" + for i in "${!ALL_LOCATIONS[@]}"; do + ALL_LOCATIONS[$i]="${ALL_LOCATIONS[$i]#"${ALL_LOCATIONS[$i]%%[![:space:]]*}"}" + ALL_LOCATIONS[$i]="${ALL_LOCATIONS[$i]%"${ALL_LOCATIONS[$i]##*[![:space:]]}"}" + done + else + mapfile -t ALL_LOCATIONS < <(grep -v '^\s*#' /etc/vpndock/servers.txt | grep -v '^\s*$') fi -done + [[ ${#ALL_LOCATIONS[@]} -eq 0 ]] && die "No locations found" + [[ -z "${PUREVPN_USER:-}" ]] && die "PUREVPN_USER not set" + [[ -z "${PUREVPN_PASS:-}" ]] && die "PUREVPN_PASS not set" + log "Location pool (${#ALL_LOCATIONS[@]}): ${ALL_LOCATIONS[*]}" + + declare -a USED_LOCATIONS=() + VPN_PID="" + + pick_location() { + local available=() + for loc in "${ALL_LOCATIONS[@]}"; do + local used=false + for u in "${USED_LOCATIONS[@]:-}"; do [[ "$loc" == "$u" ]] && used=true && break; done + $used || available+=("$loc") + done + if [[ ${#available[@]} -eq 0 ]]; then + log "All locations used — resetting pool" + USED_LOCATIONS=(); available=("${ALL_LOCATIONS[@]}") + fi + echo "${available[$(( RANDOM % ${#available[@]} ))]}" + } + + # ── Login ──────────────────────────────────────────────────────────────── + log "Logging into PureVPN …" + expect -c " + set timeout 120 + spawn purevpn-cli --login + expect -re {[Ee]mail|[Uu]sername|[Ll]ogin} { send \"$PUREVPN_USER\r\" } + expect -re {[Pp]assword} { send \"$PUREVPN_PASS\r\" } + expect eof + " && log "Login OK" || { + log "expect login failed, trying stdin …" + printf '%s\n%s\n' "$PUREVPN_USER" "$PUREVPN_PASS" | purevpn-cli --login || true + } + + # ── Connect loop (rotates through locations on failure/drop) ───────────── + while true; do + local loc + loc=$(pick_location) + USED_LOCATIONS+=("$loc") + log "Connecting → '$loc'" + + purevpn-cli --connect "$loc" & + VPN_PID=$! + + if wait_for_tunnel "$VPN_WAIT"; then + local ext_ip + ext_ip=$(curl -sf --max-time 8 https://api4.my-ip.io/ip || echo "unknown") + log "VPN up — location: '$loc' | exit IP: $ext_ip" + start_socks + monitor_loop || true + # Tunnel dropped — kill VPN process and try next location + kill "$VPN_PID" 2>/dev/null || true + purevpn-cli --disconnect 2>/dev/null || true + sleep 3 + log "Rotating to next location …" + else + log "'$loc' timed out — trying next location" + kill "$VPN_PID" 2>/dev/null || true + purevpn-cli --disconnect 2>/dev/null || true + sleep 3 + fi + done +} + +# ════════════════════════════════════════════════════════════════════════════════ +# Main +# ════════════════════════════════════════════════════════════════════════════════ +whitelist_eth0 + +if [[ "$MANUAL_CONNECT" == "true" ]]; then + manual_mode +else + auto_mode +fi