commit 505da8914401041f6762f9b603beac7b218cb6f2 Author: Malin Date: Wed Mar 11 09:45:42 2026 +0100 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. diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..cbd1ee2 --- /dev/null +++ b/.env.example @@ -0,0 +1,16 @@ +# Copy to .env and fill in your details: +# cp .env.example .env + +# ── PureVPN credentials ─────────────────────────────────────────────────────── +PUREVPN_USER=your@email.com +PUREVPN_PASS=yourpassword + +# ── Location pool (comma-separated, no spaces around commas) ───────────────── +# Each vpn-node picks randomly from this list and rotates on reconnect. +# Supports full country names or 2-letter codes — both work with purevpn-cli. +# Leave blank to use vpn-node/servers.txt instead. +# +PUREVPN_LOCATIONS=United States,United Kingdom,Germany,Netherlands,France,Canada,Australia,Singapore,Japan,Switzerland + +# ── SOCKS5 listen port on the host (browser points here) ───────────────────── +SOCKS5_PORT=1080 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2334d82 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.env +*.log diff --git a/deploy.sh b/deploy.sh new file mode 100755 index 0000000..9dcb613 --- /dev/null +++ b/deploy.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# deploy.sh – Build and scale the vpndock stack +# +# Usage: +# ./deploy.sh → deploy 3 VPN exit nodes (default) +# ./deploy.sh 5 → deploy 5 VPN exit nodes +# ./deploy.sh 10 → deploy 10 VPN exit nodes (PureVPN's max simultaneous connections) +# ./deploy.sh 0 → tear down the stack +# +set -euo pipefail + +SCALE=${1:-3} +MAX_CONNECTIONS=10 +COMPOSE="docker compose" + +# ── Sanity checks ───────────────────────────────────────────────────────────── +if [[ ! -f .env ]]; then + echo "ERROR: .env file not found. Copy .env.example and fill in your credentials:" + echo " cp .env.example .env && nano .env" + exit 1 +fi + +if [[ "$SCALE" -gt "$MAX_CONNECTIONS" ]]; then + echo "WARNING: PureVPN allows max $MAX_CONNECTIONS simultaneous connections." + echo " Capping scale at $MAX_CONNECTIONS." + SCALE=$MAX_CONNECTIONS +fi + +# ── Tear down ───────────────────────────────────────────────────────────────── +if [[ "$SCALE" -eq 0 ]]; then + echo "Tearing down vpndock stack …" + $COMPOSE down + exit 0 +fi + +# ── Build & deploy ──────────────────────────────────────────────────────────── +echo "──────────────────────────────────────────────" +echo " vpndock deploy" +echo " VPN exit nodes : $SCALE" +echo " SOCKS5 port : ${SOCKS5_PORT:-1080} (on host)" +echo " Stats UI : http://localhost:8404/stats" +echo "──────────────────────────────────────────────" + +echo "" +echo "Building images …" +$COMPOSE build + +echo "" +echo "Starting stack with $SCALE VPN exit node(s) …" +$COMPOSE up -d --scale vpn-node="$SCALE" + +echo "" +echo "Stack is up. Waiting for containers to stabilise …" +sleep 5 +$COMPOSE ps + +echo "" +echo "Done! Configure your browser's SOCKS5 proxy:" +echo " Host : $(hostname -I | awk '{print $1}')" +echo " Port : ${SOCKS5_PORT:-1080}" +echo "" +echo "HAProxy stats: http://localhost:8404/stats (admin / admin)" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..42acfa3 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,60 @@ +version: '3.8' + +services: + + # ─── Entry point ─────────────────────────────────────────────────────────── + haproxy: + image: haproxy:2.9-alpine + container_name: vpndock-haproxy + restart: unless-stopped + ports: + - "${SOCKS5_PORT:-1080}:1080" # SOCKS5 proxy for browsers/clients + - "8404:8404" # HAProxy stats UI + volumes: + - ./haproxy/haproxy.cfg:/usr/local/etc/haproxy/haproxy.cfg:ro + networks: + - proxy-net + depends_on: + - vpn-node + + # ─── VPN exit nodes ──────────────────────────────────────────────────────── + # Scale with: docker compose up -d --scale vpn-node=N (max 10) + vpn-node: + build: + context: ./vpn-node + dockerfile: Dockerfile + restart: unless-stopped + cap_add: + - NET_ADMIN + devices: + - /dev/net/tun:/dev/net/tun + environment: + - PUREVPN_USER=${PUREVPN_USER} + - PUREVPN_PASS=${PUREVPN_PASS} + # Comma-separated list of locations each container picks from randomly. + # Leave blank to use vpn-node/servers.txt instead. + # Example: "United States,United Kingdom,Germany,Netherlands,France" + - PUREVPN_LOCATIONS=${PUREVPN_LOCATIONS:-} + - SOCKS5_INNER_PORT=1080 + env_file: + - .env + networks: + - proxy-net + expose: + - "1080" + sysctls: + - net.ipv4.conf.all.rp_filter=2 + - net.ipv6.conf.all.disable_ipv6=1 + healthcheck: + test: ["CMD-SHELL", "nc -z 127.0.0.1 1080 || exit 1"] + interval: 20s + timeout: 5s + retries: 5 + start_period: 45s + +networks: + proxy-net: + driver: bridge + ipam: + config: + - subnet: 172.28.0.0/24 diff --git a/haproxy/haproxy.cfg b/haproxy/haproxy.cfg new file mode 100644 index 0000000..78a719d --- /dev/null +++ b/haproxy/haproxy.cfg @@ -0,0 +1,55 @@ +global + log stdout format raw local0 info + maxconn 50000 + # Run as non-root inside the container + # user haproxy + # group haproxy + +defaults + log global + mode tcp + option tcplog + option dontlognull + retries 3 + timeout connect 10s + timeout client 1m + timeout server 1m + timeout check 5s + +# ── Docker embedded DNS ─────────────────────────────────────────────────────── +resolvers docker_dns + nameserver dns1 127.0.0.11:53 + resolve_retries 10 + timeout resolve 1s + timeout retry 1s + hold valid 10s + hold other 10s + hold refused 10s + hold nx 10s + +# ── SOCKS5 frontend (browsers / curl / etc. connect here) ──────────────────── +frontend socks5_in + bind *:1080 + default_backend vpn_exit_nodes + +# ── Round-robin across all vpn-node containers ─────────────────────────────── +backend vpn_exit_nodes + balance roundrobin + # server-template creates up to 10 slots; Docker DNS fills them dynamically + # as you scale with: docker compose up --scale vpn-node=N (max 10) + server-template vpn 1-10 vpn-node:1080 \ + resolvers docker_dns \ + resolve-prefer ipv4 \ + init-addr none \ + check inter 20s fall 2 rise 2 + +# ── Stats page — http://:8404/stats ───────────────────────────────────── +frontend stats + bind *:8404 + mode http + stats enable + stats uri /stats + stats refresh 5s + stats show-legends + stats show-node + stats auth admin:admin # change this password diff --git a/vpn-node/Dockerfile b/vpn-node/Dockerfile new file mode 100644 index 0000000..60527cf --- /dev/null +++ b/vpn-node/Dockerfile @@ -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"] diff --git a/vpn-node/entrypoint.sh b/vpn-node/entrypoint.sh new file mode 100755 index 0000000..3434881 --- /dev/null +++ b/vpn-node/entrypoint.sh @@ -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 diff --git a/vpn-node/servers.txt b/vpn-node/servers.txt new file mode 100644 index 0000000..4f2b66c --- /dev/null +++ b/vpn-node/servers.txt @@ -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