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
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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 ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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 <ctr> 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
|
||||
|
||||
Reference in New Issue
Block a user