feat: initial vpndock stack

HAProxy SOCKS5 entry point + scalable purevpn-cli/microsocks exit nodes.
Supports up to 10 simultaneous connections (PureVPN limit), random location
selection from a predefined pool, and automatic reconnect to an unused
location on server-side drop.
This commit is contained in:
2026-03-11 09:45:42 +01:00
commit 505da89144
8 changed files with 466 additions and 0 deletions

40
vpn-node/Dockerfile Normal file
View File

@@ -0,0 +1,40 @@
FROM debian:bookworm-slim
LABEL description="microsocks + purevpn-cli exit node"
# ── System dependencies ───────────────────────────────────────────────────────
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc make git \
curl wget ca-certificates \
iproute2 iptables iputils-ping \
netcat-openbsd procps dnsutils \
expect \
&& rm -rf /var/lib/apt/lists/*
# ── Install purevpn-cli (official installer) ──────────────────────────────────
# Running as root inside Docker so no sudo needed.
RUN curl -fsSL https://apps.purevpn-tools.com/cross-platform/linux-cli/production/cli-install.sh \
-o /tmp/cli-install.sh \
&& bash /tmp/cli-install.sh \
&& rm -f /tmp/cli-install.sh
# ── Add purevpn-cli to PATH (as per official docs) ────────────────────────────
ENV PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/snap/bin:/etc/pure-linux-cli/
# ── Build microsocks from source ──────────────────────────────────────────────
RUN git clone --depth 1 https://github.com/rofl0r/microsocks.git /tmp/microsocks \
&& cd /tmp/microsocks \
&& make \
&& cp microsocks /usr/local/bin/microsocks \
&& rm -rf /tmp/microsocks
# ── Location list ─────────────────────────────────────────────────────────────
COPY servers.txt /etc/vpndock/servers.txt
# ── Entrypoint ────────────────────────────────────────────────────────────────
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
EXPOSE 1080
ENTRYPOINT ["/entrypoint.sh"]

214
vpn-node/entrypoint.sh Executable file
View File

@@ -0,0 +1,214 @@
#!/usr/bin/env bash
# entrypoint.sh
# Starts purevpn-cli (randomly selected location, rotates on reconnect)
# then starts microsocks bound to 0.0.0.0 so HAProxy can reach it.
set -euo pipefail
export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/snap/bin:/etc/pure-linux-cli/
SOCKS5_PORT="${SOCKS5_INNER_PORT:-1080}"
VPN_IF="tun0"
VPN_WAIT=60 # max seconds to wait for tun0 to appear
# ── 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 microsocks despite VPN kill-switch ────────────
whitelist_eth0() {
local ip
ip=$(ip -4 addr show eth0 2>/dev/null | grep -oP '(?<=inet\s)\d+(\.\d+){3}' || true)
if [[ -n "$ip" ]]; then
iptables -I INPUT -i eth0 -j ACCEPT 2>/dev/null || true
iptables -I OUTPUT -o eth0 -j ACCEPT 2>/dev/null || true
log "eth0 ($ip) whitelisted — HAProxy can reach microsocks"
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 microsocks ────────────────────────────────────────────────
start_socks() {
[[ -n "$SOCKS_PID" ]] && kill "$SOCKS_PID" 2>/dev/null || true
log "Starting microsocks on 0.0.0.0:${SOCKS5_PORT}"
microsocks -p "$SOCKS5_PORT" &
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 ))
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"
fi
else
FAIL_COUNT=0 # reset on any healthy check
fi
# ── microsocks process check ──────────────────────────────────────────────
if ! kill -0 "$SOCKS_PID" 2>/dev/null; then
log "microsocks died — restarting"
start_socks
fi
done

17
vpn-node/servers.txt Normal file
View File

@@ -0,0 +1,17 @@
# PureVPN locations — one per line
# Use country name ("United States") or 2-letter code ("US") — both work with:
# purevpn-cli --connect "United States" OR purevpn-cli -c "US"
#
# Run `purevpn-cli --list` to see all available locations after installing.
# Lines starting with # are ignored.
United States
United Kingdom
Germany
Netherlands
France
Canada
Australia
Singapore
Japan
Switzerland