├── .gitignore ├── root ├── etc │ └── s6-overlay │ │ └── s6-rc.d │ │ ├── svc-cron │ │ ├── type │ │ ├── dependencies │ │ └── run │ │ ├── svc-nordvpn │ │ ├── type │ │ ├── timeout-finish │ │ ├── dependencies │ │ ├── finish │ │ └── run │ │ ├── user │ │ └── contents.d │ │ │ ├── svc-cron │ │ │ └── svc-nordvpn │ │ ├── init-adduser │ │ ├── type │ │ ├── up │ │ └── run │ │ ├── init-firewall │ │ ├── type │ │ ├── up │ │ └── run │ │ ├── init-createauth │ │ ├── type │ │ ├── dependencies │ │ ├── up │ │ └── run │ │ └── init-setupcron │ │ ├── type │ │ ├── up │ │ └── run └── usr │ └── local │ ├── bin │ ├── vpn-reconnect │ ├── vpn-healthcheck │ ├── entrypoint │ ├── vpn-config │ ├── network-diagnostic │ └── backend-functions │ └── share │ └── nordvpn │ └── data │ ├── template.ovpn │ ├── technologies.json │ └── groups.json ├── NordVpn_logo.png ├── GROUPS.md ├── TECHNOLOGIES.md ├── .github ├── dependabot.yml └── workflows │ ├── security-docker.yml │ ├── ci-build-deploy.yml │ └── maintenance-cleanup.yml ├── COUNTRIES.md ├── Dockerfile ├── CITIES.md ├── scripts └── update-apk-versions.sh └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | docker-compose.yml -------------------------------------------------------------------------------- /root/etc/s6-overlay/s6-rc.d/svc-cron/type: -------------------------------------------------------------------------------- 1 | longrun -------------------------------------------------------------------------------- /root/etc/s6-overlay/s6-rc.d/svc-nordvpn/type: -------------------------------------------------------------------------------- 1 | longrun -------------------------------------------------------------------------------- /root/etc/s6-overlay/s6-rc.d/user/contents.d/svc-cron: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /root/etc/s6-overlay/s6-rc.d/init-adduser/type: -------------------------------------------------------------------------------- 1 | oneshot 2 | -------------------------------------------------------------------------------- /root/etc/s6-overlay/s6-rc.d/init-firewall/type: -------------------------------------------------------------------------------- 1 | oneshot 2 | -------------------------------------------------------------------------------- /root/etc/s6-overlay/s6-rc.d/user/contents.d/svc-nordvpn: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /root/etc/s6-overlay/s6-rc.d/init-createauth/type: -------------------------------------------------------------------------------- 1 | oneshot 2 | -------------------------------------------------------------------------------- /root/etc/s6-overlay/s6-rc.d/init-setupcron/type: -------------------------------------------------------------------------------- 1 | oneshot 2 | -------------------------------------------------------------------------------- /root/etc/s6-overlay/s6-rc.d/svc-nordvpn/timeout-finish: -------------------------------------------------------------------------------- 1 | 10000 -------------------------------------------------------------------------------- /root/etc/s6-overlay/s6-rc.d/init-createauth/dependencies: -------------------------------------------------------------------------------- 1 | init-adduser -------------------------------------------------------------------------------- /root/etc/s6-overlay/s6-rc.d/svc-cron/dependencies: -------------------------------------------------------------------------------- 1 | init-setupcron 2 | -------------------------------------------------------------------------------- /NordVpn_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/azinchen/nordvpn/HEAD/NordVpn_logo.png -------------------------------------------------------------------------------- /root/etc/s6-overlay/s6-rc.d/init-adduser/up: -------------------------------------------------------------------------------- 1 | /etc/s6-overlay/s6-rc.d/init-adduser/run 2 | -------------------------------------------------------------------------------- /root/etc/s6-overlay/s6-rc.d/init-firewall/up: -------------------------------------------------------------------------------- 1 | /etc/s6-overlay/s6-rc.d/init-firewall/run 2 | -------------------------------------------------------------------------------- /root/etc/s6-overlay/s6-rc.d/init-setupcron/up: -------------------------------------------------------------------------------- 1 | /etc/s6-overlay/s6-rc.d/init-setupcron/run 2 | -------------------------------------------------------------------------------- /root/etc/s6-overlay/s6-rc.d/init-createauth/up: -------------------------------------------------------------------------------- 1 | /etc/s6-overlay/s6-rc.d/init-createauth/run 2 | -------------------------------------------------------------------------------- /root/etc/s6-overlay/s6-rc.d/svc-nordvpn/dependencies: -------------------------------------------------------------------------------- 1 | init-adduser 2 | init-createauth 3 | init-firewall 4 | -------------------------------------------------------------------------------- /root/etc/s6-overlay/s6-rc.d/svc-cron/run: -------------------------------------------------------------------------------- 1 | #!/command/with-contenv sh 2 | # shellcheck shell=sh 3 | 4 | set -eu 5 | 6 | . /usr/local/bin/backend-functions 7 | 8 | SCRIPT_NAME="SERVICE-CRON" 9 | 10 | log "$SCRIPT_NAME" "Starting cron service" 11 | 12 | exec crond -f 13 | -------------------------------------------------------------------------------- /root/usr/local/bin/vpn-reconnect: -------------------------------------------------------------------------------- 1 | #!/command/with-contenv sh 2 | # shellcheck shell=sh 3 | 4 | set -eu 5 | 6 | . /usr/local/bin/backend-functions 7 | 8 | SCRIPT_NAME="VPN-RECONNECT" 9 | 10 | log "$SCRIPT_NAME" "Starting VPN reconnection" 11 | 12 | log "$SCRIPT_NAME" "Stopping NordVPN service" 13 | s6-rc stop svc-nordvpn 14 | sleep 2 15 | log "$SCRIPT_NAME" "Starting NordVPN service" 16 | s6-rc start svc-nordvpn 17 | 18 | log "$SCRIPT_NAME" "VPN reconnection completed" 19 | exit 0 20 | -------------------------------------------------------------------------------- /root/etc/s6-overlay/s6-rc.d/init-createauth/run: -------------------------------------------------------------------------------- 1 | #!/command/with-contenv sh 2 | # shellcheck shell=sh 3 | 4 | set -eu 5 | 6 | . /usr/local/bin/backend-functions 7 | 8 | SCRIPT_NAME="INIT-CREATEAUTH" 9 | 10 | log "$SCRIPT_NAME" "Starting authentication setup" 11 | 12 | log "$SCRIPT_NAME" "Creating VPN authentication file" 13 | echo "$user" > "$authfile" 14 | echo "$pass" >> "$authfile" 15 | chmod 0600 "$authfile" 16 | chown nordvpn:nordvpn "$authfile" 17 | 18 | log "$SCRIPT_NAME" "Authentication setup completed" 19 | exit 0 20 | -------------------------------------------------------------------------------- /root/etc/s6-overlay/s6-rc.d/svc-nordvpn/finish: -------------------------------------------------------------------------------- 1 | #!/command/with-contenv sh 2 | # shellcheck shell=sh 3 | 4 | set -eu 5 | 6 | . /usr/local/bin/backend-functions 7 | 8 | SCRIPT_NAME="SERVICE-NORDVPN" 9 | 10 | log "$SCRIPT_NAME" "Stopping NordVPN service" 11 | 12 | log "$SCRIPT_NAME" "Cleaning up VPN firewall rules" 13 | run4 -F VPN-SERVER 14 | 15 | log "$SCRIPT_NAME" "Disconnecting OpenVPN" 16 | echo "signal SIGTERM" | nc -U "$mgmtsockfile" 17 | sleep 5 18 | 19 | log "$SCRIPT_NAME" "Cleaning up VPN configuration files" 20 | rm -f /run/xt/nordvpn.ovpn 21 | 22 | log "$SCRIPT_NAME" "NordVPN service stopped" 23 | exit 0 24 | -------------------------------------------------------------------------------- /root/etc/s6-overlay/s6-rc.d/init-adduser/run: -------------------------------------------------------------------------------- 1 | #!/command/with-contenv sh 2 | # shellcheck shell=sh 3 | 4 | set -eu 5 | 6 | . /usr/local/bin/backend-functions 7 | 8 | SCRIPT_NAME="INIT-ADDUSER" 9 | 10 | log "$SCRIPT_NAME" "Starting user initialization" 11 | 12 | if [ "$(id -g nordvpn)" -ne "$pgid" ]; then 13 | log "$SCRIPT_NAME" "Updating nordvpn group ID from $(id -g nordvpn) to $pgid" 14 | groupmod --non-unique --gid "$pgid" nordvpn 15 | fi 16 | 17 | if [ "$(id -u nordvpn)" -ne "$puid" ]; then 18 | log "$SCRIPT_NAME" "Updating nordvpn user ID from $(id -u nordvpn) to $puid" 19 | usermod --non-unique --uid "$puid" nordvpn 20 | fi 21 | 22 | log "$SCRIPT_NAME" "User initialization completed" 23 | exit 0 24 | -------------------------------------------------------------------------------- /GROUPS.md: -------------------------------------------------------------------------------- 1 | Last updated: 2025-09-26 2 | --- 3 | # List of GROUPS with NordVPN servers 4 | 5 | Group | Identifier | ID 6 | ------|------------|--- 7 | Double VPN | legacy_double_vpn | 1 8 | Onion Over VPN | legacy_onion_over_vpn | 3 9 | Ultra fast TV | legacy_ultra_fast_tv | 5 10 | Anti DDoS | legacy_anti_ddos | 7 11 | Dedicated IP | legacy_dedicated_ip | 9 12 | Standard VPN servers | legacy_standard | 11 13 | Netflix USA | legacy_netflix_usa | 13 14 | P2P | legacy_p2p | 15 15 | Obfuscated Servers | legacy_obfuscated_servers | 17 16 | Europe | europe | 19 17 | The Americas | the_americas | 21 18 | Asia Pacific | asia_pacific | 23 19 | Africa, the Middle East and India | africa_the_middle_east_and_india | 25 20 | Anycast DNS | anycast-dns | 233 21 | Geo DNS | geo_dns | 236 22 | Grafana | grafana | 239 23 | Kapacitor | kapacitor | 242 24 | Socks5 Proxy | legacy_socks5_proxy | 245 25 | FastNetMon | fastnetmon | 248 26 | -------------------------------------------------------------------------------- /TECHNOLOGIES.md: -------------------------------------------------------------------------------- 1 | Last updated: 2025-09-26 2 | --- 3 | # List of TECHNOLOGIES with NordVPN servers 4 | 5 | Technology | Identifier | ID 6 | -----------|------------|--- 7 | IKEv2/IPSec | ikev2 | 1 8 | OpenVPN UDP | openvpn_udp | 3 9 | OpenVPN TCP | openvpn_tcp | 5 10 | Socks 5 | socks | 7 11 | HTTP Proxy | proxy | 9 12 | PPTP | pptp | 11 13 | L2TP/IPSec | l2tp | 13 14 | OpenVPN UDP Obfuscated | openvpn_xor_udp | 15 15 | OpenVPN TCP Obfuscated | openvpn_xor_tcp | 17 16 | HTTP CyberSec Proxy | proxy_cybersec | 19 17 | HTTP Proxy (SSL) | proxy_ssl | 21 18 | HTTP CyberSec Proxy (SSL) | proxy_ssl_cybersec | 23 19 | IKEv2/IPSec IPv6 | ikev2_v6 | 26 20 | OpenVPN UDP IPv6 | openvpn_udp_v6 | 29 21 | OpenVPN TCP IPv6 | openvpn_tcp_v6 | 32 22 | Wireguard | wireguard_udp | 35 23 | OpenVPN UDP TLS Crypt | openvpn_udp_tls_crypt | 38 24 | OpenVPN TCP TLS Crypt | openvpn_tcp_tls_crypt | 41 25 | OpenVPN UDP Dedicated | openvpn_dedicated_udp | 42 26 | OpenVPN TCP Dedicated | openvpn_dedicated_tcp | 45 27 | Skylark | skylark | 48 28 | Mesh Relay | mesh_relay | 50 29 | NordWhisper | nordwhisper | 51 30 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | # Maintain dependencies for GitHub Actions 4 | - package-ecosystem: "github-actions" 5 | directory: "/" 6 | schedule: 7 | interval: "weekly" 8 | day: "monday" 9 | time: "04:00" 10 | timezone: "UTC" 11 | commit-message: 12 | prefix: "ci" 13 | include: "scope" 14 | labels: 15 | - "dependencies" 16 | - "github-actions" 17 | - "automation" 18 | open-pull-requests-limit: 5 19 | reviewers: 20 | - "azinchen" 21 | assignees: 22 | - "azinchen" 23 | 24 | # Configuration for Dockerfile - Security-focused updates 25 | - package-ecosystem: "docker" 26 | directory: "/" 27 | schedule: 28 | interval: "daily" # More frequent for security 29 | time: "04:00" 30 | timezone: "UTC" 31 | commit-message: 32 | prefix: "docker" 33 | include: "scope" 34 | labels: 35 | - "dependencies" 36 | - "docker" 37 | - "security" 38 | open-pull-requests-limit: 5 39 | reviewers: 40 | - "azinchen" 41 | assignees: 42 | - "azinchen" 43 | -------------------------------------------------------------------------------- /root/usr/local/bin/vpn-healthcheck: -------------------------------------------------------------------------------- 1 | #!/command/with-contenv sh 2 | # shellcheck shell=sh 3 | 4 | set -eu 5 | 6 | . /usr/local/bin/backend-functions 7 | 8 | SCRIPT_NAME="VPN-HEALTHCHECK" 9 | 10 | log "$SCRIPT_NAME" "Starting VPN health check" 11 | 12 | httpreq() 13 | { 14 | case "$(curl -s --max-time 2 -I "$1" | sed 's/^[^ ]* *\([0-9]\).*/\1/; 1q')" in 15 | [23]) return 0;; 16 | 5) return 1;; 17 | *) return 1;; 18 | esac 19 | } 20 | 21 | counter=1 22 | while [ $counter -le "$check_connection_attempts" ]; do 23 | # Convert semicolon separated list to space separated 24 | oldIFS="$IFS"; IFS=',;' 25 | for url in $check_connection_url; do 26 | if httpreq "$url"; then 27 | log "$SCRIPT_NAME" "VPN connection is active" 28 | exit 0 29 | fi 30 | done 31 | IFS="$oldIFS" 32 | 33 | log "$SCRIPT_NAME" "Connection attempt $counter/$check_connection_attempts failed, retrying in $check_connection_attempt_interval seconds" 34 | sleep "$check_connection_attempt_interval" 35 | counter=$((counter + 1)) 36 | done 37 | 38 | log "$SCRIPT_NAME" "All connection attempts failed, triggering VPN reconnection" 39 | vpn-reconnect 40 | exit 1 41 | -------------------------------------------------------------------------------- /root/etc/s6-overlay/s6-rc.d/init-setupcron/run: -------------------------------------------------------------------------------- 1 | #!/command/with-contenv sh 2 | # shellcheck shell=sh 3 | 4 | set -eu 5 | 6 | . /usr/local/bin/backend-functions 7 | 8 | SCRIPT_NAME="INIT-SETUPCRON" 9 | 10 | log "$SCRIPT_NAME" "Starting cron configuration" 11 | 12 | # Validate cron-related environment variables 13 | if [ -n "${recreate_vpn_cron:-}" ]; then 14 | log "$SCRIPT_NAME" "VPN recreation cron: $recreate_vpn_cron ($(parse_cron "$recreate_vpn_cron"))" 15 | else 16 | log "$SCRIPT_NAME" "VPN recreation cron not configured" 17 | fi 18 | 19 | if [ -n "${check_connection_cron:-}" ]; then 20 | log "$SCRIPT_NAME" "Health check cron: $check_connection_cron ($(parse_cron "$check_connection_cron"))" 21 | 22 | # Validate CHECK_CONNECTION_* variables that are used by vpn-healthcheck 23 | 24 | if [ -z "$check_connection_attempts" ] || [ "$check_connection_attempts" -le 0 ]; then 25 | log_error "$SCRIPT_NAME" "ERROR: CHECK_CONNECTION_ATTEMPTS must be a positive integer, got: '$check_connection_attempts'" 26 | exit 1 27 | fi 28 | 29 | if [ -z "$check_connection_url" ]; then 30 | log_error "$SCRIPT_NAME" "ERROR: CHECK_CONNECTION_URL must be set" 31 | exit 1 32 | fi 33 | 34 | if [ -z "$check_connection_attempt_interval" ] || [ "$check_connection_attempt_interval" -le 0 ]; then 35 | log_error "$SCRIPT_NAME" "ERROR: CHECK_CONNECTION_ATTEMPT_INTERVAL must be a positive integer, got: '$check_connection_attempt_interval'" 36 | exit 1 37 | fi 38 | 39 | # Validation passed 40 | else 41 | log "$SCRIPT_NAME" "Health check cron not configured, skipping validation" 42 | fi 43 | 44 | cron_dir="/var/spool/cron/crontabs" 45 | cron_file="$cron_dir/root" 46 | 47 | rm -f "$cron_file" 48 | touch "$cron_file" 49 | 50 | if [ -n "${recreate_vpn_cron:-}" ]; then 51 | echo "$recreate_vpn_cron vpn-reconnect" >> "$cron_file" 52 | else 53 | log "$SCRIPT_NAME" "VPN reconnection cron not configured" 54 | fi 55 | 56 | if [ -n "${check_connection_cron:-}" ]; then 57 | echo "$check_connection_cron vpn-healthcheck" >> "$cron_file" 58 | else 59 | log "$SCRIPT_NAME" "Health check cron not configured" 60 | fi 61 | 62 | log "$SCRIPT_NAME" "Cron configuration completed" 63 | exit 0 64 | -------------------------------------------------------------------------------- /root/etc/s6-overlay/s6-rc.d/init-firewall/run: -------------------------------------------------------------------------------- 1 | #!/command/with-contenv sh 2 | # shellcheck shell=sh 3 | 4 | set -eu 5 | 6 | . /usr/local/bin/backend-functions 7 | 8 | SCRIPT_NAME="INIT-FIREWALL" 9 | 10 | log "$SCRIPT_NAME" "Starting firewall initialization" 11 | log "$SCRIPT_NAME" "Configuring firewall to route all traffic through VPN" 12 | 13 | 14 | # BusyBox-friendly helpers 15 | 16 | docker_network="$(ip addr show dev eth0 2>/dev/null | awk '/inet / {print $2; exit}')" 17 | 18 | # BusyBox-friendly helpers 19 | docker_network="$(ip addr show dev eth0 2>/dev/null | awk '/inet / {print $2; exit}')" 20 | gw="$(ip route 2>/dev/null | awk '/default/ {print $3; exit}')" 21 | 22 | # ===================== IPv4 ===================== 23 | if [ -n "$docker_network" ]; then 24 | log "$SCRIPT_NAME" "Setting up IPv4 firewall rules..." 25 | 26 | # Stateful fast-path (skip gracefully if conntrack match absent) 27 | run4_critical -A INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT 28 | run4_critical -A OUTPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT 29 | 30 | # Allow outbound to VPN interfaces + NAT 31 | run4_critical -A OUTPUT -o tun0 -j ACCEPT 32 | run4_critical -t nat -A POSTROUTING -o tun0 -j MASQUERADE 33 | 34 | # Create named chain for VPN Server rules 35 | run4 -N VPN-SERVER 36 | run4 -A OUTPUT -p udp --dport 1194 -j VPN-SERVER 37 | run4 -A OUTPUT -p tcp --dport 443 -j VPN-SERVER 38 | 39 | # NordVPN API bootstrap (TCP/443) by resolved IPs 40 | if [ -n "$nordvpnapi_ip" ]; then 41 | log "$SCRIPT_NAME" "Allowing NordVPN API access for IPs: ${nordvpnapi_ip}" 42 | oldIFS="$IFS"; IFS=',;' 43 | for ip in $nordvpnapi_ip; do 44 | [ -z "$ip" ] && continue 45 | run4 -A OUTPUT -d "$ip" -p tcp --dport 443 -j ACCEPT 46 | done 47 | IFS="$oldIFS" 48 | fi 49 | 50 | # NETWORK rules (CIDRs). If present, restrict inbound to these nets; do NOT add the broad eth0 accept. 51 | if [ -n "${network:-}" ]; then 52 | log "$SCRIPT_NAME" "Allowing access to custom networks: ${network}" 53 | oldIFS="$IFS"; IFS=',;' 54 | for net in $network; do 55 | [ -z "$net" ] && continue 56 | # Best-effort route add (if not already present) 57 | if [ -n "$gw" ]; then 58 | ip route 2>/dev/null | grep -q " ${net} " || ip route add "$net" via "$gw" dev eth0 2>/dev/null || true 59 | fi 60 | # Allow inbound from this network only 61 | run4 -A INPUT -i eth0 -s "$net" -j ACCEPT 62 | # Allow outbound to this network only 63 | run4 -A OUTPUT -o eth0 -d "$net" -j ACCEPT 64 | done 65 | IFS="$oldIFS" 66 | fi 67 | else 68 | log "$SCRIPT_NAME" "No IPv4 address on eth0, skipping IPv4 rules" 69 | fi 70 | 71 | log "$SCRIPT_NAME" "Firewall initialization completed" 72 | exit 0 73 | -------------------------------------------------------------------------------- /root/etc/s6-overlay/s6-rc.d/svc-nordvpn/run: -------------------------------------------------------------------------------- 1 | #!/command/with-contenv sh 2 | # shellcheck shell=sh 3 | 4 | set -eu 5 | 6 | . /usr/local/bin/backend-functions 7 | 8 | SCRIPT_NAME="SERVICE-NORDVPN" 9 | 10 | log "$SCRIPT_NAME" "Starting NordVPN service" 11 | 12 | # -------- helpers -------- 13 | normalize_proto() 14 | { 15 | # Map OpenVPN tokens to "udp" or "tcp" (family handled by IP literal) 16 | case "$(echo "$1" | tr 'A-Z' 'a-z')" in 17 | tcp|tcp-client|tcp-server|tcp6|tcp6-client|tcp6-server) echo "tcp" ;; 18 | udp|udp-client|udp-server|udp6|udp6-client|udp6-server|"") echo "udp" ;; 19 | * ) echo "udp" ;; 20 | esac 21 | } 22 | 23 | vpn-config 24 | 25 | # -------- parse the single 'remote' line -------- 26 | [ -s "$ovpnfile" ] || { log_error "$SCRIPT_NAME" "CRITICAL ERROR: OVPN file not found: $ovpnfile - sleeping infinite" >&2; sleep infinity; } 27 | 28 | r_line="$(awk '/^[[:space:]]*remote[ \t]+/ {print; exit}' "$ovpnfile")" 29 | [ -n "$r_line" ] || { log_error "$SCRIPT_NAME" "CRITICAL ERROR: No 'remote' line in $ovpnfile - sleeping infinite" >&2; sleep infinity; } 30 | 31 | # Fields: remote [port] [proto] 32 | r_ip_raw="$(echo "$r_line" | awk '{print $2}')" 33 | r_port="$(echo "$r_line" | awk '{print $3}')" 34 | r_proto_raw="$(echo "$r_line" | awk '{print $4}')" 35 | 36 | # Fallbacks from file if not specified on the remote line 37 | [ -n "${r_port:-}" ] || r_port="$(awk '/^[[:space:]]*port[ \t]+/ {print $2; exit}' "$ovpnfile" || true)" 38 | [ -n "${r_port:-}" ] || r_port=1194 39 | r_proto="$(normalize_proto "$r_proto_raw")" 40 | 41 | # Clean up any bracketed IPv6 (just in case) 42 | r_ip="$(echo "$r_ip_raw" | sed 's,^\[,,' | sed 's,\]$,,' )" 43 | 44 | # -------- add a single temporary pinhole on eth0 -------- 45 | 46 | PIN_SPEC="-A VPN-SERVER -o eth0 -p ${r_proto} -d ${r_ip} --dport ${r_port} -j ACCEPT" 47 | if run4 $PIN_SPEC 2>/dev/null; then 48 | log "$SCRIPT_NAME" "Added firewall pinhole for ${r_proto} traffic to ${r_ip}:${r_port} on interface eth0" 49 | fi 50 | 51 | # Add --data-ciphers if not already present in OPENVPN_OPTS 52 | if ! echo "$openvpn_opts" | grep -q -- "--data-ciphers"; then 53 | log "$SCRIPT_NAME" "Adding default --data-ciphers to prevent cipher deprecation warning" 54 | openvpn_opts="$openvpn_opts --data-ciphers AES-256-CBC:AES-256-GCM:AES-128-GCM:CHACHA20-POLY1305" 55 | fi 56 | 57 | log "$SCRIPT_NAME" "Launching OpenVPN" 58 | openvpn --group nordvpn --config "$ovpnfile" --auth-user-pass "$authfile" --auth-nocache --management "$mgmtsockfile" unix $openvpn_opts & 59 | 60 | # Wait for VPN connection 61 | log "$SCRIPT_NAME" "Waiting for VPN connection..." 62 | counter=0 63 | while ! is_vpn_connected && [ $counter -lt 30 ]; do 64 | sleep 2 65 | counter=$((counter + 1)) 66 | done 67 | 68 | if is_vpn_connected; then 69 | log "$SCRIPT_NAME" "VPN connection established" 70 | # Run network diagnostic if enabled 71 | if [ "$network_diagnostic_enabled" = "true" ] || [ "$network_diagnostic_enabled" = "1" ]; then 72 | log "$SCRIPT_NAME" "Running network diagnostic..." 73 | network-diagnostic 74 | fi 75 | else 76 | log "$SCRIPT_NAME" "VPN connection timed out" 77 | fi 78 | 79 | # Wait for OpenVPN to exit 80 | wait 81 | -------------------------------------------------------------------------------- /root/usr/local/share/nordvpn/data/template.ovpn: -------------------------------------------------------------------------------- 1 | client 2 | dev tun 3 | proto __PROTOCOL__ 4 | remote __IP__ __PORT__ 5 | resolv-retry infinite 6 | remote-random 7 | nobind 8 | tun-mtu 1500 9 | tun-mtu-extra 32 10 | mssfix 1450 11 | persist-key 12 | persist-tun 13 | ping 15 14 | ping-restart 0 15 | ping-timer-rem 16 | reneg-sec 0 17 | comp-lzo no 18 | verify-x509-name CN=__X509_NAME__ 19 | 20 | remote-cert-tls server 21 | 22 | auth-user-pass 23 | verb 3 24 | pull 25 | fast-io 26 | cipher AES-256-CBC 27 | auth SHA512 28 | 29 | -----BEGIN CERTIFICATE----- 30 | MIIFCjCCAvKgAwIBAgIBATANBgkqhkiG9w0BAQ0FADA5MQswCQYDVQQGEwJQQTEQ 31 | MA4GA1UEChMHTm9yZFZQTjEYMBYGA1UEAxMPTm9yZFZQTiBSb290IENBMB4XDTE2 32 | MDEwMTAwMDAwMFoXDTM1MTIzMTIzNTk1OVowOTELMAkGA1UEBhMCUEExEDAOBgNV 33 | BAoTB05vcmRWUE4xGDAWBgNVBAMTD05vcmRWUE4gUm9vdCBDQTCCAiIwDQYJKoZI 34 | hvcNAQEBBQADggIPADCCAgoCggIBAMkr/BYhyo0F2upsIMXwC6QvkZps3NN2/eQF 35 | kfQIS1gql0aejsKsEnmY0Kaon8uZCTXPsRH1gQNgg5D2gixdd1mJUvV3dE3y9FJr 36 | XMoDkXdCGBodvKJyU6lcfEVF6/UxHcbBguZK9UtRHS9eJYm3rpL/5huQMCppX7kU 37 | eQ8dpCwd3iKITqwd1ZudDqsWaU0vqzC2H55IyaZ/5/TnCk31Q1UP6BksbbuRcwOV 38 | skEDsm6YoWDnn/IIzGOYnFJRzQH5jTz3j1QBvRIuQuBuvUkfhx1FEwhwZigrcxXu 39 | MP+QgM54kezgziJUaZcOM2zF3lvrwMvXDMfNeIoJABv9ljw969xQ8czQCU5lMVmA 40 | 37ltv5Ec9U5hZuwk/9QO1Z+d/r6Jx0mlurS8gnCAKJgwa3kyZw6e4FZ8mYL4vpRR 41 | hPdvRTWCMJkeB4yBHyhxUmTRgJHm6YR3D6hcFAc9cQcTEl/I60tMdz33G6m0O42s 42 | Qt/+AR3YCY/RusWVBJB/qNS94EtNtj8iaebCQW1jHAhvGmFILVR9lzD0EzWKHkvy 43 | WEjmUVRgCDd6Ne3eFRNS73gdv/C3l5boYySeu4exkEYVxVRn8DhCxs0MnkMHWFK6 44 | MyzXCCn+JnWFDYPfDKHvpff/kLDobtPBf+Lbch5wQy9quY27xaj0XwLyjOltpiST 45 | LWae/Q4vAgMBAAGjHTAbMAwGA1UdEwQFMAMBAf8wCwYDVR0PBAQDAgEGMA0GCSqG 46 | SIb3DQEBDQUAA4ICAQC9fUL2sZPxIN2mD32VeNySTgZlCEdVmlq471o/bDMP4B8g 47 | nQesFRtXY2ZCjs50Jm73B2LViL9qlREmI6vE5IC8IsRBJSV4ce1WYxyXro5rmVg/ 48 | k6a10rlsbK/eg//GHoJxDdXDOokLUSnxt7gk3QKpX6eCdh67p0PuWm/7WUJQxH2S 49 | DxsT9vB/iZriTIEe/ILoOQF0Aqp7AgNCcLcLAmbxXQkXYCCSB35Vp06u+eTWjG0/ 50 | pyS5V14stGtw+fA0DJp5ZJV4eqJ5LqxMlYvEZ/qKTEdoCeaXv2QEmN6dVqjDoTAo 51 | k0t5u4YRXzEVCfXAC3ocplNdtCA72wjFJcSbfif4BSC8bDACTXtnPC7nD0VndZLp 52 | +RiNLeiENhk0oTC+UVdSc+n2nJOzkCK0vYu0Ads4JGIB7g8IB3z2t9ICmsWrgnhd 53 | NdcOe15BincrGA8avQ1cWXsfIKEjbrnEuEk9b5jel6NfHtPKoHc9mDpRdNPISeVa 54 | wDBM1mJChneHt59Nh8Gah74+TM1jBsw4fhJPvoc7Atcg740JErb904mZfkIEmojC 55 | VPhBHVQ9LHBAdM8qFI2kRK0IynOmAZhexlP/aT/kpEsEPyaZQlnBn3An1CRz8h0S 56 | PApL8PytggYKeQmRhl499+6jLxcZ2IegLfqq41dzIjwHwTMplg+1pKIOVojpWA== 57 | -----END CERTIFICATE----- 58 | 59 | key-direction 1 60 | 61 | # 62 | # 2048 bit OpenVPN static key 63 | # 64 | -----BEGIN OpenVPN Static key V1----- 65 | e685bdaf659a25a200e2b9e39e51ff03 66 | 0fc72cf1ce07232bd8b2be5e6c670143 67 | f51e937e670eee09d4f2ea5a6e4e6996 68 | 5db852c275351b86fc4ca892d78ae002 69 | d6f70d029bd79c4d1c26cf14e9588033 70 | cf639f8a74809f29f72b9d58f9b8f5fe 71 | fc7938eade40e9fed6cb92184abb2cc1 72 | 0eb1a296df243b251df0643d53724cdb 73 | 5a92a1d6cb817804c4a9319b57d53be5 74 | 80815bcfcb2df55018cc83fc43bc7ff8 75 | 2d51f9b88364776ee9d12fc85cc7ea5b 76 | 9741c4f598c485316db066d52db4540e 77 | 212e1518a9bd4828219e24b20d88f598 78 | a196c9de96012090e333519ae18d3509 79 | 9427e7b372d348d352dc4c85e18cd4b9 80 | 3f8a56ddb2e64eb67adfc9b337157ff4 81 | -----END OpenVPN Static key V1----- 82 | 83 | script-security 2 84 | up /etc/openvpn/up.sh 85 | down /etc/openvpn/down.sh 86 | -------------------------------------------------------------------------------- /COUNTRIES.md: -------------------------------------------------------------------------------- 1 | Last updated: 2025-11-08 2 | --- 3 | # List of COUNTRIES with NordVPN servers 4 | 5 | Country | Code | ID 6 | --------|------|--- 7 | Afghanistan | AF | 1 8 | Albania | AL | 2 9 | Algeria | DZ | 3 10 | Andorra | AD | 5 11 | Angola | AO | 6 12 | Argentina | AR | 10 13 | Armenia | AM | 11 14 | Australia | AU | 13 15 | Austria | AT | 14 16 | Azerbaijan | AZ | 15 17 | Bahamas | BS | 16 18 | Bahrain | BH | 17 19 | Bangladesh | BD | 18 20 | Belgium | BE | 21 21 | Belize | BZ | 22 22 | Bermuda | BM | 24 23 | Bhutan | BT | 25 24 | Bolivia | BO | 26 25 | Bosnia and Herzegovina | BA | 27 26 | Brazil | BR | 30 27 | Brunei Darussalam | BN | 32 28 | Bulgaria | BG | 33 29 | Cambodia | KH | 36 30 | Canada | CA | 38 31 | Cayman Islands | KY | 40 32 | Chile | CL | 43 33 | Colombia | CO | 47 34 | Comoros | KM | 48 35 | Costa Rica | CR | 52 36 | Croatia | HR | 54 37 | Cyprus | CY | 56 38 | Czech Republic | CZ | 57 39 | Denmark | DK | 58 40 | Dominican Republic | DO | 61 41 | Ecuador | EC | 63 42 | Egypt | EG | 64 43 | El Salvador | SV | 65 44 | Estonia | EE | 68 45 | Ethiopia | ET | 69 46 | Finland | FI | 73 47 | France | FR | 74 48 | Georgia | GE | 80 49 | Germany | DE | 81 50 | Ghana | GH | 82 51 | Greece | GR | 84 52 | Greenland | GL | 85 53 | Guam | GU | 88 54 | Guatemala | GT | 89 55 | Honduras | HN | 96 56 | Hong Kong | HK | 97 57 | Hungary | HU | 98 58 | Iceland | IS | 99 59 | India | IN | 100 60 | Indonesia | ID | 101 61 | Iraq | IQ | 103 62 | Ireland | IE | 104 63 | Isle of Man | IM | 243 64 | Israel | IL | 105 65 | Italy | IT | 106 66 | Jamaica | JM | 107 67 | Japan | JP | 108 68 | Jersey | JE | 244 69 | Jordan | JO | 109 70 | Kazakhstan | KZ | 110 71 | Kenya | KE | 111 72 | Kuwait | KW | 116 73 | Lao People's Democratic Republic | LA | 118 74 | Latvia | LV | 119 75 | Lebanon | LB | 120 76 | Libyan Arab Jamahiriya | LY | 123 77 | Liechtenstein | LI | 124 78 | Lithuania | LT | 125 79 | Luxembourg | LU | 126 80 | Malaysia | MY | 131 81 | Malta | MT | 134 82 | Mauritania | MR | 137 83 | Mauritius | MU | 138 84 | Mexico | MX | 140 85 | Moldova | MD | 142 86 | Monaco | MC | 143 87 | Mongolia | MN | 144 88 | Montenegro | ME | 146 89 | Morocco | MA | 147 90 | Mozambique | MZ | 148 91 | Myanmar | MM | 149 92 | Nepal | NP | 152 93 | Netherlands | NL | 153 94 | New Zealand | NZ | 156 95 | Nigeria | NG | 159 96 | North Macedonia | MK | 128 97 | Norway | NO | 163 98 | Pakistan | PK | 165 99 | Panama | PA | 168 100 | Papua New Guinea | PG | 169 101 | Paraguay | PY | 170 102 | Peru | PE | 171 103 | Philippines | PH | 172 104 | Poland | PL | 174 105 | Portugal | PT | 175 106 | Puerto Rico | PR | 176 107 | Qatar | QA | 177 108 | Romania | RO | 179 109 | Rwanda | RW | 181 110 | Senegal | SN | 191 111 | Serbia | RS | 192 112 | Singapore | SG | 195 113 | Slovakia | SK | 196 114 | Slovenia | SI | 197 115 | Somalia | SO | 199 116 | South Africa | ZA | 200 117 | South Korea | KR | 114 118 | Spain | ES | 202 119 | Sri Lanka | LK | 203 120 | Sweden | SE | 208 121 | Switzerland | CH | 209 122 | Taiwan | TW | 211 123 | Tajikistan | TJ | 212 124 | Thailand | TH | 214 125 | Trinidad and Tobago | TT | 218 126 | Tunisia | TN | 219 127 | Turkey | TR | 220 128 | Ukraine | UA | 225 129 | United Arab Emirates | AE | 226 130 | United Kingdom | GB | 227 131 | United States | US | 228 132 | Uruguay | UY | 230 133 | Uzbekistan | UZ | 231 134 | Venezuela | VE | 233 135 | Vietnam | VN | 234 136 | -------------------------------------------------------------------------------- /.github/workflows/security-docker.yml: -------------------------------------------------------------------------------- 1 | name: Security - Docker Image Analysis 2 | 3 | permissions: 4 | contents: read 5 | security-events: write # Required for uploading SARIF files to Security tab 6 | 7 | on: 8 | schedule: 9 | - cron: '0 6 * * *' # Run at 6 AM UTC daily to check latest production image on Docker Hub 10 | workflow_dispatch: 11 | inputs: 12 | image_tag: 13 | description: 'Docker image tag to scan' 14 | required: false 15 | default: 'latest' 16 | type: string 17 | workflow_call: # Allow this workflow to be called by other workflows 18 | inputs: 19 | image_tag: 20 | description: 'Docker image tag to scan' 21 | required: false 22 | default: 'latest' 23 | type: string 24 | is_production_build: 25 | description: 'Whether this is a production build (tag)' 26 | required: false 27 | default: false 28 | type: boolean 29 | 30 | jobs: 31 | docker-security-scan: 32 | name: 🔍 Docker Image Security Scan 33 | runs-on: ubuntu-latest 34 | if: github.event_name == 'workflow_dispatch' 35 | steps: 36 | - name: Checkout 37 | uses: actions/checkout@v6.0.1 38 | 39 | - name: Determine registry and image to scan 40 | id: config 41 | shell: bash 42 | run: | 43 | # Default values 44 | IMAGE_TAG="${{ inputs.image_tag || 'latest' }}" 45 | 46 | # Determine which registry to scan based on trigger and inputs 47 | REGISTRY="ghcr" 48 | IMAGE_REF="ghcr.io/${{ github.repository }}:${IMAGE_TAG}" 49 | if [[ "${{ github.event_name }}" == "schedule" ]]; then 50 | # Cron job: Always scan latest production image on 51 | IMAGE_TAG="latest" 52 | echo "🕒 Scheduled scan: Checking latest production image on GHCR" 53 | elif [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then 54 | # Manual trigger: Use user-specified registry or default to GHCR 55 | echo "🚀 Manual scan: Checking $REGISTRY registry" 56 | elif [[ "${{ github.event_name }}" == "workflow_call" ]]; then 57 | # Called from CI: Determine registry based on build type 58 | echo "🔧 Build scan: Checking GHCR" 59 | else 60 | # Fallback: For development branches, use GHCR; for production, use Docker Hub 61 | echo "⚠️ Fallback to GHCR for development build" 62 | fi 63 | 64 | echo "registry=${REGISTRY}" >> $GITHUB_OUTPUT 65 | echo "image_tag=${IMAGE_TAG}" >> $GITHUB_OUTPUT 66 | echo "image_ref=${IMAGE_REF}" >> $GITHUB_OUTPUT 67 | 68 | echo "📋 Scan Configuration:" 69 | echo " Event: ${{ github.event_name }}" 70 | echo " Registry: ${REGISTRY}" 71 | echo " Image Tag: ${IMAGE_TAG}" 72 | echo " Full Image Reference: ${IMAGE_REF}" 73 | echo " Production Build: ${{ inputs.is_production_build }}" 74 | 75 | - name: Login to GitHub Container Registry 76 | uses: docker/login-action@v3.6.0 77 | with: 78 | registry: ghcr.io 79 | username: ${{ github.actor }} 80 | password: ${{ github.token }} 81 | 82 | - name: Run Trivy security scan 83 | id: trivy-image 84 | uses: aquasecurity/trivy-action@master 85 | continue-on-error: true 86 | with: 87 | image-ref: "${{ steps.config.outputs.image_ref }}" 88 | format: 'sarif' 89 | output: 'trivy-image-results.sarif' 90 | 91 | - name: Upload Trivy scan results to GitHub Security tab 92 | uses: github/codeql-action/upload-sarif@v4.31.8 93 | if: ${{ always() && hashFiles('trivy-image-results.sarif') != '' }} 94 | with: 95 | sarif_file: 'trivy-image-results.sarif' 96 | category: 'trivy-docker-image' 97 | 98 | - name: Run Grype security scan 99 | id: grype-scan 100 | continue-on-error: true 101 | uses: anchore/scan-action@v7.2.2 102 | with: 103 | image: "${{ steps.config.outputs.image_ref }}" 104 | fail-build: false 105 | severity-cutoff: medium 106 | output-format: sarif 107 | 108 | - name: Move Grype results to named file 109 | if: always() 110 | run: | 111 | if [ -f "results.sarif" ]; then 112 | mv results.sarif grype-results.sarif 113 | fi 114 | 115 | - name: Upload Grype scan results to GitHub Security tab 116 | uses: github/codeql-action/upload-sarif@v4.31.8 117 | if: always() && hashFiles('grype-results.sarif') != '' 118 | with: 119 | sarif_file: 'grype-results.sarif' 120 | category: 'grype-docker-image' 121 | 122 | - name: Run Snyk security scan 123 | id: snyk-scan 124 | continue-on-error: true 125 | uses: snyk/actions/docker@master 126 | env: 127 | SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} 128 | with: 129 | image: "${{ steps.config.outputs.image_ref }}" 130 | args: --severity-threshold=medium --file=Dockerfile --sarif-file-output=snyk-results.sarif 131 | 132 | - name: Upload Snyk scan results to GitHub Security tab 133 | uses: github/codeql-action/upload-sarif@v4.31.8 134 | if: always() && hashFiles('snyk-results.sarif') != '' 135 | with: 136 | sarif_file: 'snyk-results.sarif' 137 | category: 'snyk-docker-image' 138 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # s6 overlay builder 2 | FROM alpine:3.23.2 AS s6-builder 3 | 4 | ARG TARGETARCH 5 | ARG TARGETVARIANT 6 | 7 | ENV PACKAGE="just-containers/s6-overlay" 8 | ENV PACKAGEVERSION="3.2.1.0" 9 | 10 | RUN echo "**** install security fix packages ****" && \ 11 | echo "**** install mandatory packages ****" && \ 12 | apk --no-cache --no-progress add \ 13 | tar=1.35-r4 \ 14 | xz=5.8.1-r0 \ 15 | && \ 16 | echo "**** create folders ****" && \ 17 | mkdir -p /s6 && \ 18 | echo "**** download ${PACKAGE} ****" && \ 19 | echo "Target arch: ${TARGETARCH}${TARGETVARIANT}" && \ 20 | # Map Docker TARGETARCH to s6-overlay architecture names 21 | case "${TARGETARCH}${TARGETVARIANT}" in \ 22 | amd64) s6_arch="x86_64" ;; \ 23 | arm64) s6_arch="aarch64" ;; \ 24 | armv7) s6_arch="arm" ;; \ 25 | armv6) s6_arch="armhf" ;; \ 26 | 386) s6_arch="i686" ;; \ 27 | ppc64) s6_arch="powerpc64" ;; \ 28 | ppc64le) s6_arch="powerpc64le" ;; \ 29 | riscv64) s6_arch="riscv64" ;; \ 30 | s390x) s6_arch="s390x" ;; \ 31 | *) s6_arch="x86_64" ;; \ 32 | esac && \ 33 | echo "Package ${PACKAGE} platform ${PACKAGEPLATFORM} version ${PACKAGEVERSION}" && \ 34 | s6_url_base="https://github.com/${PACKAGE}/releases/download/v${PACKAGEVERSION}" && \ 35 | wget -q "${s6_url_base}/s6-overlay-noarch.tar.xz" -qO /tmp/s6-overlay-noarch.tar.xz && \ 36 | wget -q "${s6_url_base}/s6-overlay-${s6_arch}.tar.xz" -qO /tmp/s6-overlay-binaries.tar.xz && \ 37 | wget -q "${s6_url_base}/s6-overlay-symlinks-noarch.tar.xz" -qO /tmp/s6-overlay-symlinks-noarch.tar.xz && \ 38 | wget -q "${s6_url_base}/s6-overlay-symlinks-arch.tar.xz" -qO /tmp/s6-overlay-symlinks-arch.tar.xz && \ 39 | tar -C /s6/ -Jxpf /tmp/s6-overlay-noarch.tar.xz && \ 40 | tar -C /s6/ -Jxpf /tmp/s6-overlay-binaries.tar.xz && \ 41 | tar -C /s6/ -Jxpf /tmp/s6-overlay-symlinks-noarch.tar.xz && \ 42 | tar -C /s6/ -Jxpf /tmp/s6-overlay-symlinks-arch.tar.xz 43 | 44 | # rootfs builder 45 | FROM alpine:3.23.2 AS rootfs-builder 46 | 47 | ARG IMAGE_VERSION=N/A \ 48 | BUILD_DATE=N/A 49 | 50 | RUN echo "**** install security fix packages ****" && \ 51 | echo "**** install mandatory packages ****" && \ 52 | apk --no-cache --no-progress add \ 53 | jq=1.8.1-r0 \ 54 | && \ 55 | echo "**** end run statement ****" 56 | 57 | COPY root/ /rootfs/ 58 | RUN chmod +x /rootfs/usr/local/bin/* || true && \ 59 | chmod +x /rootfs/etc/s6-overlay/s6-rc.d/*/run || true && \ 60 | chmod +x /rootfs/etc/s6-overlay/s6-rc.d/*/finish || true && \ 61 | chmod 644 /rootfs/usr/local/share/nordvpn/data/*.json && \ 62 | chmod 644 /rootfs/usr/local/share/nordvpn/data/template.ovpn && \ 63 | for f in /rootfs/usr/local/share/nordvpn/data/*.json; do \ 64 | jq -c . "$f" > "$f.tmp" && mv "$f.tmp" "$f"; \ 65 | done && \ 66 | safe_sed() { \ 67 | local pattern="$1"; \ 68 | local replacement="$2"; \ 69 | local file="$3"; \ 70 | local delim; \ 71 | for delim in '/' '|' '#' '@' '%' '^' '&' '*' '+' '-' '_' '=' ':' ';' '<' '>' ',' '.' '?' '~' '`' '!' '$' '(' ')' '[' ']' '{' '}' '\\' '"' "'"; do \ 72 | if [[ "$replacement" != *"$delim"* ]]; then \ 73 | sed -i "s$delim$pattern$delim$replacement$delim g" "$file"; \ 74 | return; \ 75 | fi; \ 76 | done; \ 77 | echo "No safe delimiter found for $pattern in $file"; \ 78 | } && \ 79 | safe_sed "__IMAGE_VERSION__" "${IMAGE_VERSION}" /rootfs/usr/local/bin/entrypoint && \ 80 | safe_sed "__BUILD_DATE__" "${BUILD_DATE}" /rootfs/usr/local/bin/entrypoint 81 | COPY --from=s6-builder /s6/ /rootfs/ 82 | 83 | # Main image 84 | FROM alpine:3.23.2 85 | 86 | ARG TARGETPLATFORM 87 | ARG IMAGE_VERSION=N/A \ 88 | BUILD_DATE=N/A 89 | 90 | LABEL org.opencontainers.image.authors="Alexander Zinchenko " \ 91 | org.opencontainers.image.description="OpenVPN client docker container that routes other containers' traffic through NordVPN servers automatically." \ 92 | org.opencontainers.image.source="https://github.com/azinchen/nordvpn" \ 93 | org.opencontainers.image.licenses="AGPL-3.0" \ 94 | org.opencontainers.image.title="NordVPN OpenVPN Docker Container" \ 95 | org.opencontainers.image.url="https://github.com/azinchen/nordvpn" \ 96 | org.opencontainers.image.version="${IMAGE_VERSION}" \ 97 | org.opencontainers.image.created="${BUILD_DATE}" 98 | 99 | ENV S6_CMD_WAIT_FOR_SERVICES_MAXTIME=120000 100 | 101 | RUN echo "**** install security fix packages ****" && \ 102 | echo "**** install mandatory packages ****" && \ 103 | echo "Target platform: ${TARGETPLATFORM}" && \ 104 | apk --no-cache --no-progress add \ 105 | curl=8.17.0-r1 \ 106 | iptables=1.8.11-r1 \ 107 | iptables-legacy=1.8.11-r1 \ 108 | jq=1.8.1-r0 \ 109 | shadow=4.18.0-r0 \ 110 | shadow-login=4.18.0-r0 \ 111 | openvpn=2.6.16-r0 \ 112 | bind-tools=9.20.16-r0 \ 113 | netcat-openbsd=1.234.1-r0 \ 114 | && \ 115 | echo "**** create process user ****" && \ 116 | addgroup --system --gid 912 nordvpn && \ 117 | adduser --system --uid 912 --disabled-password --no-create-home --ingroup nordvpn nordvpn && \ 118 | echo "**** cleanup ****" && \ 119 | rm -rf /tmp/* && \ 120 | rm -rf /var/cache/apk/* 121 | 122 | COPY --from=rootfs-builder /rootfs/ / 123 | 124 | ENTRYPOINT ["/usr/local/bin/entrypoint"] 125 | -------------------------------------------------------------------------------- /root/usr/local/share/nordvpn/data/technologies.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": 1, 4 | "name": "IKEv2/IPSec", 5 | "identifier": "ikev2", 6 | "internal_identifier": "ikev2-ipsec", 7 | "created_at": "2017-03-21 12:00:24", 8 | "updated_at": "2017-09-05 14:20:16" 9 | }, 10 | { 11 | "id": 3, 12 | "name": "OpenVPN UDP", 13 | "identifier": "openvpn_udp", 14 | "internal_identifier": "openvpn-udp", 15 | "created_at": "2017-05-04 08:03:24", 16 | "updated_at": "2017-05-09 19:27:37" 17 | }, 18 | { 19 | "id": 5, 20 | "name": "OpenVPN TCP", 21 | "identifier": "openvpn_tcp", 22 | "internal_identifier": "openvpn-tcp", 23 | "created_at": "2017-05-09 19:28:14", 24 | "updated_at": "2017-05-09 19:28:14" 25 | }, 26 | { 27 | "id": 7, 28 | "name": "Socks 5", 29 | "identifier": "socks", 30 | "internal_identifier": "socks", 31 | "created_at": "2017-05-09 19:28:57", 32 | "updated_at": "2017-06-13 14:27:05" 33 | }, 34 | { 35 | "id": 9, 36 | "name": "HTTP Proxy", 37 | "identifier": "proxy", 38 | "internal_identifier": "proxy", 39 | "created_at": "2017-05-09 19:29:09", 40 | "updated_at": "2017-06-13 14:25:29" 41 | }, 42 | { 43 | "id": 11, 44 | "name": "PPTP", 45 | "identifier": "pptp", 46 | "internal_identifier": "pptp", 47 | "created_at": "2017-05-09 19:29:16", 48 | "updated_at": "2017-05-09 19:29:16" 49 | }, 50 | { 51 | "id": 13, 52 | "name": "L2TP/IPSec", 53 | "identifier": "l2tp", 54 | "internal_identifier": "l2tp-ipsec", 55 | "created_at": "2017-05-09 19:29:26", 56 | "updated_at": "2017-09-05 14:19:42" 57 | }, 58 | { 59 | "id": 15, 60 | "name": "OpenVPN UDP Obfuscated", 61 | "identifier": "openvpn_xor_udp", 62 | "internal_identifier": "openvpn-xor-udp", 63 | "created_at": "2017-05-26 14:04:04", 64 | "updated_at": "2017-11-07 08:37:53" 65 | }, 66 | { 67 | "id": 17, 68 | "name": "OpenVPN TCP Obfuscated", 69 | "identifier": "openvpn_xor_tcp", 70 | "internal_identifier": "openvpn-xor-tcp", 71 | "created_at": "2017-05-26 14:04:27", 72 | "updated_at": "2017-11-07 08:38:16" 73 | }, 74 | { 75 | "id": 19, 76 | "name": "HTTP CyberSec Proxy", 77 | "identifier": "proxy_cybersec", 78 | "internal_identifier": "proxy-cybersec", 79 | "created_at": "2017-08-22 12:44:49", 80 | "updated_at": "2017-08-22 12:44:49" 81 | }, 82 | { 83 | "id": 21, 84 | "name": "HTTP Proxy (SSL)", 85 | "identifier": "proxy_ssl", 86 | "internal_identifier": "proxy-ssl", 87 | "created_at": "2017-10-02 12:45:14", 88 | "updated_at": "2017-10-02 12:45:14" 89 | }, 90 | { 91 | "id": 23, 92 | "name": "HTTP CyberSec Proxy (SSL)", 93 | "identifier": "proxy_ssl_cybersec", 94 | "internal_identifier": "proxy-ssl-cybersec", 95 | "created_at": "2017-10-02 12:50:49", 96 | "updated_at": "2017-10-02 12:50:49" 97 | }, 98 | { 99 | "id": 26, 100 | "name": "IKEv2/IPSec IPv6", 101 | "identifier": "ikev2_v6", 102 | "internal_identifier": "ikev2-ipsec-v6", 103 | "created_at": "2018-09-18 13:35:16", 104 | "updated_at": "2018-09-18 13:35:16" 105 | }, 106 | { 107 | "id": 29, 108 | "name": "OpenVPN UDP IPv6", 109 | "identifier": "openvpn_udp_v6", 110 | "internal_identifier": "openvpn-udp-v6", 111 | "created_at": "2018-09-18 13:35:38", 112 | "updated_at": "2018-09-18 13:35:38" 113 | }, 114 | { 115 | "id": 32, 116 | "name": "OpenVPN TCP IPv6", 117 | "identifier": "openvpn_tcp_v6", 118 | "internal_identifier": "openvpn-tcp-v6", 119 | "created_at": "2018-09-18 13:36:02", 120 | "updated_at": "2018-09-18 13:36:02" 121 | }, 122 | { 123 | "id": 35, 124 | "name": "Wireguard", 125 | "identifier": "wireguard_udp", 126 | "internal_identifier": "wireguard-udp", 127 | "created_at": "2019-02-14 14:08:43", 128 | "updated_at": "2019-02-14 14:08:43" 129 | }, 130 | { 131 | "id": 38, 132 | "name": "OpenVPN UDP TLS Crypt", 133 | "identifier": "openvpn_udp_tls_crypt", 134 | "internal_identifier": "openvpn-udp-tls-crypt", 135 | "created_at": "2019-03-21 14:52:42", 136 | "updated_at": "2019-03-21 14:52:42" 137 | }, 138 | { 139 | "id": 41, 140 | "name": "OpenVPN TCP TLS Crypt", 141 | "identifier": "openvpn_tcp_tls_crypt", 142 | "internal_identifier": "openvpn-tcp-tls-crypt", 143 | "created_at": "2019-03-21 14:53:05", 144 | "updated_at": "2019-03-21 14:53:05" 145 | }, 146 | { 147 | "id": 42, 148 | "name": "OpenVPN UDP Dedicated", 149 | "identifier": "openvpn_dedicated_udp", 150 | "internal_identifier": "openvpn-dedicated-udp", 151 | "created_at": "2019-09-19 14:49:18", 152 | "updated_at": "2019-09-19 14:49:18" 153 | }, 154 | { 155 | "id": 45, 156 | "name": "OpenVPN TCP Dedicated", 157 | "identifier": "openvpn_dedicated_tcp", 158 | "internal_identifier": "openvpn-dedicated-tcp", 159 | "created_at": "2019-09-19 14:49:54", 160 | "updated_at": "2019-09-19 14:49:54" 161 | }, 162 | { 163 | "id": 48, 164 | "name": "Skylark", 165 | "identifier": "skylark", 166 | "internal_identifier": "skylark", 167 | "created_at": "2019-10-28 13:29:37", 168 | "updated_at": "2021-12-27 05:35:17" 169 | }, 170 | { 171 | "id": 50, 172 | "name": "Mesh Relay", 173 | "identifier": "mesh_relay", 174 | "internal_identifier": "mesh-relay", 175 | "created_at": "2021-05-13 12:17:05", 176 | "updated_at": "2021-05-13 12:17:05" 177 | }, 178 | { 179 | "id": 51, 180 | "name": "NordWhisper", 181 | "identifier": "nordwhisper", 182 | "internal_identifier": "nordwhisper", 183 | "created_at": "2024-10-07 10:17:17", 184 | "updated_at": "2024-10-07 10:17:17" 185 | } 186 | ] 187 | -------------------------------------------------------------------------------- /root/usr/local/bin/entrypoint: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # shellcheck shell=sh 3 | 4 | set -eu 5 | 6 | . /usr/local/bin/backend-functions 7 | 8 | SCRIPT_NAME="ENTRYPOINT" 9 | 10 | # Display project information 11 | echo "==================================================================================" 12 | echo "🚀 NordVPN OpenVPN Docker Container" 13 | echo "==================================================================================" 14 | echo "📋 Description: Docker container for NordVPN with OpenVPN and advanced networking" 15 | echo "👤 Author: Alexander Zinchenko " 16 | echo "🔗 Repository: https://github.com/azinchen/nordvpn" 17 | echo "📚 Documentation: https://github.com/azinchen/nordvpn#readme" 18 | echo "🏷️ Image Version: __IMAGE_VERSION__" 19 | echo "📅 Build Date: __BUILD_DATE__" 20 | echo "==================================================================================" 21 | 22 | log "$SCRIPT_NAME" "Applying security rules" 23 | 24 | # ---- helpers --------------------------------------------------------------- 25 | 26 | kernel_ge_4_18() 27 | { 28 | ver="$(uname -r | awk -F- '{print $1}')" # e.g., 4.4.0 or 6.8.0 29 | major="$(echo "$ver" | awk -F. '{print $1}')" 30 | minor="$(echo "$ver" | awk -F. '{print $2}')" 31 | [ -n "$minor" ] || minor=0 32 | if [ "$major" -gt 4 ]; then return 0; fi 33 | if [ "$major" -lt 4 ]; then return 1; fi 34 | [ "$minor" -ge 18 ] 35 | } 36 | 37 | try_policy() 38 | { 39 | _bin="$1" 40 | # Prove backend can change policy on this kernel (DROP then revert) 41 | $_bin -t filter -S >/dev/null 2>&1 || return 1 42 | if $_bin -t filter -P OUTPUT DROP >/dev/null 2>&1; then 43 | $_bin -t filter -P OUTPUT ACCEPT >/dev/null 2>&1 || true 44 | return 0 45 | fi 46 | return 1 47 | } 48 | 49 | flush_nft_if_dirty_v4() 50 | { 51 | # Flush nftables v4 only if we selected legacy for v4 AND nft tables actually carry rules 52 | if [ "${_IPT}" = "iptables-legacy" ] && command -v iptables-nft >/dev/null 2>&1; then 53 | if iptables-nft -S 2>/dev/null | grep -q '^-A ' || iptables-nft -t nat -S 2>/dev/null | grep -q '^-A '; then 54 | iptables-nft -F 2>/dev/null || true 55 | iptables-nft -t nat -F 2>/dev/null || true 56 | iptables-nft -X 2>/dev/null || true 57 | log "$SCRIPT_NAME" "Flushed IPv4 nftables to avoid mixed stacks" 58 | fi 59 | fi 60 | } 61 | 62 | flush_nft_if_dirty_v6() 63 | { 64 | # Flush nftables v6 only if we selected legacy for v6 AND nft v6 tables actually carry rules 65 | if [ -n "${_IP6T:-}" ] && [ "${_IP6T}" = "ip6tables-legacy" ] && command -v ip6tables-nft >/dev/null 2>&1; then 66 | if ip6tables-nft -S 2>/dev/null | grep -q '^-A ' || ip6tables-nft -t nat -S 2>/dev/null | grep -q '^-A '; then 67 | ip6tables-nft -F 2>/dev/null || true 68 | ip6tables-nft -t nat -F 2>/dev/null || true 69 | ip6tables-nft -X 2>/dev/null || true 70 | log "$SCRIPT_NAME" "Flushed IPv6 nftables to avoid mixed stacks" 71 | fi 72 | fi 73 | } 74 | 75 | # ---- IPv4 backend picker: prefer nft on ≥4.18; else legacy ----------------- 76 | 77 | pick_ipv4_backend() 78 | { 79 | _IPT="" 80 | if kernel_ge_4_18; then 81 | if command -v iptables >/dev/null 2>&1 && iptables -V 2>&1 | grep -qi "(nf_tables)"; then 82 | try_policy iptables && _IPT="iptables" 83 | fi 84 | [ -n "$_IPT" ] || { command -v iptables-legacy >/dev/null 2>&1 && try_policy iptables-legacy && _IPT="iptables-legacy"; } 85 | [ -n "$_IPT" ] || { command -v iptables >/dev/null 2>&1 && try_policy iptables && _IPT="iptables"; } 86 | else 87 | if command -v iptables-legacy >/dev/null 2>&1; then 88 | try_policy iptables-legacy && _IPT="iptables-legacy" 89 | fi 90 | [ -n "$_IPT" ] || { command -v iptables >/dev/null 2>&1 && try_policy iptables && _IPT="iptables"; } 91 | fi 92 | [ -n "$_IPT" ] || { log_error "$SCRIPT_NAME" "ERROR: no working iptables (IPv4) found"; exit 1; } 93 | 94 | log "$SCRIPT_NAME" "Kernel: $(uname -r)" 95 | log "$SCRIPT_NAME" "Using iptables backend: ${_IPT}" 96 | } 97 | 98 | # ---- IPv6 backend picker: prefer nft on ≥4.18; else legacy ----------------- 99 | 100 | pick_ipv6_backend() 101 | { 102 | _IP6T="" 103 | if kernel_ge_4_18; then 104 | if command -v ip6tables >/dev/null 2>&1 && ip6tables -V 2>&1 | grep -qi "(nf_tables)"; then 105 | try_policy ip6tables && _IP6T="ip6tables" 106 | fi 107 | [ -n "$_IP6T" ] || { command -v ip6tables-legacy >/dev/null 2>&1 && try_policy ip6tables-legacy && _IP6T="ip6tables-legacy"; } || true 108 | [ -n "$_IP6T" ] || { command -v ip6tables >/dev/null 2>&1 && try_policy ip6tables && _IP6T="ip6tables"; } || true 109 | else 110 | if command -v ip6tables-legacy >/dev/null 2>&1; then 111 | try_policy ip6tables-legacy && _IP6T="ip6tables-legacy" 112 | fi 113 | [ -n "$_IP6T" ] || { command -v ip6tables >/dev/null 2>&1 && try_policy ip6tables && _IP6T="ip6tables"; } || true 114 | fi 115 | 116 | # Last-resort: if a binary exists at all, use it (even if probe failed) 117 | [ -n "$_IP6T" ] || { command -v ip6tables-legacy >/dev/null 2>&1 && _IP6T="ip6tables-legacy"; } || true 118 | [ -n "$_IP6T" ] || { command -v ip6tables >/dev/null 2>&1 && _IP6T="ip6tables"; } || true 119 | 120 | if [ -n "$_IP6T" ]; then 121 | log "$SCRIPT_NAME" "Using ip6tables backend: ${_IP6T}" 122 | else 123 | log "$SCRIPT_NAME" "IPv6 support unavailable" 124 | fi 125 | } 126 | 127 | verify_ipv6_backend() 128 | { 129 | # If we picked something for v6, make sure v6 tables actually exist and nat table is usable. 130 | if [ -n "$_IP6T" ]; then 131 | if ! ${_IP6T} -t filter -S >/dev/null 2>&1 || ! ${_IP6T} -t nat -F >/dev/null 2>&1; then 132 | log "$SCRIPT_NAME" "IPv6 tables unavailable in current namespace" 133 | _IP6T="" 134 | fi 135 | fi 136 | } 137 | 138 | pick_ipv4_backend 139 | pick_ipv6_backend 140 | verify_ipv6_backend 141 | 142 | # If we chose legacy for a family, clear any existing nft rules for that family to avoid mixed stacks 143 | flush_nft_if_dirty_v4 144 | flush_nft_if_dirty_v6 145 | 146 | # Publish for other scripts 147 | mkdir -p /run/xt 148 | { 149 | printf 'IPT=%s\n' "$_IPT" 150 | printf 'IP6T=%s\n' "$_IP6T" 151 | } > /run/xt/backend.env 152 | chmod 0644 /run/xt/backend.env 153 | 154 | . /run/xt/backend.env 155 | 156 | # ---- secure defaults (v4 + v6 if available) -------------------------------- 157 | 158 | # Default DROP policies (set ASAP) 159 | run4_critical -t filter -P OUTPUT DROP 160 | run4_critical -t filter -P INPUT DROP 161 | run4_critical -t filter -P FORWARD DROP 162 | run6_critical -t filter -P OUTPUT DROP 163 | run6_critical -t filter -P INPUT DROP 164 | run6_critical -t filter -P FORWARD DROP 165 | 166 | # Flush existing rules (filter + nat), best effort 167 | run4_critical -t filter -F; run4_critical -t filter -X 168 | run4_critical -t nat -F; run4_critical -t nat -X 169 | run6_critical -t filter -F; run6_critical -t filter -X 170 | run6_critical -t nat -F; run6_critical -t nat -X 171 | 172 | # Allow loopback (critical for local apps) 173 | run4 -A INPUT -i lo -j ACCEPT 174 | run4 -A OUTPUT -o lo -j ACCEPT 175 | 176 | log "$SCRIPT_NAME" "Security rules applied - all traffic blocked by default" 177 | 178 | exec /init "$@" 179 | -------------------------------------------------------------------------------- /.github/workflows/ci-build-deploy.yml: -------------------------------------------------------------------------------- 1 | name: CI - Build and Deploy 2 | 3 | on: 4 | push: 5 | # Production builds: tags only -> push to both registries with latest tag 6 | # Development builds: master and other branches -> push only to GHCR 7 | branches: [ '**' ] 8 | tags: [ '**' ] 9 | pull_request: 10 | # Only PRs targeting master branch 11 | branches: [ master ] 12 | 13 | permissions: 14 | contents: read 15 | packages: write 16 | actions: read 17 | security-events: write # Required for called security workflows to upload SARIF files 18 | 19 | env: 20 | # Multi-platform builds for production (master/tags) 21 | PLATFORMS: "linux/386,linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x,linux/riscv64" 22 | # Fast builds for development branches (amd64 only) 23 | DEV_PLATFORMS: "linux/amd64" # Only build for amd64 on development branches for speed 24 | 25 | jobs: 26 | build-and-deploy: 27 | name: 🏗️ Build and Deploy Docker Image 28 | runs-on: ubuntu-latest 29 | outputs: 30 | image_tag: ${{ steps.git.outputs.image_tag }} 31 | is_tag: ${{ steps.git.outputs.is_tag }} 32 | steps: 33 | # === VALIDATION PHASE === 34 | - name: Checkout 35 | uses: actions/checkout@v6.0.1 36 | 37 | - name: Validate required secrets 38 | shell: bash 39 | run: | 40 | echo "🔐 Validating required secrets and environment..." 41 | 42 | # Check if required secrets are available for production builds (tags only) 43 | if [[ "${{ github.ref_type }}" == "tag" ]]; then 44 | if [[ -z "${{ secrets.DOCKERHUB_USERNAME }}" ]] || [[ -z "${{ secrets.DOCKERHUB_TOKEN }}" ]]; then 45 | echo "❌ DockerHub credentials not available for production build" 46 | exit 1 47 | fi 48 | if [[ -z "${{ secrets.DOCKERHUB_PASSWORD }}" ]]; then 49 | echo "⚠️ DockerHub password not available - repo description update will be skipped" 50 | fi 51 | fi 52 | 53 | echo "✅ Secret validation completed" 54 | 55 | # === BUILD CONFIGURATION PHASE === 56 | - name: Get branch name and build metadata 57 | id: git 58 | shell: bash 59 | run: | 60 | # Determine if this is a tag or branch 61 | IS_TAG=${{ github.ref_type == 'tag' }} 62 | 63 | # Get clean branch/tag name 64 | if [[ "$IS_TAG" == "true" ]]; then 65 | # Remove 'v' prefix from tag name for production releases 66 | IMAGE_TAG=${GITHUB_REF_NAME#v} 67 | PLATFORMS="${{ env.PLATFORMS }}" # Build all platforms for tags 68 | PUSH_TO_PROD=true 69 | elif [[ "$GITHUB_REF_NAME" == "master" ]]; then 70 | IMAGE_TAG="dev" # Master branch tagged as 'dev' 71 | PLATFORMS="${{ env.PLATFORMS }}" # Build all platforms for master 72 | PUSH_TO_PROD=false 73 | else 74 | # Sanitize branch name for Docker tag (replace / and other invalid chars with -) 75 | IMAGE_TAG=$(echo "${GITHUB_REF_NAME}" | sed 's/[^a-zA-Z0-9._-]/-/g' | sed 's/^-\+\|-\+$//g') 76 | PLATFORMS="${{ env.DEV_PLATFORMS }}" # Build only amd64 for dev branches 77 | PUSH_TO_PROD=false 78 | fi 79 | 80 | echo "image_tag=${IMAGE_TAG}" >> $GITHUB_OUTPUT 81 | echo "is_tag=${IS_TAG}" >> $GITHUB_OUTPUT 82 | echo "platforms=${PLATFORMS}" >> $GITHUB_OUTPUT 83 | echo "push_to_prod=${PUSH_TO_PROD}" >> $GITHUB_OUTPUT 84 | echo "build_date=$(date -u +'%Y-%m-%d %H:%M:%S %Z')" >> $GITHUB_OUTPUT 85 | 86 | echo "Action branch=${GITHUB_REF_NAME} tag=${IS_TAG} ref=${GITHUB_REF} image_tag=${IMAGE_TAG} platforms=${PLATFORMS}" 87 | 88 | # === AUTHENTICATION PHASE === 89 | - name: Login to DockerHub 90 | if: ${{ steps.git.outputs.push_to_prod == 'true' }} 91 | uses: docker/login-action@v3.6.0 92 | with: 93 | username: ${{ secrets.DOCKERHUB_USERNAME }} 94 | password: ${{ secrets.DOCKERHUB_TOKEN }} 95 | 96 | - name: Login to GitHub Container Registry 97 | uses: docker/login-action@v3.6.0 98 | with: 99 | registry: ghcr.io 100 | username: ${{ github.actor }} 101 | password: ${{ github.token }} 102 | 103 | # === DOCKER BUILD SETUP PHASE === 104 | - name: Set up QEMU 105 | uses: docker/setup-qemu-action@v3.7.0 106 | with: 107 | platforms: ${{ steps.git.outputs.platforms }} 108 | 109 | - name: Set up Docker Buildx 110 | uses: docker/setup-buildx-action@v3.11.1 111 | 112 | # === BUILD AND PUSH PHASE === 113 | - name: Build and push image (Development) 114 | if: ${{ steps.git.outputs.push_to_prod == 'false' }} 115 | uses: docker/build-push-action@v6.18.0 116 | with: 117 | platforms: ${{ steps.git.outputs.platforms }} 118 | push: true 119 | cache-from: type=gha 120 | cache-to: type=gha,mode=max 121 | build-args: | 122 | IMAGE_VERSION=${{ steps.git.outputs.image_tag }} 123 | BUILD_DATE=${{ steps.git.outputs.build_date }} 124 | tags: | 125 | ghcr.io/${{ github.repository }}:${{ steps.git.outputs.image_tag }} 126 | 127 | - name: Build and push image (Production) 128 | if: ${{ steps.git.outputs.push_to_prod == 'true' }} 129 | uses: docker/build-push-action@v6.18.0 130 | with: 131 | platforms: ${{ steps.git.outputs.platforms }} 132 | push: true 133 | cache-from: type=gha 134 | cache-to: type=gha,mode=max 135 | build-args: | 136 | IMAGE_VERSION=${{ steps.git.outputs.image_tag }} 137 | BUILD_DATE=${{ steps.git.outputs.build_date }} 138 | tags: | 139 | ${{ github.repository }}:${{ steps.git.outputs.image_tag }} 140 | ghcr.io/${{ github.repository }}:${{ steps.git.outputs.image_tag }} 141 | ${{ github.repository }}:latest 142 | ghcr.io/${{ github.repository }}:latest 143 | 144 | # === MONITORING AND METADATA PHASE === 145 | - name: Get image size and metadata 146 | if: ${{ always() }} 147 | shell: bash 148 | run: | 149 | echo "📊 Image Build Metrics:" 150 | echo "- Image Tag: ${{ steps.git.outputs.image_tag }}" 151 | echo "- Platforms: ${{ steps.git.outputs.platforms }}" 152 | echo "- Production Build: ${{ steps.git.outputs.push_to_prod }}" 153 | echo "- Registry: ghcr.io/${{ github.repository }}:${{ steps.git.outputs.image_tag }}" 154 | 155 | # Get image size if possible 156 | if docker manifest inspect ghcr.io/${{ github.repository }}:${{ steps.git.outputs.image_tag }} >/dev/null 2>&1; then 157 | echo "✅ Image successfully pushed and available" 158 | else 159 | echo "⚠️ Image may still be propagating" 160 | fi 161 | 162 | # === REPOSITORY MAINTENANCE PHASE === 163 | - name: Update repo description 164 | if: ${{ steps.git.outputs.is_tag == 'true' }} 165 | uses: peter-evans/dockerhub-description@v5.0.0 166 | with: 167 | username: ${{ secrets.DOCKERHUB_USERNAME }} 168 | password: ${{ secrets.DOCKERHUB_PASSWORD }} 169 | repository: ${{ github.repository }} 170 | short-description: ${{ github.event.repository.description }} 171 | 172 | # === SECURITY SCANNING PHASE === 173 | security-scan-built-image: 174 | name: 🔒 Security Scan - Built Image 175 | needs: build-and-deploy 176 | if: ${{ always() && needs.build-and-deploy.result == 'success' }} 177 | uses: ./.github/workflows/security-docker.yml 178 | with: 179 | image_tag: ${{ needs.build-and-deploy.outputs.image_tag }} 180 | is_production_build: ${{ needs.build-and-deploy.outputs.is_tag == 'true' }} 181 | secrets: inherit 182 | -------------------------------------------------------------------------------- /CITIES.md: -------------------------------------------------------------------------------- 1 | Last updated: 2025-12-16 2 | --- 3 | # List of CITIES with NordVPN servers 4 | 5 | Country | Code | ID | City | ID 6 | --------|------|----|------|--- 7 | Afghanistan | AF | 1 | Kabul | 98270 8 | Albania | AL | 2 | Tirana | 308615 9 | Algeria | DZ | 3 | Algiers | 2438216 10 | Andorra | AD | 5 | Andorra la Vella | 20 11 | Angola | AO | 6 | Luanda | 351407 12 | Argentina | AR | 10 | Buenos Aires | 384866 13 | Armenia | AM | 11 | Yerevan | 322385 14 | Australia | AU | 13 | Adelaide | 452717 15 | Australia | AU | 13 | Brisbane | 456494 16 | Australia | AU | 13 | Melbourne | 470813 17 | Australia | AU | 13 | Perth | 475799 18 | Australia | AU | 13 | Sydney | 479570 19 | Austria | AT | 14 | Vienna | 448799 20 | Azerbaijan | AZ | 15 | Baku | 490037 21 | Bahamas | BS | 16 | Nassau | 988382 22 | Bahrain | BH | 17 | Manama | 789398 23 | Bangladesh | BD | 18 | Dhaka | 594935 24 | Belgium | BE | 21 | Brussels | 654293 25 | Belize | BZ | 22 | Belmopan | 1037459 26 | Bermuda | BM | 24 | Hamilton | 809078 27 | Bhutan | BT | 25 | Thimphu | 989699 28 | Bolivia | BO | 26 | La Paz | 838514 29 | Bosnia and Herzegovina | BA | 27 | Sarajevo | 556823 30 | Brazil | BR | 30 | Sao Paulo | 890249 31 | Brunei Darussalam | BN | 32 | Bandar Seri Begawan | 809132 32 | Bulgaria | BG | 33 | Sofia | 777368 33 | Cambodia | KH | 36 | Phnom Penh | 4658987 34 | Canada | CA | 38 | Montreal | 1048463 35 | Canada | CA | 38 | Toronto | 1054250 36 | Canada | CA | 38 | Vancouver | 1054610 37 | Cayman Islands | KY | 40 | George Town | 4922303 38 | Chile | CL | 43 | Santiago | 1227092 39 | Colombia | CO | 47 | Bogota | 1980695 40 | Comoros | KM | 48 | Moroni | 4698371 41 | Costa Rica | CR | 52 | San Jose | 2062994 42 | Croatia | HR | 54 | Zagreb | 3308120 43 | Cyprus | CY | 56 | Nicosia | 2099627 44 | Czech Republic | CZ | 57 | Prague | 2144945 45 | Denmark | DK | 58 | Copenhagen | 2382515 46 | Dominican Republic | DO | 61 | Santo Domingo | 2434841 47 | Ecuador | EC | 63 | Quito | 2485688 48 | Egypt | EG | 64 | Cairo | 2528003 49 | El Salvador | SV | 65 | San Salvador | 7990847 50 | Estonia | EE | 68 | Tallinn | 2514182 51 | Ethiopia | ET | 69 | Addis Ababa | 2660744 52 | Finland | FI | 73 | Helsinki | 2704343 53 | France | FR | 74 | Marseille | 2867102 54 | France | FR | 74 | Paris | 2886284 55 | France | FR | 74 | Strasbourg | 2929151 56 | Georgia | GE | 80 | Tbilisi | 3032063 57 | Germany | DE | 81 | Berlin | 2181458 58 | Germany | DE | 81 | Frankfurt | 2215709 59 | Germany | DE | 81 | Hamburg | 2234906 60 | Ghana | GH | 82 | Accra | 3040355 61 | Greece | GR | 84 | Athens | 3131903 62 | Greenland | GL | 85 | Nuuk | 3085001 63 | Guam | GU | 88 | Hagatna | 8808314 64 | Guatemala | GT | 89 | Guatemala City | 3202463 65 | Honduras | HN | 96 | Tegucigalpa | 3270551 66 | Hong Kong | HK | 97 | Hong Kong | 3232931 67 | Hungary | HU | 98 | Budapest | 3348344 68 | Iceland | IS | 99 | Reykjavik | 4509791 69 | India | IN | 100 | Mumbai | 4041548 70 | Indonesia | ID | 101 | Jakarta | 3560288 71 | Iraq | IQ | 103 | Baghdad | 4093955 72 | Ireland | IE | 104 | Dublin | 3939200 73 | Isle of Man | IM | 243 | Douglas | 3965405 74 | Israel | IL | 105 | Tel Aviv | 3964220 75 | Italy | IT | 106 | Milan | 4542737 76 | Italy | IT | 106 | Palermo | 4548074 77 | Italy | IT | 106 | Rome | 4555808 78 | Jamaica | JM | 107 | Kingston | 4576328 79 | Japan | JP | 108 | Osaka | 4621847 80 | Japan | JP | 108 | Tokyo | 4633349 81 | Jersey | JE | 244 | Saint Helier | 4572281 82 | Jordan | JO | 109 | Amman | 4581203 83 | Kazakhstan | KZ | 110 | Astana | 4925732 84 | Kenya | KE | 111 | Nairobi | 4646603 85 | Kuwait | KW | 116 | Kuwait City | 9521894 86 | Lao People's Democratic Republic | LA | 118 | Vientiane | 5015876 87 | Latvia | LV | 119 | Riga | 5192828 88 | Lebanon | LB | 120 | Beirut | 5022080 89 | Libyan Arab Jamahiriya | LY | 123 | Tripoli | 5206697 90 | Liechtenstein | LI | 124 | Vaduz | 5037212 91 | Lithuania | LT | 125 | Vilnius | 5166932 92 | Luxembourg | LU | 126 | Luxembourg | 9521876 93 | Malaysia | MY | 131 | Kuala Lumpur | 5820143 94 | Malta | MT | 134 | Valletta | 5554481 95 | Mauritania | MR | 137 | Nouakchott | 5551598 96 | Mauritius | MU | 138 | Port Louis | 5556011 97 | Mexico | MX | 140 | Mexico | 5677037 98 | Moldova | MD | 142 | Chisinau | 5295179 99 | Monaco | MC | 143 | Monte Carlo | 5292332 100 | Mongolia | MN | 144 | Ulaanbaatar | 5543669 101 | Montenegro | ME | 146 | Podgorica | 5318561 102 | Morocco | MA | 147 | Rabat | 5271254 103 | Mozambique | MZ | 148 | Maputo | 5870336 104 | Myanmar | MM | 149 | Naypyidaw | 9521893 105 | Nepal | NP | 152 | Kathmandu | 6142175 106 | Netherlands | NL | 153 | Amsterdam | 6076868 107 | New Zealand | NZ | 156 | Auckland | 6144239 108 | Nigeria | NG | 159 | Lagos | 6010328 109 | North Macedonia | MK | 128 | Skopje | 5386019 110 | Norway | NO | 163 | Oslo | 6127364 111 | Pakistan | PK | 165 | Karachi | 6600485 112 | Panama | PA | 168 | Panama City | 6176273 113 | Papua New Guinea | PG | 169 | Port Moresby | 6292406 114 | Paraguay | PY | 170 | Asuncion | 9521890 115 | Peru | PE | 171 | Lima | 6222584 116 | Philippines | PH | 172 | Manila | 6391379 117 | Poland | PL | 174 | Warsaw | 6863429 118 | Portugal | PT | 175 | Lisbon | 6906665 119 | Puerto Rico | PR | 176 | San Juan | 9521884 120 | Qatar | QA | 177 | Doha | 6940529 121 | Romania | RO | 179 | Bucharest | 6953096 122 | Rwanda | RW | 181 | Kigali | 7723910 123 | Senegal | SN | 191 | Dakar | 7924958 124 | Serbia | RS | 192 | Belgrade | 7030907 125 | Singapore | SG | 195 | Singapore | 7867982 126 | Slovakia | SK | 196 | Bratislava | 7884305 127 | Slovenia | SI | 197 | Ljubljana | 7874306 128 | Somalia | SO | 199 | Mogadishu | 7971170 129 | South Africa | ZA | 200 | Johannesburg | 9383693 130 | South Korea | KR | 114 | Seoul | 4879586 131 | Spain | ES | 202 | Barcelona | 2572757 132 | Spain | ES | 202 | Madrid | 2619989 133 | Sri Lanka | LK | 203 | Colombo | 5043197 134 | Sweden | SE | 208 | Stockholm | 7852919 135 | Switzerland | CH | 209 | Zurich | 1171814 136 | Taiwan | TW | 211 | Taipei | 8544365 137 | Tajikistan | TJ | 212 | Dushanbe | 8269814 138 | Thailand | TH | 214 | Bangkok | 8121638 139 | Trinidad and Tobago | TT | 218 | Port of Spain | 9521887 140 | Tunisia | TN | 219 | Tunis | 8295401 141 | Turkey | TR | 220 | Istanbul | 8401790 142 | Ukraine | UA | 225 | Kyiv | 8626766 143 | United Arab Emirates | AE | 226 | Dubai | 728 144 | United Arab Emirates | AE | 226 | Fujairah | 800 145 | United Kingdom | GB | 227 | Edinburgh | 2975852 146 | United Kingdom | GB | 227 | Glasgow | 2978888 147 | United Kingdom | GB | 227 | London | 2989907 148 | United Kingdom | GB | 227 | Manchester | 2991110 149 | United States | US | 228 | Ashburn | 9103211 150 | United States | US | 228 | Atlanta | 8792429 151 | United States | US | 228 | Baltimore | 8873894 152 | United States | US | 228 | Boston | 8895305 153 | United States | US | 228 | Buffalo | 8963153 154 | United States | US | 228 | Burlington | 9099731 155 | United States | US | 228 | Charlotte | 8980922 156 | United States | US | 228 | Chicago | 8815352 157 | United States | US | 228 | Dallas | 9080300 158 | United States | US | 228 | Denver | 8770934 159 | United States | US | 228 | Houston | 9083687 160 | United States | US | 228 | Kansas City | 8930717 161 | United States | US | 228 | Lewiston | 8869646 162 | United States | US | 228 | Los Angeles | 8761958 163 | United States | US | 228 | McAllen | 9086162 164 | United States | US | 228 | Miami | 8787782 165 | United States | US | 228 | Nashua | 8948549 166 | United States | US | 228 | Nashville | 9071273 167 | United States | US | 228 | New Haven | 8775974 168 | United States | US | 228 | New York | 8971718 169 | United States | US | 228 | Omaha | 8943887 170 | United States | US | 228 | Phoenix | 8741960 171 | United States | US | 228 | Pittsburgh | 9036872 172 | United States | US | 228 | Providence | 9048614 173 | United States | US | 228 | Saint Louis | 8934551 174 | United States | US | 228 | Salt Lake City | 9097865 175 | United States | US | 228 | San Francisco | 8766359 176 | United States | US | 228 | Seattle | 9128402 177 | United States | US | 228 | Trenton | 8956166 178 | United States | US | 228 | Wilmington | 8781776 179 | Uruguay | UY | 230 | Montevideo | 9150812 180 | Uzbekistan | UZ | 231 | Tashkent | 9166826 181 | Venezuela | VE | 233 | Caracas | 9176843 182 | Vietnam | VN | 234 | Hanoi | 9270302 183 | Vietnam | VN | 234 | Ho Chi Minh City | 9271799 184 | -------------------------------------------------------------------------------- /root/usr/local/share/nordvpn/data/groups.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": 1, 4 | "created_at": "2017-06-13 13:41:00", 5 | "updated_at": "2017-06-13 13:41:00", 6 | "title": "Double VPN", 7 | "identifier": "legacy_double_vpn", 8 | "type": { 9 | "id": 3, 10 | "created_at": "2017-06-13 13:40:17", 11 | "updated_at": "2017-06-13 13:40:23", 12 | "title": "Legacy category", 13 | "identifier": "legacy_group_category" 14 | } 15 | }, 16 | { 17 | "id": 3, 18 | "created_at": "2017-06-13 13:41:22", 19 | "updated_at": "2017-11-06 10:16:14", 20 | "title": "Onion Over VPN", 21 | "identifier": "legacy_onion_over_vpn", 22 | "type": { 23 | "id": 3, 24 | "created_at": "2017-06-13 13:40:17", 25 | "updated_at": "2017-06-13 13:40:23", 26 | "title": "Legacy category", 27 | "identifier": "legacy_group_category" 28 | } 29 | }, 30 | { 31 | "id": 5, 32 | "created_at": "2017-06-13 13:41:42", 33 | "updated_at": "2017-06-13 13:41:42", 34 | "title": "Ultra fast TV", 35 | "identifier": "legacy_ultra_fast_tv", 36 | "type": { 37 | "id": 3, 38 | "created_at": "2017-06-13 13:40:17", 39 | "updated_at": "2017-06-13 13:40:23", 40 | "title": "Legacy category", 41 | "identifier": "legacy_group_category" 42 | } 43 | }, 44 | { 45 | "id": 7, 46 | "created_at": "2017-06-13 13:42:08", 47 | "updated_at": "2017-06-13 13:42:08", 48 | "title": "Anti DDoS", 49 | "identifier": "legacy_anti_ddos", 50 | "type": { 51 | "id": 3, 52 | "created_at": "2017-06-13 13:40:17", 53 | "updated_at": "2017-06-13 13:40:23", 54 | "title": "Legacy category", 55 | "identifier": "legacy_group_category" 56 | } 57 | }, 58 | { 59 | "id": 9, 60 | "created_at": "2017-06-13 13:42:36", 61 | "updated_at": "2018-08-22 12:54:48", 62 | "title": "Dedicated IP", 63 | "identifier": "legacy_dedicated_ip", 64 | "type": { 65 | "id": 3, 66 | "created_at": "2017-06-13 13:40:17", 67 | "updated_at": "2017-06-13 13:40:23", 68 | "title": "Legacy category", 69 | "identifier": "legacy_group_category" 70 | } 71 | }, 72 | { 73 | "id": 11, 74 | "created_at": "2017-06-13 13:43:00", 75 | "updated_at": "2017-06-13 13:43:00", 76 | "title": "Standard VPN servers", 77 | "identifier": "legacy_standard", 78 | "type": { 79 | "id": 3, 80 | "created_at": "2017-06-13 13:40:17", 81 | "updated_at": "2017-06-13 13:40:23", 82 | "title": "Legacy category", 83 | "identifier": "legacy_group_category" 84 | } 85 | }, 86 | { 87 | "id": 13, 88 | "created_at": "2017-06-13 13:43:20", 89 | "updated_at": "2017-06-13 13:43:20", 90 | "title": "Netflix USA", 91 | "identifier": "legacy_netflix_usa", 92 | "type": { 93 | "id": 3, 94 | "created_at": "2017-06-13 13:40:17", 95 | "updated_at": "2017-06-13 13:40:23", 96 | "title": "Legacy category", 97 | "identifier": "legacy_group_category" 98 | } 99 | }, 100 | { 101 | "id": 15, 102 | "created_at": "2017-06-13 13:43:38", 103 | "updated_at": "2017-06-13 13:43:38", 104 | "title": "P2P", 105 | "identifier": "legacy_p2p", 106 | "type": { 107 | "id": 3, 108 | "created_at": "2017-06-13 13:40:17", 109 | "updated_at": "2017-06-13 13:40:23", 110 | "title": "Legacy category", 111 | "identifier": "legacy_group_category" 112 | } 113 | }, 114 | { 115 | "id": 17, 116 | "created_at": "2017-06-13 13:44:10", 117 | "updated_at": "2017-06-13 13:44:10", 118 | "title": "Obfuscated Servers", 119 | "identifier": "legacy_obfuscated_servers", 120 | "type": { 121 | "id": 3, 122 | "created_at": "2017-06-13 13:40:17", 123 | "updated_at": "2017-06-13 13:40:23", 124 | "title": "Legacy category", 125 | "identifier": "legacy_group_category" 126 | } 127 | }, 128 | { 129 | "id": 19, 130 | "created_at": "2017-10-27 14:17:17", 131 | "updated_at": "2017-10-27 14:17:17", 132 | "title": "Europe", 133 | "identifier": "europe", 134 | "type": { 135 | "id": 5, 136 | "created_at": "2017-10-27 14:16:30", 137 | "updated_at": "2017-10-27 14:16:30", 138 | "title": "Regions", 139 | "identifier": "regions" 140 | } 141 | }, 142 | { 143 | "id": 21, 144 | "created_at": "2017-10-27 14:23:03", 145 | "updated_at": "2017-10-30 08:09:48", 146 | "title": "The Americas", 147 | "identifier": "the_americas", 148 | "type": { 149 | "id": 5, 150 | "created_at": "2017-10-27 14:16:30", 151 | "updated_at": "2017-10-27 14:16:30", 152 | "title": "Regions", 153 | "identifier": "regions" 154 | } 155 | }, 156 | { 157 | "id": 23, 158 | "created_at": "2017-10-27 14:23:51", 159 | "updated_at": "2017-10-30 08:09:57", 160 | "title": "Asia Pacific", 161 | "identifier": "asia_pacific", 162 | "type": { 163 | "id": 5, 164 | "created_at": "2017-10-27 14:16:30", 165 | "updated_at": "2017-10-27 14:16:30", 166 | "title": "Regions", 167 | "identifier": "regions" 168 | } 169 | }, 170 | { 171 | "id": 25, 172 | "created_at": "2017-10-27 14:40:12", 173 | "updated_at": "2017-10-30 08:10:20", 174 | "title": "Africa, the Middle East and India", 175 | "identifier": "africa_the_middle_east_and_india", 176 | "type": { 177 | "id": 5, 178 | "created_at": "2017-10-27 14:16:30", 179 | "updated_at": "2017-10-27 14:16:30", 180 | "title": "Regions", 181 | "identifier": "regions" 182 | } 183 | }, 184 | { 185 | "id": 233, 186 | "created_at": "2020-08-06 08:40:18", 187 | "updated_at": "2020-08-06 08:40:18", 188 | "title": "Anycast DNS", 189 | "identifier": "anycast-dns", 190 | "type": { 191 | "id": 3, 192 | "created_at": "2017-06-13 13:40:17", 193 | "updated_at": "2017-06-13 13:40:23", 194 | "title": "Legacy category", 195 | "identifier": "legacy_group_category" 196 | } 197 | }, 198 | { 199 | "id": 236, 200 | "created_at": "2020-08-18 07:22:50", 201 | "updated_at": "2020-08-18 07:22:50", 202 | "title": "Geo DNS", 203 | "identifier": "geo_dns", 204 | "type": { 205 | "id": 3, 206 | "created_at": "2017-06-13 13:40:17", 207 | "updated_at": "2017-06-13 13:40:23", 208 | "title": "Legacy category", 209 | "identifier": "legacy_group_category" 210 | } 211 | }, 212 | { 213 | "id": 239, 214 | "created_at": "2020-08-26 08:21:18", 215 | "updated_at": "2020-08-26 08:21:18", 216 | "title": "Grafana", 217 | "identifier": "grafana", 218 | "type": { 219 | "id": 3, 220 | "created_at": "2017-06-13 13:40:17", 221 | "updated_at": "2017-06-13 13:40:23", 222 | "title": "Legacy category", 223 | "identifier": "legacy_group_category" 224 | } 225 | }, 226 | { 227 | "id": 242, 228 | "created_at": "2020-08-26 08:22:54", 229 | "updated_at": "2020-08-26 08:22:54", 230 | "title": "Kapacitor", 231 | "identifier": "kapacitor", 232 | "type": { 233 | "id": 3, 234 | "created_at": "2017-06-13 13:40:17", 235 | "updated_at": "2017-06-13 13:40:23", 236 | "title": "Legacy category", 237 | "identifier": "legacy_group_category" 238 | } 239 | }, 240 | { 241 | "id": 245, 242 | "created_at": "2020-11-18 12:10:45", 243 | "updated_at": "2020-11-18 12:10:45", 244 | "title": "Socks5 Proxy", 245 | "identifier": "legacy_socks5_proxy", 246 | "type": { 247 | "id": 3, 248 | "created_at": "2017-06-13 13:40:17", 249 | "updated_at": "2017-06-13 13:40:23", 250 | "title": "Legacy category", 251 | "identifier": "legacy_group_category" 252 | } 253 | }, 254 | { 255 | "id": 248, 256 | "created_at": "2020-12-02 12:30:50", 257 | "updated_at": "2020-12-02 12:30:50", 258 | "title": "FastNetMon", 259 | "identifier": "fastnetmon", 260 | "type": { 261 | "id": 3, 262 | "created_at": "2017-06-13 13:40:17", 263 | "updated_at": "2017-06-13 13:40:23", 264 | "title": "Legacy category", 265 | "identifier": "legacy_group_category" 266 | } 267 | } 268 | ] 269 | -------------------------------------------------------------------------------- /.github/workflows/maintenance-cleanup.yml: -------------------------------------------------------------------------------- 1 | name: Maintenance - Registry Cleanup 2 | 3 | on: 4 | schedule: 5 | # Run weekly on Sundays at 2:00 AM UTC 6 | - cron: '0 2 * * 0' 7 | workflow_dispatch: 8 | inputs: 9 | dry_run: 10 | description: 'Run in dry-run mode (preview only)' 11 | required: false 12 | default: true 13 | type: boolean 14 | max_release_days: 15 | description: 'Maximum age in days for release tags' 16 | required: false 17 | default: '365' 18 | type: string 19 | max_dev_days: 20 | description: 'Maximum age in days for development tags' 21 | required: false 22 | default: '90' 23 | type: string 24 | protect_latest_per: 25 | description: 'Protection level for latest tags' 26 | required: false 27 | default: 'minor' 28 | type: choice 29 | options: 30 | - none 31 | - minor 32 | - patch 33 | 34 | jobs: 35 | cleanup-registries: 36 | name: 🧹 Cleanup Docker Registries 37 | runs-on: ubuntu-latest 38 | permissions: 39 | contents: read 40 | packages: write # Required for GHCR cleanup 41 | 42 | steps: 43 | - name: Check Docker Hub secrets 44 | id: check_dockerhub 45 | run: | 46 | DOCKERHUB_AVAILABLE="false" 47 | if [ -n "${{ secrets.DOCKERHUB_USERNAME }}" ] && [ -n "${{ secrets.DOCKERHUB_TOKEN }}" ]; then 48 | DOCKERHUB_AVAILABLE="true" 49 | echo "✅ Docker Hub secrets are configured" 50 | else 51 | echo "⚠️ Docker Hub secrets are not set - Docker Hub cleanup will be skipped" 52 | if [ -z "${{ secrets.DOCKERHUB_USERNAME }}" ]; then 53 | echo " - DOCKERHUB_USERNAME secret is missing" 54 | fi 55 | if [ -z "${{ secrets.DOCKERHUB_TOKEN }}" ]; then 56 | echo " - DOCKERHUB_TOKEN secret is missing" 57 | fi 58 | fi 59 | echo "dockerhub_available=${DOCKERHUB_AVAILABLE}" >> $GITHUB_OUTPUT 60 | 61 | - name: Verify repository configuration 62 | run: | 63 | echo "Repository: ${{ github.repository }}" 64 | echo "Owner: ${{ github.repository_owner }}" 65 | echo "GHCR package will be: nordvpn" 66 | echo "Docker Hub repo will be: ${{ github.repository_owner }}/nordvpn" 67 | echo "Docker Hub cleanup: ${{ steps.check_dockerhub.outputs.dockerhub_available == 'true' && 'enabled' || 'disabled (secrets missing)' }}" 68 | 69 | - name: Download registry pruner script 70 | run: | 71 | curl -fsSL https://raw.githubusercontent.com/azinchen/container-registry-pruner/main/registry-prune.sh -o registry-prune.sh 72 | chmod +x registry-prune.sh 73 | 74 | - name: Set cleanup parameters 75 | id: params 76 | run: | 77 | # Default values for scheduled runs (conservative settings) 78 | DRY_RUN="false" 79 | MAX_RELEASE_DAYS="365" # Keep release tags for 1 year 80 | MAX_DEV_DAYS="90" # Keep dev tags for 3 months 81 | PROTECT_LATEST_PER="minor" # Default protection level 82 | 83 | # Override with manual inputs if workflow_dispatch 84 | if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then 85 | DRY_RUN="${{ inputs.dry_run }}" 86 | MAX_RELEASE_DAYS="${{ inputs.max_release_days }}" 87 | MAX_DEV_DAYS="${{ inputs.max_dev_days }}" 88 | PROTECT_LATEST_PER="${{ inputs.protect_latest_per }}" 89 | fi 90 | 91 | echo "dry_run=${DRY_RUN}" >> $GITHUB_OUTPUT 92 | echo "max_release_days=${MAX_RELEASE_DAYS}" >> $GITHUB_OUTPUT 93 | echo "max_dev_days=${MAX_DEV_DAYS}" >> $GITHUB_OUTPUT 94 | echo "protect_latest_per=${PROTECT_LATEST_PER}" >> $GITHUB_OUTPUT 95 | 96 | echo "Configuration:" 97 | echo " Dry run: ${DRY_RUN}" 98 | echo " Max release days: ${MAX_RELEASE_DAYS}" 99 | echo " Max dev days: ${MAX_DEV_DAYS}" 100 | echo " Protect latest per: ${PROTECT_LATEST_PER}" 101 | 102 | - name: Run registry cleanup (dry-run preview) 103 | if: steps.params.outputs.dry_run == 'true' 104 | run: | 105 | echo "🔍 Running in DRY-RUN mode - no images will be deleted" 106 | 107 | # Prepare protect-latest-per argument 108 | PROTECT_ARG="" 109 | if [ "${{ steps.params.outputs.protect_latest_per }}" != "none" ]; then 110 | PROTECT_ARG="--protect-latest-per ${{ steps.params.outputs.protect_latest_per }}" 111 | fi 112 | 113 | # Always cleanup GHCR 114 | echo "Cleaning up GHCR..." 115 | ./registry-prune.sh \ 116 | --ghcr-owner-type users \ 117 | --ghcr-owner ${{ github.repository_owner }} \ 118 | --ghcr-token ${{ github.token }} \ 119 | --ghcr-package nordvpn \ 120 | --max-release-days ${{ steps.params.outputs.max_release_days }} \ 121 | --max-dev-days ${{ steps.params.outputs.max_dev_days }} \ 122 | ${PROTECT_ARG} 123 | 124 | # Only cleanup Docker Hub if secrets are available 125 | if [ "${{ steps.check_dockerhub.outputs.dockerhub_available }}" == "true" ]; then 126 | echo "Cleaning up Docker Hub..." 127 | ./registry-prune.sh \ 128 | --docker-user ${{ secrets.DOCKERHUB_USERNAME }} \ 129 | --docker-pass ${{ secrets.DOCKERHUB_TOKEN }} \ 130 | --docker-namespace ${{ secrets.DOCKERHUB_USERNAME }} \ 131 | --docker-repo nordvpn \ 132 | --max-release-days ${{ steps.params.outputs.max_release_days }} \ 133 | --max-dev-days ${{ steps.params.outputs.max_dev_days }} \ 134 | ${PROTECT_ARG} 135 | else 136 | echo "⚠️ Skipping Docker Hub cleanup - secrets not available" 137 | fi 138 | 139 | - name: Run registry cleanup (execute) 140 | if: steps.params.outputs.dry_run == 'false' 141 | run: | 142 | echo "🗑️ Running in EXECUTE mode - images will be deleted" 143 | 144 | # Prepare protect-latest-per argument 145 | PROTECT_ARG="" 146 | if [ "${{ steps.params.outputs.protect_latest_per }}" != "none" ]; then 147 | PROTECT_ARG="--protect-latest-per ${{ steps.params.outputs.protect_latest_per }}" 148 | fi 149 | 150 | # Always cleanup GHCR 151 | echo "Cleaning up GHCR..." 152 | ./registry-prune.sh \ 153 | --ghcr-owner-type users \ 154 | --ghcr-owner ${{ github.repository_owner }} \ 155 | --ghcr-token ${{ github.token }} \ 156 | --ghcr-package nordvpn \ 157 | --max-release-days ${{ steps.params.outputs.max_release_days }} \ 158 | --max-dev-days ${{ steps.params.outputs.max_dev_days }} \ 159 | ${PROTECT_ARG} \ 160 | --execute \ 161 | --yes 162 | 163 | # Only cleanup Docker Hub if secrets are available 164 | if [ "${{ steps.check_dockerhub.outputs.dockerhub_available }}" == "true" ]; then 165 | echo "Cleaning up Docker Hub..." 166 | ./registry-prune.sh \ 167 | --docker-user ${{ secrets.DOCKERHUB_USERNAME }} \ 168 | --docker-pass ${{ secrets.DOCKERHUB_TOKEN }} \ 169 | --docker-namespace ${{ secrets.DOCKERHUB_USERNAME }} \ 170 | --docker-repo nordvpn \ 171 | --max-release-days ${{ steps.params.outputs.max_release_days }} \ 172 | --max-dev-days ${{ steps.params.outputs.max_dev_days }} \ 173 | ${PROTECT_ARG} \ 174 | --execute \ 175 | --yes 176 | else 177 | echo "⚠️ Skipping Docker Hub cleanup - secrets not available" 178 | fi 179 | 180 | - name: Cleanup summary 181 | if: always() 182 | run: | 183 | echo "✅ Registry cleanup workflow completed" 184 | echo "Mode: ${{ steps.params.outputs.dry_run == 'true' && 'DRY-RUN (preview only)' || 'EXECUTE (images deleted)' }}" 185 | echo "Settings used:" 186 | echo " - Max release tag age: ${{ steps.params.outputs.max_release_days }} days" 187 | echo " - Max dev tag age: ${{ steps.params.outputs.max_dev_days }} days" 188 | echo " - Protected tags: latest (default) + ${{ steps.params.outputs.protect_latest_per == 'none' && 'no version protection' || format('highest {0} per version', steps.params.outputs.protect_latest_per) }}" 189 | echo " - GHCR cleanup: enabled" 190 | echo " - Docker Hub cleanup: ${{ steps.check_dockerhub.outputs.dockerhub_available == 'true' && 'enabled' || 'skipped (secrets missing)' }}" 191 | echo "Registries processed:" 192 | echo " - GHCR: ghcr.io/${{ github.repository_owner }}/nordvpn" 193 | if [ "${{ steps.check_dockerhub.outputs.dockerhub_available }}" == "true" ]; then 194 | echo " - Docker Hub: ${{ secrets.DOCKERHUB_USERNAME }}/nordvpn" 195 | else 196 | echo " - Docker Hub: skipped" 197 | fi 198 | -------------------------------------------------------------------------------- /root/usr/local/bin/vpn-config: -------------------------------------------------------------------------------- 1 | #!/command/with-contenv sh 2 | # shellcheck shell=sh 3 | 4 | set -eu 5 | 6 | . /usr/local/bin/backend-functions 7 | 8 | SCRIPT_NAME="VPN-CONFIG" 9 | 10 | log "$SCRIPT_NAME" "Starting VPN configuration" 11 | 12 | numericregex="^[0-9]+$" 13 | 14 | # Helper functions for pattern matching (sh-compatible) 15 | is_numeric() 16 | { 17 | case "$1" in 18 | ''|*[!0-9]*) return 1 ;; 19 | *) return 0 ;; 20 | esac 21 | } 22 | 23 | is_specific_server() 24 | { 25 | case "$1" in 26 | [a-zA-Z][a-zA-Z][0-9]*) 27 | # Check if it's exactly 2 letters followed by numbers 28 | prefix=$(echo "$1" | cut -c1-2) 29 | suffix=$(echo "$1" | cut -c3-) 30 | case "$prefix" in 31 | [a-zA-Z][a-zA-Z]) ;; 32 | *) return 1 ;; 33 | esac 34 | case "$suffix" in 35 | ''|*[!0-9]*) return 1 ;; 36 | *) return 0 ;; 37 | esac 38 | ;; 39 | *) return 1 ;; 40 | esac 41 | } 42 | 43 | contains_substring() 44 | { 45 | case "$1" in 46 | *"$2"*) return 0 ;; 47 | *) return 1 ;; 48 | esac 49 | } 50 | 51 | getcountryid() 52 | { 53 | input=$1 54 | 55 | if is_numeric "$input"; then 56 | id=$(jq -r --argjson ID "$input" '.[] | select(.id == $ID) | .id' < "/usr/local/share/nordvpn/data/countries.json") 57 | else 58 | id=$(jq -r --arg NAME "$input" '.[] | select(.name == $NAME) | .id' < "/usr/local/share/nordvpn/data/countries.json") 59 | if [ -z "$id" ]; then 60 | id=$(jq -r --arg CODE "$input" '.[] | select(.code == $CODE) | .id' < "/usr/local/share/nordvpn/data/countries.json") 61 | fi 62 | fi 63 | 64 | printf '%s' "$id" 65 | 66 | if [ -z "$id" ]; then 67 | return 1 68 | fi 69 | 70 | return 0 71 | } 72 | 73 | getcountryname() 74 | { 75 | input=$1 76 | 77 | if is_numeric "$input"; then 78 | name=$(jq -r --argjson ID "$input" '.[] | select(.id == $ID) | .name' < "/usr/local/share/nordvpn/data/countries.json") 79 | else 80 | name=$(jq -r --arg NAME "$input" '.[] | select(.name == $NAME) | .name' < "/usr/local/share/nordvpn/data/countries.json") 81 | if [ -z "$name" ]; then 82 | name=$(jq -r --arg CODE "$input" '.[] | select(.code == $CODE) | .name' < "/usr/local/share/nordvpn/data/countries.json") 83 | fi 84 | fi 85 | 86 | printf '%s' "$name" 87 | 88 | if [ -z "$name" ]; then 89 | return 1 90 | fi 91 | 92 | return 0 93 | } 94 | 95 | getcityid() 96 | { 97 | input=$1 98 | 99 | if is_numeric "$input"; then 100 | id=$(jq -r --argjson ID "$input" '.[] | select(.cities[]? | .id == $ID) | .cities[] | select(.id == $ID) | .id' < "/usr/local/share/nordvpn/data/countries.json" | head -n 1) 101 | else 102 | id=$(jq -r --arg NAME "$input" '.[] | select(.cities[]? | .name == $NAME) | .cities[] | select(.name == $NAME) | .id' < "/usr/local/share/nordvpn/data/countries.json" | head -n 1) 103 | if [ -z "$id" ]; then 104 | id=$(jq -r --arg DNS_NAME "$input" '.[] | select(.cities[]? | .dns_name == $DNS_NAME) | .cities[] | select(.dns_name == $DNS_NAME) | .id' < "/usr/local/share/nordvpn/data/countries.json" | head -n 1) 105 | fi 106 | fi 107 | 108 | printf '%s' "$id" 109 | 110 | if [ -z "$id" ]; then 111 | return 1 112 | fi 113 | 114 | return 0 115 | } 116 | 117 | getcityname() 118 | { 119 | input=$1 120 | 121 | if is_numeric "$input"; then 122 | name=$(jq -r --argjson ID "$input" '.[] | select(.cities[]? | .id == $ID) | .cities[] | select(.id == $ID) | .name' < "/usr/local/share/nordvpn/data/countries.json" | head -n 1) 123 | else 124 | name=$(jq -r --arg NAME "$input" '.[] | select(.cities[]? | .name == $NAME) | .cities[] | select(.name == $NAME) | .name' < "/usr/local/share/nordvpn/data/countries.json" | head -n 1) 125 | if [ -z "$name" ]; then 126 | name=$(jq -r --arg DNS_NAME "$input" '.[] | select(.cities[]? | .dns_name == $DNS_NAME) | .cities[] | select(.dns_name == $DNS_NAME) | .name' < "/usr/local/share/nordvpn/data/countries.json" | head -n 1) 127 | fi 128 | fi 129 | 130 | printf '%s' "$name" 131 | 132 | if [ -z "$name" ]; then 133 | return 1 134 | fi 135 | 136 | return 0 137 | } 138 | 139 | getcitycoordinates() 140 | { 141 | input=$1 142 | 143 | # First get the city ID using existing function 144 | cityid=$(getcityid "$input") 145 | if [ -z "$cityid" ]; then 146 | return 1 147 | fi 148 | 149 | # Then get coordinates using the city ID 150 | coords=$(jq -r --argjson ID "$cityid" '.[] | select(.cities[]? | .id == $ID) | .cities[] | select(.id == $ID) | "\(.latitude),\(.longitude)"' < "/usr/local/share/nordvpn/data/countries.json" | head -n 1) 151 | 152 | printf '%s' "$coords" 153 | 154 | if [ -z "$coords" ] || [ "$coords" = "null,null" ]; then 155 | return 1 156 | fi 157 | 158 | return 0 159 | } 160 | 161 | getgroupid() 162 | { 163 | input=$1 164 | 165 | # Check for empty input 166 | if [ -z "$input" ]; then 167 | return 1 168 | fi 169 | 170 | if is_numeric "$input"; then 171 | id=$(jq -r --argjson ID "$input" '.[] | select(.id == $ID) | .id' < "/usr/local/share/nordvpn/data/groups.json") 172 | else 173 | id=$(jq -r --arg TITLE "$input" '.[] | select(.title == $TITLE) | .id' < "/usr/local/share/nordvpn/data/groups.json") 174 | if [ -z "$id" ]; then 175 | id=$(jq -r --arg IDENTIFIER "$input" '.[] | select(.identifier == $IDENTIFIER) | .id' < "/usr/local/share/nordvpn/data/groups.json") 176 | fi 177 | fi 178 | 179 | printf '%s' "$id" 180 | 181 | if [ -z "$id" ]; then 182 | return 1 183 | fi 184 | 185 | return 0 186 | } 187 | 188 | getgrouptitle() 189 | { 190 | input=$1 191 | 192 | if is_numeric "$input"; then 193 | title=$(jq -r --argjson ID "$input" '.[] | select(.id == $ID) | .title' < "/usr/local/share/nordvpn/data/groups.json") 194 | else 195 | title=$(jq -r --arg TITLE "$input" '.[] | select(.title == $TITLE) | .title' < "/usr/local/share/nordvpn/data/groups.json") 196 | if [ -z "$title" ]; then 197 | title=$(jq -r --arg IDENTIFIER "$input" '.[] | select(.identifier == $IDENTIFIER) | .title' < "/usr/local/share/nordvpn/data/groups.json") 198 | fi 199 | fi 200 | 201 | printf '%s' "$title" 202 | 203 | if [ -z "$title" ]; then 204 | return 1 205 | fi 206 | 207 | return 0 208 | } 209 | 210 | gettechnologyid() 211 | { 212 | input=$1 213 | 214 | # Check for empty input 215 | if [ -z "$input" ]; then 216 | return 1 217 | fi 218 | 219 | if is_numeric "$input"; then 220 | id=$(jq -r --argjson ID "$input" '.[] | select(.id == $ID) | .id' < "/usr/local/share/nordvpn/data/technologies.json") 221 | else 222 | id=$(jq -r --arg NAME "$input" '.[] | select(.name == $NAME) | .id' < "/usr/local/share/nordvpn/data/technologies.json") 223 | if [ -z "$id" ]; then 224 | id=$(jq -r --arg IDENTIFIER "$input" '.[] | select(.identifier == $IDENTIFIER) | .id' < "/usr/local/share/nordvpn/data/technologies.json") 225 | fi 226 | fi 227 | 228 | printf '%s' "$id" 229 | 230 | if [ -z "$id" ]; then 231 | return 1 232 | fi 233 | 234 | return 0 235 | } 236 | 237 | gettechnologyname() 238 | { 239 | input=$1 240 | 241 | if is_numeric "$input"; then 242 | name=$(jq -r --argjson ID "$input" '.[] | select(.id == $ID) | .name' < "/usr/local/share/nordvpn/data/technologies.json") 243 | else 244 | name=$(jq -r --arg NAME "$input" '.[] | select(.name == $NAME) | .name' < "/usr/local/share/nordvpn/data/technologies.json") 245 | if [ -z "$name" ]; then 246 | name=$(jq -r --arg IDENTIFIER "$input" '.[] | select(.identifier == $IDENTIFIER) | .name' < "/usr/local/share/nordvpn/data/technologies.json") 247 | fi 248 | fi 249 | 250 | printf '%s' "$name" 251 | 252 | if [ -z "$name" ]; then 253 | return 1 254 | fi 255 | 256 | return 0 257 | } 258 | 259 | getopenvpnprotocol() 260 | { 261 | input=$1 262 | 263 | ident=$(jq -r --arg NAME "$input" '.[] | select(.name == $NAME) | .identifier' < "/usr/local/share/nordvpn/data/technologies.json") 264 | if [ -z "$ident" ]; then 265 | if is_numeric "$input"; then 266 | ident=$(jq -r --argjson ID "$input" '.[] | select(.id == $ID) | .identifier' < "/usr/local/share/nordvpn/data/technologies.json") 267 | fi 268 | fi 269 | if [ -z "$ident" ]; then 270 | ident=$input 271 | fi 272 | 273 | if ! contains_substring "$ident" "openvpn"; then 274 | printf "" 275 | return 1 276 | elif contains_substring "$ident" "udp"; then 277 | printf "udp" 278 | return 0 279 | elif contains_substring "$ident" "tcp"; then 280 | printf "tcp" 281 | return 0 282 | else 283 | printf "" 284 | return 1 285 | fi 286 | } 287 | 288 | handle_specific_server() 289 | { 290 | value=$1 291 | 292 | # Convert to lowercase using tr instead of ${value,,} 293 | hostname="$(echo "$value" | tr '[:upper:]' '[:lower:]').nordvpn.com" 294 | ip="$(host -t A "$hostname" | awk '{print $4}')" 295 | # Extract country code (first 2 chars) and number part 296 | country_code=$(echo "$value" | cut -c1-2) 297 | country_num=$(echo "$value" | cut -c3-) 298 | name="$(getcountryname "$country_code") #$country_num" 299 | constructed_json=$(printf '{"name":"%s","hostname":"%s","load":0,"station":"%s"}' "$name" "$hostname" "$ip") 300 | 301 | printf '%s' "$constructed_json" 302 | } 303 | 304 | nord_api_curl() 305 | { 306 | _path="$1" 307 | shift 2>/dev/null || true 308 | 309 | # First, try without predefined API IPs (use DNS resolution) 310 | curl -sG --connect-timeout 10 --max-time 30 \ 311 | "https://api.nordvpn.com/${_path}" "$@" 312 | _rc=$? 313 | if [ $_rc -eq 0 ]; then 314 | return 0 315 | fi 316 | 317 | # If failed, fallback to using predefined API IPs 318 | oldIFS="$IFS"; IFS=',;' 319 | for _ip in $nordvpnapi_ip; do 320 | [ -n "$_ip" ] || continue 321 | 322 | curl -sG --connect-timeout 10 --max-time 30 \ 323 | --resolve "api.nordvpn.com:443:${_ip}" \ 324 | "https://api.nordvpn.com/${_path}" "$@" 325 | _rc=$? 326 | if [ $_rc -eq 0 ]; then 327 | IFS="$oldIFS" 328 | return 0 329 | fi 330 | done 331 | IFS="$oldIFS" 332 | return $_rc 333 | } 334 | 335 | servers="" 336 | locations_count=0 337 | 338 | # Validate technology and group IDs and build filter strings 339 | tech_filter="" 340 | if [ -n "$technology" ]; then 341 | tech_id=$(gettechnologyid "$technology") 342 | if [ -z "$tech_id" ]; then 343 | log_warning "$SCRIPT_NAME" "Warning: Could not find technology \"$technology\"" 344 | else 345 | tech_filter="--data-urlencode filters[servers_technologies][id]=$tech_id" 346 | fi 347 | else 348 | log "$SCRIPT_NAME" "No technology filter specified" 349 | fi 350 | 351 | group_filter="" 352 | if [ -n "$group" ]; then 353 | group_id=$(getgroupid "$group") 354 | if [ -z "$group_id" ]; then 355 | log_warning "$SCRIPT_NAME" "Warning: Could not find group \"$group\"" 356 | else 357 | group_filter="--data-urlencode filters[servers_groups][id]=$group_id" 358 | fi 359 | else 360 | log "$SCRIPT_NAME" "No group filter specified" 361 | fi 362 | 363 | servers="" 364 | locations_count=0 365 | 366 | # Process COUNTRY if set 367 | if [ -n "$country" ]; then 368 | # Convert semicolon/comma separated list to space separated 369 | oldIFS="$IFS"; IFS=',;'; 370 | for value in $country; do 371 | if [ -n "$value" ]; then 372 | locations_count=$((locations_count + 1)) 373 | fi 374 | if is_specific_server "$value"; then 375 | servers="$servers$(handle_specific_server "$value")" 376 | elif [ -n "$value" ]; then 377 | countryid=$(getcountryid "$value") 378 | if [ -n "$countryid" ]; then 379 | # Build curl parameters 380 | curl_params="--data-urlencode filters[country_id]=$countryid" 381 | if [ -n "$tech_filter" ]; then 382 | curl_params="$curl_params $tech_filter" 383 | fi 384 | if [ -n "$group_filter" ]; then 385 | curl_params="$curl_params $group_filter" 386 | fi 387 | 388 | # Execute curl with built parameters 389 | serversincountry=$(eval "nord_api_curl \"v1/servers/recommendations\" $curl_params 2>/dev/null | jq -c '.[]' 2>/dev/null || echo \"\"") 390 | if [ -n "$serversincountry" ]; then 391 | log "$SCRIPT_NAME" "Fetched $(echo "$serversincountry" | jq -s 'length') servers for $(getcountryname "$value")" 392 | echo "$serversincountry" | jq -r '[.name, .hostname, .load, .locations[0].country.name, .locations[0].country.city.name] | "\(.[1]): \(.[2])% load - \(.[3]), \(.[4]) (\(.[0]))"' 393 | servers="$servers""$serversincountry" 394 | else 395 | log_warning "$SCRIPT_NAME" "Warning: No servers returned for country \"$value\"" 396 | fi 397 | else 398 | log_warning "$SCRIPT_NAME" "Warning: Could not find country \"$value\"" 399 | fi 400 | fi 401 | done 402 | IFS="$oldIFS" 403 | fi 404 | 405 | # Process CITY if set 406 | if [ -n "$city" ]; then 407 | # Convert semicolon/comma separated list to space separated 408 | oldIFS="$IFS"; IFS=',;' 409 | for value in $city; do 410 | if [ -n "$value" ]; then 411 | locations_count=$((locations_count + 1)) 412 | fi 413 | if is_specific_server "$value"; then 414 | servers="$servers$(handle_specific_server "$value")" 415 | elif [ -n "$value" ]; then 416 | coords=$(getcitycoordinates "$value") 417 | if [ -n "$coords" ]; then 418 | latitude=$(echo "$coords" | cut -d',' -f1) 419 | longitude=$(echo "$coords" | cut -d',' -f2) 420 | 421 | # Build curl parameters 422 | curl_params="--data-urlencode coordinates[latitude]=$latitude --data-urlencode coordinates[longitude]=$longitude" 423 | if [ -n "$tech_filter" ]; then 424 | curl_params="$curl_params $tech_filter" 425 | fi 426 | if [ -n "$group_filter" ]; then 427 | curl_params="$curl_params $group_filter" 428 | fi 429 | 430 | # Execute curl with built parameters 431 | serversincity=$(eval "nord_api_curl \"v1/servers/recommendations\" $curl_params 2>/dev/null | jq -c '.[]' 2>/dev/null || echo \"\"") 432 | if [ -n "$serversincity" ]; then 433 | log "$SCRIPT_NAME" "Fetched $(echo "$serversincity" | jq -s 'length') servers for $(getcityname "$value")" 434 | echo "$serversincity" | jq -r '[.name, .hostname, .load, .locations[0].country.name, .locations[0].country.city.name] | "\(.[1]): \(.[2])% load - \(.[3]), \(.[4]) (\(.[0]))"' 435 | servers="$servers""$serversincity" 436 | else 437 | log_warning "$SCRIPT_NAME" "Warning: No servers returned for city \"$value\"" 438 | fi 439 | else 440 | log_warning "$SCRIPT_NAME" "Warning: Could not find coordinates for city \"$value\"" 441 | fi 442 | fi 443 | done 444 | IFS="$oldIFS" 445 | fi 446 | 447 | poollength=0 448 | if [ -n "$servers" ]; then 449 | poollength=$(echo "$servers" | jq -s 'unique | length' 2>/dev/null || echo "0") 450 | else 451 | log "$SCRIPT_NAME" "No servers found matching criteria" 452 | fi 453 | 454 | # If no servers found, fallback to default recommended servers 455 | if [ "$poollength" -eq 0 ]; then 456 | log "$SCRIPT_NAME" "Using default recommended servers" 457 | 458 | # Build curl parameters for fallback 459 | curl_params="" 460 | if [ -n "$tech_filter" ]; then 461 | curl_params="$tech_filter" 462 | fi 463 | if [ -n "$group_filter" ]; then 464 | curl_params="$curl_params $group_filter" 465 | fi 466 | 467 | servers=$(eval "nord_api_curl \"v1/servers/recommendations\" $curl_params 2>/dev/null | jq -c '.[]' 2>/dev/null || echo \"\"") 468 | 469 | if [ -n "$servers" ]; then 470 | log "$SCRIPT_NAME" "Fetched $(echo "$servers" | jq -s 'length') default servers" 471 | echo "$servers" | jq -r '[.name, .hostname, .load, .locations[0].country.name, .locations[0].country.city.name] | "\(.[1]): \(.[2])% load - \(.[3]), \(.[4]) (\(.[0]))"' 472 | poollength=$(echo "$servers" | jq -s 'unique | length') 473 | else 474 | log_error "$SCRIPT_NAME" "CRITICAL ERROR: Failed to get fallback servers - sleeping infinite" 475 | sleep infinity 476 | fi 477 | fi 478 | 479 | # Only sort by load if multiple locations are specified, otherwise keep recommended order 480 | if [ \( -n "$country" \) -o \( -n "$city" \) ] && [ "$locations_count" -gt 1 ] && [ "$poollength" -gt 0 ]; then 481 | # Multiple locations - sort by load and remove duplicates 482 | servers=$(echo "$servers" | jq -s -c 'unique | sort_by(.load) | .[]') 483 | else 484 | # Single location or no location specified - keep recommended order as is 485 | servers=$(echo "$servers" | jq -s -c '.[]') 486 | fi 487 | 488 | if [ "$random_top" -ne 0 ]; then 489 | if [ "$random_top" -lt "$poollength" ]; then 490 | filtered=$(echo "$servers" | head -n "$random_top" | shuf) 491 | servers="$filtered"$(echo "$servers" | tail -n +"$((random_top + 1))") 492 | else 493 | servers=$(echo "$servers" | shuf) 494 | fi 495 | fi 496 | 497 | log "$SCRIPT_NAME" "Server pool contains $poollength servers" 498 | if [ "$poollength" -ne 0 ]; then 499 | log "$SCRIPT_NAME" "Top servers in filtered pool:" 500 | echo "$servers" | jq -r '[.name, .hostname, .load, .locations[0].country.name, .locations[0].country.city.name] | "\(.[1]): \(.[2])% load - \(.[3]), \(.[4]) (\(.[0]))"' | head -n 20 501 | fi 502 | 503 | if [ "$poollength" -eq 0 ]; then 504 | log_error "$SCRIPT_NAME" "CRITICAL ERROR: list of selected servers is empty - sleeping infinite" 505 | sleep infinity 506 | fi 507 | 508 | serverip=$(echo "$servers" | jq -r '.station' | head -n 1) 509 | name=$(echo "$servers" | jq -r '.name' | head -n 1) 510 | hostname=$(echo "$servers" | jq -r '.hostname' | head -n 1) 511 | protocol=$(getopenvpnprotocol "$technology") 512 | 513 | # Extract location information 514 | country_name=$(echo "$servers" | jq -r '.locations[0].country.name' | head -n 1) 515 | city_name=$(echo "$servers" | jq -r '.locations[0].country.city.name' | head -n 1) 516 | log "$SCRIPT_NAME" "Selected server: $name ($hostname, $country_name, $city_name)" 517 | 518 | log "$SCRIPT_NAME" "Creating VPN configuration file" 519 | cp "$ovpntemplatefile" "$ovpnfile" 520 | chmod 0600 "$ovpnfile" 521 | chown nordvpn:nordvpn "$ovpnfile" 522 | 523 | log "$SCRIPT_NAME" "Configuring VPN settings (IP: $serverip, Protocol: $protocol)" 524 | sed -i "s/__IP__/$serverip/g" "$ovpnfile" 525 | sed -i "s/__PROTOCOL__/$protocol/g" "$ovpnfile" 526 | sed -i "s/__X509_NAME__/$hostname/g" "$ovpnfile" 527 | 528 | if [ "$protocol" = "udp" ]; then 529 | sed -i "s/__PORT__/1194/g" "$ovpnfile" 530 | elif [ "$protocol" = "tcp" ]; then 531 | sed -i "s/__PORT__/443/g" "$ovpnfile" 532 | else 533 | log_error "$SCRIPT_NAME" "CRITICAL ERROR: TECHNOLOGY environment variable contains wrong parameter \"$technology\" - sleeping infinite" 534 | sleep infinity 535 | fi 536 | 537 | # Verify the config file was created successfully 538 | if [ ! -s "$ovpnfile" ]; then 539 | log_error "$SCRIPT_NAME" "CRITICAL ERROR: VPN config file was not created successfully - sleeping infinite" 540 | sleep infinity 541 | fi 542 | 543 | log "$SCRIPT_NAME" "VPN configuration completed" 544 | exit 0 545 | -------------------------------------------------------------------------------- /scripts/update-apk-versions.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # shellcheck shell=sh 3 | # scripts/update-apk-versions.sh 4 | # 5 | # This script extracts package names and versions from a specified Dockerfile, 6 | # checks for updates from Alpine package repositories (main first, then community), 7 | # and updates the Dockerfile if necessary. 8 | # 9 | # Usage: ./scripts/update-apk-versions.sh 10 | # If no argument is provided, it defaults to "Dockerfile" in the current directory. 11 | # 12 | # REGULAR PACKAGE VERSIONS (NO PLATFORM-SPECIFIC DIFFERENCES) 13 | # ============================================================ 14 | # For packages that use the same version across all architectures, use the 15 | # standard apk add format with explicit version pinning. 16 | # 17 | # Format in Dockerfile: 18 | # apk --no-cache --no-progress add \ 19 | # = \ 20 | # = \ 21 | # 22 | # Example: 23 | # apk --no-cache --no-progress add \ 24 | # curl=8.14.1-r2 \ 25 | # iptables=1.8.11-r1 \ 26 | # jq=1.8.0-r0 \ 27 | # openvpn=2.6.14-r0 \ 28 | # && \ 29 | # 30 | # The script will: 31 | # - Automatically detect all packages with version pins (package=version format) 32 | # - Check the latest version from Alpine repositories (x86_64) 33 | # - Update package versions in-place when newer versions are available 34 | # - Skip packages using variables (e.g., ${variable_name}) 35 | # 36 | # PLATFORM-SPECIFIC PACKAGE VERSIONS 37 | # =================================== 38 | # For packages that have different versions across architectures, use the 39 | # PLATFORM_VERSIONS comment format. This allows the script to automatically 40 | # check and update versions for each platform. 41 | # 42 | # Format in Dockerfile (using TARGETPLATFORM from buildx): 43 | # # PLATFORM_VERSIONS: : = = ... 44 | # _version=$(case "${TARGETPLATFORM:-linux/amd64}" in \ 45 | # ) echo "" ;; \ 46 | # echo "" ;; \ 47 | # *) echo "" ;; esac) && \ 48 | # apk --no-cache --no-progress add \ 49 | # =${_version} \ 50 | # 51 | # Platform names: 52 | # - Use "default" for the wildcard (*) case (linux/amd64 and other platforms) 53 | # - Use TARGETPLATFORM values: linux/amd64, linux/arm64, linux/arm/v7, linux/arm/v6, 54 | # linux/386, linux/ppc64le, linux/s390x, linux/riscv64 55 | # - The script automatically maps TARGETPLATFORM values to Alpine package repository architectures 56 | # 57 | # Example: 58 | # # PLATFORM_VERSIONS: bind-tools: default=9.20.15-r0 linux/riscv64=9.20.13-r0 59 | # bind_tools_version=$(case "${TARGETPLATFORM:-linux/amd64}" in \ 60 | # linux/riscv64) echo "9.20.13-r0" ;; \ 61 | # *) echo "9.20.15-r0" ;; esac) && \ 62 | # apk --no-cache --no-progress add \ 63 | # bind-tools=${bind_tools_version} \ 64 | # 65 | # Important formatting rules: 66 | # 1. The PLATFORM_VERSIONS comment must be indented with 4 spaces 67 | # 2. All 'echo' statements in the case statement must align in the same column 68 | # 3. Use 10 spaces between the closing parenthesis and 'echo' for alignment 69 | # 4. Package references using variables (e.g., ${bind_tools_version}) are 70 | # automatically excluded from regular version checking 71 | # 72 | # The script will: 73 | # - Check versions for "default" platform against x86_64 packages 74 | # - Check versions for other platforms against their specific Alpine repository architectures 75 | # - Update both the comment line and the case statement when new versions are found 76 | # - Preserve indentation and alignment throughout the update process 77 | 78 | set -eu 79 | 80 | DOCKERFILE="${1:-Dockerfile}" 81 | 82 | if [ ! -f "$DOCKERFILE" ]; then 83 | echo "Error: Dockerfile not found at '$DOCKERFILE'" 84 | exit 1 85 | fi 86 | 87 | # --- 1. Extract Alpine Version from Dockerfile --- 88 | ALPINE_VERSION_FULL=$(grep '^FROM alpine:' "$DOCKERFILE" | head -n1 | sed -E 's/FROM alpine:(.*)/\1/') 89 | ALPINE_BRANCH=$(echo "$ALPINE_VERSION_FULL" | cut -d. -f1,2) 90 | 91 | # --- 2. Extract Package List from Dockerfile --- 92 | joined_content=$(sed ':a;N;$!ba;s/\\\n/ /g' "$DOCKERFILE") 93 | package_lines=$(echo "$joined_content" | grep -oP 'apk --no-cache --no-progress add\s+\K[^&]+') 94 | # Filter out packages with variable references (containing ${ }) 95 | packages=$(echo "$package_lines" | tr ' ' '\n' | sed '/^\s*$/d' | grep -v '\${') 96 | 97 | # Sort packages by line number (forward order) 98 | temp_pkg_file=$(mktemp) 99 | for pkg in $packages; do 100 | line_num=$(grep -n "${pkg}[[:space:]\\]" "$DOCKERFILE" | head -1 | cut -d: -f1) 101 | echo "$line_num:$pkg" >> "$temp_pkg_file" 102 | done 103 | packages=$(sort -n -t: -k1 "$temp_pkg_file" | cut -d: -f2) 104 | rm -f "$temp_pkg_file" 105 | 106 | echo "=== Package Update Check ===" 107 | echo "Alpine version: $ALPINE_BRANCH" 108 | echo "Platform: x86_64" 109 | echo "Main repository: https://pkgs.alpinelinux.org/package/v${ALPINE_BRANCH}/main/x86_64/" 110 | echo "Community repository: https://pkgs.alpinelinux.org/package/v${ALPINE_BRANCH}/community/x86_64/" 111 | echo 112 | echo "Found packages in $DOCKERFILE:" 113 | echo "$packages" 114 | echo 115 | 116 | # --- 3. Function to Precisely Extract Version from HTML using AWK --- 117 | extract_new_version() 118 | { 119 | local url="$1" 120 | local html 121 | html=$(curl -s "$url") 122 | local version 123 | version=$(echo "$html" | awk 'BEGIN { RS=""; FS="\n" } 124 | /Version<\/th>/ { 125 | if (match($0, /([^<]+)<\/strong>/, a)) { 126 | print a[1] 127 | } 128 | }' | head -n 1) 129 | echo "$version" 130 | } 131 | 132 | # --- 3b. Function to Map TARGETPLATFORM to Alpine Package Repository Architecture --- 133 | # Based on TARGETPLATFORM values from Docker buildx and actual Alpine repository usage: 134 | # - linux/amd64 -> x86_64 135 | # - linux/386 -> x86 136 | # - linux/arm64 -> aarch64 137 | # - linux/arm/v7 -> armv7 (NOT armhf!) 138 | # - linux/arm/v6 -> armhf 139 | # - linux/ppc64le -> ppc64le 140 | # - linux/s390x -> s390x 141 | # - linux/riscv64 -> riscv64 142 | map_targetplatform_to_alpine_arch() 143 | { 144 | local platform="$1" 145 | 146 | case "$platform" in 147 | linux/amd64) echo "x86_64" ;; 148 | linux/386) echo "x86" ;; 149 | linux/arm64*) echo "aarch64" ;; 150 | linux/arm/v7) echo "armv7" ;; 151 | linux/arm/v6) echo "armhf" ;; 152 | linux/arm*) echo "armhf" ;; 153 | linux/ppc64le) echo "ppc64le" ;; 154 | linux/s390x) echo "s390x" ;; 155 | linux/riscv64) echo "riscv64" ;; 156 | default) echo "x86_64" ;; 157 | *) echo "x86_64" ;; 158 | esac 159 | } 160 | 161 | # --- 3c. Legacy function for backward compatibility with uname -m --- 162 | # This function maps uname -m output to Alpine Package Repository Architecture 163 | map_uname_to_alpine_arch() 164 | { 165 | local uname_arch="$1" 166 | 167 | case "$uname_arch" in 168 | x86_64) echo "x86_64" ;; 169 | i?86|i386|i686) echo "x86" ;; 170 | aarch64) echo "aarch64" ;; 171 | armv7l) echo "armv7" ;; 172 | armv6l) echo "armhf" ;; 173 | ppc64le) echo "ppc64le" ;; 174 | riscv64) echo "riscv64" ;; 175 | s390x) echo "s390x" ;; 176 | *) echo "$uname_arch" ;; 177 | esac 178 | } 179 | 180 | # --- 3d. Function to Extract Version for Specific Architecture --- 181 | extract_version_for_arch() 182 | { 183 | local pkg="$1" 184 | local arch="$2" 185 | local alpine_arch 186 | local url 187 | 188 | # Determine if this is a TARGETPLATFORM format (starts with "linux/") or uname format 189 | case "$arch" in 190 | linux/*) 191 | # Map TARGETPLATFORM to Alpine package repository architecture 192 | alpine_arch=$(map_targetplatform_to_alpine_arch "$arch") 193 | ;; 194 | *) 195 | # Map uname architecture to Alpine package repository architecture 196 | alpine_arch=$(map_uname_to_alpine_arch "$arch") 197 | ;; 198 | esac 199 | 200 | # Try main repository first 201 | url="https://pkgs.alpinelinux.org/package/v${ALPINE_BRANCH}/main/${alpine_arch}/${pkg}" 202 | local html 203 | html=$(curl -s "$url") 204 | local version 205 | version=$(echo "$html" | awk 'BEGIN { RS=""; FS="\n" } 206 | /Version<\/th>/ { 207 | if (match($0, /([^<]+)<\/strong>/, a)) { 208 | print a[1] 209 | } 210 | }' | head -n 1) 211 | 212 | # If not found in main, try community 213 | if [ -z "$version" ]; then 214 | url="https://pkgs.alpinelinux.org/package/v${ALPINE_BRANCH}/community/${alpine_arch}/${pkg}" 215 | html=$(curl -s "$url") 216 | version=$(echo "$html" | awk 'BEGIN { RS=""; FS="\n" } 217 | /Version<\/th>/ { 218 | if (match($0, /([^<]+)<\/strong>/, a)) { 219 | print a[1] 220 | } 221 | }' | head -n 1) 222 | fi 223 | 224 | echo "$version" 225 | } 226 | 227 | # --- 4. Initialize variables to track updates --- 228 | UPDATED_PACKAGES="" 229 | TOTAL_PACKAGES=0 230 | UPDATED_COUNT=0 231 | 232 | # --- 5. Modified update_package function to track changes --- 233 | update_package_with_tracking() { 234 | pkg_with_version="$1" # e.g., tar=1.35-r2 235 | TOTAL_PACKAGES=$((TOTAL_PACKAGES + 1)) 236 | 237 | if [ -n "$pkg_with_version" ] && [ "${pkg_with_version#*=}" != "$pkg_with_version" ]; then 238 | pkg=$(echo "$pkg_with_version" | cut -d'=' -f1) 239 | current_version=$(echo "$pkg_with_version" | cut -d'=' -f2) 240 | else 241 | pkg="$pkg_with_version" 242 | current_version="" 243 | fi 244 | 245 | # Find the line number of this package occurrence 246 | line_num=$(grep -n "${pkg}=${current_version}[[:space:]\\]" "$DOCKERFILE" | head -1 | cut -d: -f1) 247 | 248 | # First try the "main" repository. 249 | URL="https://pkgs.alpinelinux.org/package/v${ALPINE_BRANCH}/main/x86_64/${pkg}" 250 | echo "Checking '$pkg' (version: $current_version) [line $line_num]" 251 | new_version=$(extract_new_version "$URL") 252 | repo="main" 253 | 254 | # If not found in main, try the "community" repository. 255 | if [ -z "$new_version" ]; then 256 | URL="https://pkgs.alpinelinux.org/package/v${ALPINE_BRANCH}/community/x86_64/${pkg}" 257 | new_version=$(extract_new_version "$URL") 258 | repo="community" 259 | fi 260 | 261 | if [ -z "$new_version" ]; then 262 | echo " ✗ Not found in either repository" 263 | return 264 | fi 265 | 266 | if [ "$current_version" != "$new_version" ]; then 267 | echo " → Updating to $new_version (from $repo)" 268 | # Match package=version followed by whitespace or backslash to ensure we match the complete version 269 | sed -i "s/\(${pkg}=\)${current_version}\([[:space:]\\]\)/\1${new_version}\2/" "$DOCKERFILE" 270 | UPDATED_COUNT=$((UPDATED_COUNT + 1)) 271 | if [ -z "$UPDATED_PACKAGES" ]; then 272 | UPDATED_PACKAGES="- $pkg ($current_version → $new_version) [line $line_num]" 273 | else 274 | UPDATED_PACKAGES="$UPDATED_PACKAGES 275 | - $pkg ($current_version → $new_version) [line $line_num]" 276 | fi 277 | else 278 | echo " ✓ Up-to-date (from $repo)" 279 | fi 280 | echo 281 | } 282 | 283 | # --- 6. Loop Over All Packages and Update --- 284 | IFS=' 285 | ' 286 | for package in $packages; do 287 | update_package_with_tracking "$package" 288 | done 289 | unset IFS 290 | 291 | # --- 7. Handle Platform-Specific Package Versions --- 292 | platform_lines=$(grep -n "# PLATFORM_VERSIONS:" "$DOCKERFILE" || true) 293 | 294 | if [ -n "$platform_lines" ]; then 295 | echo "=== Checking Platform-Specific Versions ===" 296 | # Save to temp file to avoid subshell from pipe 297 | # Process in reverse order so line number deletions don't affect earlier entries 298 | temp_file=$(mktemp) 299 | echo "$platform_lines" | sort -t: -k1 -nr > "$temp_file" 300 | 301 | while IFS=: read -r line_num line_content; do 302 | # Parse the comment line format: # PLATFORM_VERSIONS: package-name: arch1=version1 arch2=version2 ... 303 | pkg_name=$(echo "$line_content" | sed -E 's/.*# PLATFORM_VERSIONS: ([^:]+):.*/\1/' | xargs) 304 | 305 | if [ -z "$pkg_name" ]; then 306 | continue 307 | fi 308 | 309 | echo "Processing platform-specific package: $pkg_name" 310 | 311 | # Extract all architecture-version pairs 312 | arch_versions=$(echo "$line_content" | sed -E 's/.*# PLATFORM_VERSIONS: [^:]+: (.*)/\1/') 313 | 314 | # Parse each arch=version pair 315 | updated_line="# PLATFORM_VERSIONS: $pkg_name:" 316 | has_platform_update=0 317 | 318 | for pair in $arch_versions; do 319 | arch=$(echo "$pair" | cut -d'=' -f1) 320 | current_version=$(echo "$pair" | cut -d'=' -f2) 321 | 322 | echo " Checking $arch: current=$current_version" 323 | 324 | # Get the latest version for this architecture 325 | # Map "default" to x86_64 for package lookup 326 | lookup_arch="$arch" 327 | if [ "$arch" = "default" ]; then 328 | lookup_arch="x86_64" 329 | fi 330 | new_version=$(extract_version_for_arch "$pkg_name" "$lookup_arch") 331 | 332 | if [ -z "$new_version" ]; then 333 | echo " Could not retrieve version for $pkg_name on $arch, keeping current" 334 | updated_line="$updated_line $arch=$current_version" 335 | elif [ "$current_version" != "$new_version" ]; then 336 | echo " Updating $arch: $current_version → $new_version" 337 | updated_line="$updated_line $arch=$new_version" 338 | has_platform_update=1 339 | 340 | # Update the case statement in the Dockerfile, preserving alignment 341 | # For "default", update the "*)" wildcard pattern instead 342 | if [ "$arch" = "default" ]; then 343 | case_pattern='\*' 344 | else 345 | case_pattern="${arch}" 346 | fi 347 | 348 | # Extract the exact spacing from the current line to preserve it 349 | current_spacing=$(grep "${case_pattern})" "$DOCKERFILE" | sed -n "s|.*${case_pattern})\([[:space:]]*\)echo.*|\1|p" | head -1) 350 | if [ -z "$current_spacing" ]; then 351 | # Default to 10 spaces if not found (to match standard formatting) 352 | current_spacing=" " 353 | fi 354 | # Use | as delimiter to avoid conflicts with / in platform names like linux/arm/v7 355 | sed -i "s|\(${case_pattern})\)[[:space:]]*echo \"\(${current_version}\)\"|\1${current_spacing}echo \"${new_version}\"|" "$DOCKERFILE" 356 | 357 | # Find line number for the case pattern we just updated 358 | case_line_num=$(grep -n "${case_pattern})" "$DOCKERFILE" | head -1 | cut -d: -f1) 359 | 360 | UPDATED_COUNT=$((UPDATED_COUNT + 1)) 361 | if [ -z "$UPDATED_PACKAGES" ]; then 362 | UPDATED_PACKAGES="- $pkg_name ($arch: $current_version → $new_version) [line $case_line_num]" 363 | else 364 | UPDATED_PACKAGES="$UPDATED_PACKAGES 365 | - $pkg_name ($arch: $current_version → $new_version) [line $case_line_num]" 366 | fi 367 | else 368 | echo " $arch is up-to-date ($current_version)" 369 | updated_line="$updated_line $arch=$current_version" 370 | fi 371 | done 372 | 373 | # Extract default version for comparison 374 | default_version=$(echo "$updated_line" | grep -o 'default=[^ ]*' | cut -d'=' -f2) 375 | 376 | # Remove platform-specific entries that match the default version 377 | archs_to_remove="" 378 | if [ -n "$default_version" ]; then 379 | cleaned_line="# PLATFORM_VERSIONS: $pkg_name: default=$default_version" 380 | has_cleanup=0 381 | 382 | for pair in $(echo "$updated_line" | sed -E 's/.*: //' | tr ' ' '\n'); do 383 | arch=$(echo "$pair" | cut -d'=' -f1) 384 | version=$(echo "$pair" | cut -d'=' -f2) 385 | 386 | if [ "$arch" != "default" ]; then 387 | if [ "$version" = "$default_version" ]; then 388 | echo " ℹ Removing $arch (same as default: $default_version)" 389 | has_cleanup=1 390 | archs_to_remove="$archs_to_remove $arch" 391 | else 392 | cleaned_line="$cleaned_line $arch=$version" 393 | fi 394 | fi 395 | done 396 | 397 | updated_line="$cleaned_line" 398 | 399 | if [ $has_cleanup -eq 1 ]; then 400 | has_platform_update=1 401 | fi 402 | fi 403 | 404 | # Check if all platform-specific versions are the same 405 | all_versions=$(echo "$updated_line" | sed -E 's/.*: //' | tr ' ' '\n' | cut -d'=' -f2 | sort -u) 406 | version_count=$(echo "$all_versions" | wc -l) 407 | 408 | if [ "$version_count" -eq 1 ]; then 409 | # All versions are the same - convert to regular package format 410 | common_version=$(echo "$all_versions" | head -1) 411 | echo " ℹ All architectures use the same version ($common_version)" 412 | echo " Converting to regular package format..." 413 | 414 | # Find the package variable name (e.g., bind_tools_version) 415 | var_name="${pkg_name}_version" 416 | var_name=$(echo "$var_name" | tr '-' '_') 417 | 418 | # Remove the PLATFORM_VERSIONS comment line 419 | sed -i "${line_num}d" "$DOCKERFILE" 420 | 421 | # Remove the case statement (find lines between variable assignment and apk add) 422 | # Find the line with the variable assignment 423 | case_start_line=$(grep -n "${var_name}=\$(case" "$DOCKERFILE" | cut -d: -f1 | head -1) 424 | if [ -n "$case_start_line" ]; then 425 | # Find the closing ;; esac) line 426 | case_end_line=$(awk "NR>$case_start_line && /;; esac\)/ {print NR; exit}" "$DOCKERFILE") 427 | if [ -n "$case_end_line" ]; then 428 | # Delete the case statement lines 429 | sed -i "${case_start_line},${case_end_line}d" "$DOCKERFILE" 430 | fi 431 | fi 432 | 433 | # Replace the variable reference with the actual version 434 | apk_line_num=$(grep -n "${pkg_name}=\${${var_name}}" "$DOCKERFILE" | head -1 | cut -d: -f1) 435 | sed -i "s/${pkg_name}=\${${var_name}}/${pkg_name}=${common_version}/" "$DOCKERFILE" 436 | 437 | echo " ✓ Converted $pkg_name to regular format with version $common_version [line $apk_line_num]" 438 | UPDATED_COUNT=$((UPDATED_COUNT + 1)) 439 | if [ -z "$UPDATED_PACKAGES" ]; then 440 | UPDATED_PACKAGES="- $pkg_name (converted to regular format: $common_version) [line $apk_line_num]" 441 | else 442 | UPDATED_PACKAGES="$UPDATED_PACKAGES 443 | - $pkg_name (converted to regular format: $common_version) [line $apk_line_num]" 444 | fi 445 | else 446 | # Update the comment line with new versions if there were updates 447 | # IMPORTANT: Do this BEFORE deleting case statement lines to preserve line numbers 448 | if [ $has_platform_update -eq 1 ]; then 449 | # Preserve indentation from original line 450 | indent=$(echo "$line_content" | sed -n 's/^\([[:space:]]*\)#.*/\1/p') 451 | # Use | as delimiter instead of / to avoid conflicts with escaped slashes 452 | escaped_new=$(echo "${indent}${updated_line}" | sed 's/[&/\]/\\&/g') 453 | sed -i "${line_num}s|.*|${escaped_new}|" "$DOCKERFILE" 454 | fi 455 | 456 | # Remove case statement lines for architectures that match default 457 | # Do this AFTER updating the comment to avoid line number shifts 458 | if [ -n "$archs_to_remove" ]; then 459 | for arch in $archs_to_remove; do 460 | # Use | as delimiter to avoid conflicts with / in platform names 461 | sed -i "\|^[[:space:]]*${arch})[[:space:]]*echo|d" "$DOCKERFILE" 462 | done 463 | fi 464 | fi 465 | 466 | echo 467 | done < "$temp_file" 468 | 469 | rm -f "$temp_file" 470 | fi 471 | 472 | # --- 8. Output summary --- 473 | echo "=== UPDATE SUMMARY ===" 474 | echo "Total packages checked: $TOTAL_PACKAGES" 475 | echo "Packages updated: $UPDATED_COUNT" 476 | 477 | if [ $UPDATED_COUNT -gt 0 ]; then 478 | echo "Updated packages:" 479 | echo "$UPDATED_PACKAGES" 480 | echo "✅ SUCCESS: $UPDATED_COUNT package(s) were updated." 481 | UPDATE_EXIT_CODE=0 482 | else 483 | echo "No packages were updated." 484 | echo "✅ All packages are up-to-date." 485 | UPDATE_EXIT_CODE=0 486 | fi 487 | 488 | # Set GitHub Actions environment variables and outputs (only if running in GitHub Actions) 489 | if [ -n "${GITHUB_ENV:-}" ]; then 490 | { 491 | echo "TOTAL_PACKAGES=$TOTAL_PACKAGES" 492 | echo "UPDATED_COUNT=$UPDATED_COUNT" 493 | 494 | if [ $UPDATED_COUNT -gt 0 ]; then 495 | echo "PACKAGES_UPDATED<> "$GITHUB_ENV" 504 | fi 505 | 506 | if [ -n "${GITHUB_OUTPUT:-}" ]; then 507 | { 508 | echo "total_packages=$TOTAL_PACKAGES" 509 | echo "updated_count=$UPDATED_COUNT" 510 | 511 | if [ $UPDATED_COUNT -gt 0 ]; then 512 | echo "packages_updated<> "$GITHUB_OUTPUT" 521 | fi 522 | 523 | # Exit with appropriate code 524 | exit $UPDATE_EXIT_CODE 525 | 526 | -------------------------------------------------------------------------------- /root/usr/local/bin/network-diagnostic: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # network-diagnostic: POSIX sh network diagnostics for an OpenVPN-connected system (Docker-friendly) 3 | # Usage: network-diagnostic [--basic|-b] | [--full|-f] | [--help|-h] 4 | # Default mode is --full. 5 | 6 | set -u # be strict about undefined vars 7 | 8 | # OpenVPN management socket path 9 | MGMT_SOCK="/run/xt/openvpn-mgmt.sock" 10 | 11 | MODE="full" 12 | 13 | print_usage() 14 | { 15 | cat <<'EOF' 16 | Usage: network-diagnostic [options] 17 | 18 | Options: 19 | -b, --basic Print only: "Public IP address X.X.X.X, location City CC" 20 | -f, --full Full diagnostics (default) 21 | -h, --help Show this help 22 | EOF 23 | } 24 | 25 | # ---- Parse CLI -------------------------------------------------------------- 26 | while [ "$#" -gt 0 ]; do 27 | case "$1" in 28 | -b|--basic) MODE="basic" ;; 29 | -f|--full) MODE="full" ;; 30 | -h|--help) print_usage; exit 0 ;; 31 | *) echo "Unknown option: $1" >&2; print_usage; exit 2 ;; 32 | esac 33 | shift 34 | done 35 | 36 | . /usr/local/bin/backend-functions 37 | 38 | # ---- Tool discovery --------------------------------------------------------- 39 | NFT_BIN="$(command -v nft 2>/dev/null || printf '')" 40 | TR_BIN="$(command -v traceroute 2>/dev/null || printf '')" 41 | [ -n "$TR_BIN" ] || TR_BIN="$(command -v tracepath 2>/dev/null || printf '')" 42 | 43 | # ---- Public IP helpers (prefer ipinfo.io) ---------------------------------- 44 | fetch_public_info() 45 | { 46 | IP=""; CITY=""; CC="" 47 | RESP="$(curl -s --max-time 4 https://ipinfo.io/json 2>/dev/null || printf '')" 48 | if [ -n "$RESP" ]; then 49 | IP="$(printf "%s" "$RESP" | jq -r '.ip // empty' 2>/dev/null || printf '')" 50 | CITY="$(printf "%s" "$RESP" | jq -r '.city // empty' 2>/dev/null || printf '')" 51 | CC="$(printf "%s" "$RESP" | jq -r '.country // empty' 2>/dev/null || printf '')" 52 | fi 53 | if [ -z "$IP" ]; then 54 | IP="$(curl -s --max-time 4 https://ifconfig.co 2>/dev/null || printf '')" 55 | CITY="$(curl -s --max-time 4 https://ifconfig.co/city 2>/dev/null || printf '')" 56 | CC="$(curl -s --max-time 4 https://ifconfig.co/country-iso 2>/dev/null || printf '')" 57 | fi 58 | } 59 | 60 | print_basic() 61 | { 62 | fetch_public_info 63 | if [ -n "$IP" ]; then 64 | echo "Public IP address $IP, location ${CITY:-Unknown} ${CC:-??}" 65 | exit 0 66 | else 67 | echo "Public IP address Unknown, location Unknown ??" 68 | exit 1 69 | fi 70 | } 71 | 72 | # If basic mode requested, do that and exit early. 73 | if [ "$MODE" = "basic" ]; then 74 | print_basic 75 | fi 76 | 77 | # ---- Helpers: DNS nameserver geolocation & RTT ------------------------------ 78 | is_private_ip() 79 | { 80 | case "$1" in 81 | 10.*|192.168.*|172.1[6-9].*|172.2[0-9].*|172.3[0-1].*) return 0 ;; 82 | fd*|FD*|fc*|FC*|fe80*|FE80*) return 0 ;; 83 | *) return 1 ;; 84 | esac 85 | } 86 | 87 | # Query OpenVPN management interface for connection details 88 | get_openvpn_status() 89 | { 90 | MGMT_SOURCE="" 91 | VPN_STATUS="Not Connected" 92 | # Try to connect to OpenVPN management interface via Unix socket 93 | if command -v nc >/dev/null 2>&1; then 94 | # Use netcat with delays to allow management interface to process commands 95 | MGMT_RESP="$({ sleep 0.2; printf "status\n"; sleep 1; printf "quit\n"; } | nc -U "$MGMT_SOCK" 2>/dev/null || printf '')" 96 | else 97 | MGMT_RESP="" 98 | fi 99 | 100 | # Parse the response for connection details 101 | if [ -n "$MGMT_RESP" ]; then 102 | MGMT_SOURCE="management interface" 103 | # For client connections, try to extract from different lines 104 | # Look for remote server info in the status output 105 | COMMON_NAME="$(printf "%s" "$MGMT_RESP" | grep -o 'TCP connection established with \[AF_INET\][0-9.]*:[0-9]*' | sed 's/.*\[\([^]]*\)\].*/\1/' | cut -d: -f1 || printf '')" 106 | 107 | # Extract remote IP from status (TCP or UDP) 108 | TCP_LINE="$(printf "%s" "$MGMT_RESP" | grep 'TCP connection established' | head -1 || printf '')" 109 | UDP_LINE="$(printf "%s" "$MGMT_RESP" | grep 'UDPv4 link remote' | head -1 || printf '')" 110 | 111 | if [ -n "$TCP_LINE" ]; then 112 | TRUSTED_IP="$(printf "%s" "$TCP_LINE" | sed 's/.*\[AF_INET\]\([0-9.]*\):.*/\1/' || printf '')" 113 | elif [ -n "$UDP_LINE" ]; then 114 | TRUSTED_IP="$(printf "%s" "$UDP_LINE" | sed 's/.*\[AF_INET\]\([0-9.]*\):.*/\1/' || printf '')" 115 | fi 116 | 117 | # Try to get port and uptime from management interface state 118 | STATE_RESP="$({ sleep 0.2; printf "state\n"; sleep 1; printf "quit\n"; } | nc -U "$MGMT_SOCK" 2>/dev/null || printf '')" 119 | if [ -n "$STATE_RESP" ]; then 120 | # Parse state line format: timestamp,CONNECTED,SUCCESS,local_ip,remote_ip,remote_port 121 | STATE_LINE="$(printf "%s" "$STATE_RESP" | grep -E '^[0-9]+,CONNECTED' | head -1 || printf '')" 122 | if [ -n "$STATE_LINE" ]; then 123 | VPN_STATUS="Connected" 124 | # Extract connection timestamp (seconds since epoch) 125 | CONN_TIMESTAMP="$(printf "%s" "$STATE_LINE" | cut -d, -f1 || printf '')" 126 | STATE_IP="$(printf "%s" "$STATE_LINE" | cut -d, -f5 || printf '')" 127 | STATE_PORT="$(printf "%s" "$STATE_LINE" | cut -d, -f6 || printf '')" 128 | [ -n "$STATE_IP" ] && [ "$STATE_IP" != "0.0.0.0" ] && TRUSTED_IP="$STATE_IP" 129 | [ -n "$STATE_PORT" ] && [ "$STATE_PORT" != "0" ] && TRUSTED_PORT="$STATE_PORT" 130 | 131 | # Calculate uptime if we have the timestamp 132 | if [ -n "$CONN_TIMESTAMP" ] && [ "$CONN_TIMESTAMP" -gt 0 ]; then 133 | NOW="$(date +%s 2>/dev/null || printf '')" 134 | if [ -n "$NOW" ]; then 135 | UPTIME_SECONDS=$((NOW - CONN_TIMESTAMP)) 136 | # Format uptime as HH:MM:SS or DDd HH:MM:SS 137 | if [ "$UPTIME_SECONDS" -ge 86400 ]; then 138 | DAYS=$((UPTIME_SECONDS / 86400)) 139 | HOURS=$(((UPTIME_SECONDS % 86400) / 3600)) 140 | MINS=$(((UPTIME_SECONDS % 3600) / 60)) 141 | SECS=$((UPTIME_SECONDS % 60)) 142 | CONNECTION_UPTIME="$(printf "%dd %02d:%02d:%02d" "$DAYS" "$HOURS" "$MINS" "$SECS")" 143 | else 144 | HOURS=$((UPTIME_SECONDS / 3600)) 145 | MINS=$(((UPTIME_SECONDS % 3600) / 60)) 146 | SECS=$((UPTIME_SECONDS % 60)) 147 | CONNECTION_UPTIME="$(printf "%02d:%02d:%02d" "$HOURS" "$MINS" "$SECS")" 148 | fi 149 | fi 150 | fi 151 | fi 152 | fi 153 | fi 154 | 155 | # Fallback: try to get from config file if management interface didn't work and VPN is connected 156 | if [ "$VPN_STATUS" = "Connected" ] && [ -z "$COMMON_NAME" ] && [ -f "/run/xt/nordvpn.ovpn" ]; then 157 | [ -z "$MGMT_SOURCE" ] && MGMT_SOURCE="config file" 158 | # Extract from verify-x509-name line first (preferred) 159 | COMMON_NAME="$(awk '/^[[:space:]]*verify-x509-name/ {for(i=1;i<=NF;i++) if($i ~ /CN=/) {sub(/CN=/,"",$i); print $i; exit}}' "/run/xt/nordvpn.ovpn" 2>/dev/null || printf '')" 160 | if [ -z "$COMMON_NAME" ]; then 161 | # Fallback to remote hostname/IP 162 | REMOTE_VAL="$(awk '/^[[:space:]]*remote[ \t]+/ {print $2; exit}' "/run/xt/nordvpn.ovpn" 2>/dev/null || printf '')" 163 | # If it's an IP address, try to construct hostname 164 | if printf "%s" "$REMOTE_VAL" | grep -E '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$' >/dev/null 2>&1; then 165 | # It's an IP, we can't easily get hostname, so leave empty for now 166 | COMMON_NAME="" 167 | else 168 | COMMON_NAME="$REMOTE_VAL" 169 | fi 170 | fi 171 | fi 172 | if [ "$VPN_STATUS" = "Connected" ] && [ -z "$TRUSTED_IP" ] && [ -f "/run/xt/nordvpn.ovpn" ]; then 173 | [ -z "$MGMT_SOURCE" ] && MGMT_SOURCE="config file" 174 | TRUSTED_IP="$(awk '/^[[:space:]]*remote[ \t]+/ {print $2; exit}' "/run/xt/nordvpn.ovpn" 2>/dev/null || printf '')" 175 | TRUSTED_PORT="$(awk '/^[[:space:]]*remote[ \t]+/ {print $3; exit}' "/run/xt/nordvpn.ovpn" 2>/dev/null || printf '')" 176 | [ -z "$TRUSTED_PORT" ] && TRUSTED_PORT="$(awk '/^[[:space:]]*port[ \t]+/ {print $2; exit}' "/run/xt/nordvpn.ovpn" 2>/dev/null || printf '')" 177 | fi 178 | 179 | # Export for use in output 180 | export MGMT_SOURCE 181 | export CONNECTION_UPTIME 182 | export VPN_STATUS 183 | } 184 | 185 | # Detect iptables backend (nft_tables or legacy) 186 | get_iptables_backend() 187 | { 188 | IPTABLES_BACKEND="unknown" 189 | IPTABLES_VERSION="" 190 | IPTABLES_COMMAND="unknown" 191 | 192 | # Get the selected iptables binary from backend.env (set by entrypoint) 193 | if [ -n "${IPT:-}" ]; then 194 | IPTABLES_COMMAND="$IPT" 195 | # Get version information 196 | IPTABLES_VERSION="$(${IPT} --version 2>/dev/null || printf '')" 197 | 198 | # Detect backend from version string 199 | if printf "%s" "$IPTABLES_VERSION" | grep -q "nf_tables"; then 200 | IPTABLES_BACKEND="nf_tables" 201 | elif printf "%s" "$IPTABLES_VERSION" | grep -q "legacy"; then 202 | IPTABLES_BACKEND="legacy" 203 | else 204 | # Try to detect by checking for nft command or the binary name 205 | if printf "%s" "$IPT" | grep -q "legacy"; then 206 | IPTABLES_BACKEND="legacy" 207 | elif command -v nft >/dev/null 2>&1; then 208 | IPTABLES_BACKEND="nf_tables (detected via nft)" 209 | else 210 | # Assume legacy if we can't determine 211 | IPTABLES_BACKEND="legacy (assumed)" 212 | fi 213 | fi 214 | elif command -v iptables >/dev/null 2>&1; then 215 | # Fallback if IPT not set 216 | IPTABLES_COMMAND="iptables" 217 | IPTABLES_VERSION="$(iptables --version 2>/dev/null || printf '')" 218 | if printf "%s" "$IPTABLES_VERSION" | grep -q "nf_tables"; then 219 | IPTABLES_BACKEND="nf_tables" 220 | elif printf "%s" "$IPTABLES_VERSION" | grep -q "legacy"; then 221 | IPTABLES_BACKEND="legacy" 222 | fi 223 | fi 224 | 225 | export IPTABLES_BACKEND 226 | export IPTABLES_VERSION 227 | export IPTABLES_COMMAND 228 | } 229 | 230 | # Get OpenVPN interface details from system 231 | get_interface_details() 232 | { 233 | # Get device type (tun/tap) 234 | DEV_TYPE="$(ip link show "$DEV_IF" 2>/dev/null | awk -F: '/^[0-9]+:.*tun[0-9]*:/ {print "tun"} /^[0-9]+:.*tap[0-9]*:/ {print "tap"}' || printf '?')" 235 | 236 | # Get local IP from interface 237 | IFCONF_LOCAL="$(ip addr show "$DEV_IF" 2>/dev/null | awk '/inet / {split($2,a,"/"); print a[1]; exit}' || printf '?')" 238 | 239 | # For OpenVPN tun interface, remote IP is typically local_ip + 1 or from peer address 240 | if [ "$IFCONF_LOCAL" != "?" ]; then 241 | # Calculate remote IP (usually .1 for local, .2 for remote in /30 subnet) 242 | # Split IP address using POSIX-compatible method 243 | a="$(printf "%s" "$IFCONF_LOCAL" | cut -d. -f1)" 244 | b="$(printf "%s" "$IFCONF_LOCAL" | cut -d. -f2)" 245 | c="$(printf "%s" "$IFCONF_LOCAL" | cut -d. -f3)" 246 | d="$(printf "%s" "$IFCONF_LOCAL" | cut -d. -f4)" 247 | if [ "$d" = "1" ]; then 248 | IFCONF_REMOTE="$a.$b.$c.2" 249 | elif [ "$d" = "2" ]; then 250 | IFCONF_REMOTE="$a.$b.$c.1" 251 | else 252 | IFCONF_REMOTE="?" 253 | fi 254 | else 255 | IFCONF_REMOTE="?" 256 | fi 257 | 258 | # Get VPN gateway from routes (usually the remote IP) 259 | ROUTE_VPN_GW="$IFCONF_REMOTE" 260 | } 261 | 262 | ns_geo_line() 263 | { 264 | IPQ="$1" 265 | if is_private_ip "$IPQ"; then 266 | echo "$IPQ : (private/local resolver)" 267 | return 268 | fi 269 | 270 | CITY=""; CC=""; ORG="" 271 | RESP="$(curl -s --max-time 4 "https://ipinfo.io/$IPQ/json" 2>/dev/null || printf '')" 272 | if [ -n "$RESP" ]; then 273 | CITY="$(printf "%s" "$RESP" | jq -r '.city // empty' 2>/dev/null || printf '')" 274 | CC="$(printf "%s" "$RESP" | jq -r '.country // empty' 2>/dev/null || printf '')" 275 | ORG="$(printf "%s" "$RESP" | jq -r '.org // empty' 2>/dev/null || printf '')" 276 | fi 277 | if [ -z "$CITY$CC$ORG" ]; then 278 | RESP="$(curl -s --max-time 4 "https://ipapi.co/$IPQ/json/" 2>/dev/null || printf '')" 279 | if [ -n "$RESP" ]; then 280 | CITY="$(printf "%s" "$RESP" | jq -r '.city // empty' 2>/dev/null || printf '')" 281 | CC="$(printf "%s" "$RESP" | jq -r '.country // empty' 2>/dev/null || printf '')" 282 | [ -n "$CC" ] || CC="$(printf "%s" "$RESP" | jq -r '.country_code // empty' 2>/dev/null || printf '')" 283 | ORG="$(printf "%s" "$RESP" | jq -r '.org // empty' 2>/dev/null || printf '')" 284 | fi 285 | fi 286 | [ -n "$CITY" ] || CITY="Unknown" 287 | [ -n "$CC" ] || CC="??" 288 | if [ -n "$ORG" ]; then 289 | echo "$IPQ : $CITY $CC — $ORG" 290 | else 291 | echo "$IPQ : $CITY $CC" 292 | fi 293 | } 294 | 295 | ns_rtt() 296 | { 297 | NS="$1" 298 | POUT="$(ping -w 2 -c 2 "$NS" 2>/dev/null | awk -F'[/ ]' '/^rtt|^round-trip/{print $(NF-2)" ms"}')" 299 | [ -n "$POUT" ] && echo " (avg RTT: $POUT)" 300 | } 301 | 302 | print_dns_geo() 303 | { 304 | echo "### DNS servers geolocation" 305 | NS_LIST="$(awk '/^nameserver/ {print $2}' /etc/resolv.conf 2>/dev/null | sed 's/%.*//' | sort -u)" 306 | if [ -z "$NS_LIST" ]; then 307 | NS_LIST="$(resolvectl dns 2>/dev/null | tr ' ' '\n' | sed 's/%.*//' | \ 308 | grep -E '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$|^[0-9a-fA-F:]+$' | sort -u)" 309 | fi 310 | if [ -z "$NS_LIST" ]; then 311 | echo "(no nameservers found)" 312 | echo 313 | return 314 | fi 315 | for NS in $NS_LIST; do 316 | ns_geo_line "$NS" 317 | ns_rtt "$NS" 318 | done 319 | echo 320 | } 321 | 322 | # ---- Full diagnostics (default) -------------------------------------------- 323 | PUBLIC_TEST_IPv4="1.1.1.1" 324 | PUBLIC_TEST_IPv6="2606:4700:4700::1111" 325 | 326 | # Initialize variables with defaults 327 | COMMON_NAME="" 328 | DEV_IF="tun0" 329 | DEV_TYPE="?" 330 | IFCONF_LOCAL="?" 331 | IFCONF_REMOTE="?" 332 | ROUTE_VPN_GW="?" 333 | TRUSTED_IP="" 334 | TRUSTED_PORT="" 335 | PROTO="?" 336 | LPORT="?" 337 | CONNECTION_UPTIME="" 338 | IPTABLES_BACKEND="" 339 | IPTABLES_VERSION="" 340 | IPTABLES_COMMAND="" 341 | VPN_STATUS="Not Connected" 342 | 343 | # Try to get OpenVPN status from management interface 344 | get_openvpn_status 345 | 346 | # Get interface details from system 347 | get_interface_details 348 | 349 | # Detect iptables backend 350 | get_iptables_backend 351 | 352 | # Try to get protocol and port from OpenVPN config or process 353 | if [ "$VPN_STATUS" = "Connected" ] && [ -f "/run/xt/nordvpn.ovpn" ]; then 354 | # Try to extract from config file 355 | PROTO="$(awk '/^[[:space:]]*proto[ \t]+/ {print tolower($2); exit}' "/run/xt/nordvpn.ovpn" || printf '?')" 356 | LPORT="$(awk '/^[[:space:]]*port[ \t]+/ {print $2; exit}' "/run/xt/nordvpn.ovpn" || printf '?')" 357 | if [ -z "$LPORT" ] || [ "$LPORT" = "?" ]; then 358 | # Try from remote line 359 | LPORT="$(awk '/^[[:space:]]*remote[ \t]+/ {print $3; exit}' "/run/xt/nordvpn.ovpn" | grep '^[0-9]*$' || printf '?')" 360 | fi 361 | elif command -v pgrep >/dev/null 2>&1 && command -v ps >/dev/null 2>&1; then 362 | OVPN_PID="$(pgrep -f openvpn || printf '')" 363 | if [ -n "$OVPN_PID" ]; then 364 | # Try to extract from process command line 365 | OVPN_CMD="$(ps -p "$OVPN_PID" -o args= 2>/dev/null || printf '')" 366 | PROTO="$(printf "%s" "$OVPN_CMD" | grep -o '\-\-proto [a-zA-Z0-9]*' | awk '{print $2}' | tr 'A-Z' 'a-z' || printf '?')" 367 | LPORT="$(printf "%s" "$OVPN_CMD" | grep -o '\-\-lport [0-9]*\|\-\-port [0-9]*' | awk '{print $2}' | tail -1 || printf '?')" 368 | [ -z "$LPORT" ] && LPORT="$(printf "%s" "$OVPN_CMD" | grep -o '\-\-remote [0-9.]* [0-9]*' | awk '{print $3}' || printf '?')" 369 | fi 370 | fi 371 | 372 | # Format trusted peer display 373 | if [ -n "$TRUSTED_IP" ] && [ -n "$TRUSTED_PORT" ]; then 374 | TRUSTED_PEER="$TRUSTED_IP:$TRUSTED_PORT" 375 | elif [ -n "$TRUSTED_IP" ]; then 376 | TRUSTED_PEER="$TRUSTED_IP:?" 377 | else 378 | TRUSTED_PEER="?:?" 379 | fi 380 | 381 | echo "================================================================" 382 | echo "OpenVPN DIAG (full) : $(date -Is 2>/dev/null || date)" 383 | echo "VPN Status : $VPN_STATUS" 384 | [ -n "$MGMT_SOURCE" ] && echo "Data source : $MGMT_SOURCE" 385 | [ -n "$IPTABLES_COMMAND" ] && echo "Iptables command : $IPTABLES_COMMAND" 386 | [ -n "$IPTABLES_BACKEND" ] && echo "Iptables backend : $IPTABLES_BACKEND" 387 | [ -n "$IPTABLES_VERSION" ] && echo "Iptables version : $IPTABLES_VERSION" 388 | echo "Kernel version : $(uname -r 2>/dev/null || echo 'unknown')" 389 | echo "Device : $DEV_IF (type=$DEV_TYPE)" 390 | echo "Common Name : $COMMON_NAME" 391 | echo "Ifconfig Local : $IFCONF_LOCAL" 392 | echo "Ifconfig Remote : $IFCONF_REMOTE" 393 | echo "Route VPN Gateway : $ROUTE_VPN_GW" 394 | echo "Trusted Peer : $TRUSTED_PEER" 395 | echo "Proto/Local Port : $PROTO/$LPORT" 396 | [ -n "$CONNECTION_UPTIME" ] && echo "Connection Uptime : $CONNECTION_UPTIME" 397 | echo 398 | 399 | echo "### ip addr show $DEV_IF" 400 | ip addr show "$DEV_IF" 2>/dev/null || true 401 | echo 402 | 403 | echo "### ip link (up)" 404 | ip -brief link show up 2>/dev/null || true 405 | echo 406 | 407 | echo "### ip route (main)" 408 | ip route show 2>/dev/null || true 409 | echo 410 | 411 | echo "### ip rule" 412 | ip rule show 2>/dev/null || true 413 | echo 414 | 415 | # Default route / split-default detection 416 | DEF_DEV="$(ip route show default 2>/dev/null | awk '/default/{for(i=1;i<=NF;i++) if($i=="dev"){print $(i+1); exit}}')" 417 | DEV0="$(ip route show '0.0.0.0/1' 2>/dev/null | awk '{for(i=1;i<=NF;i++) if($i=="dev"){print $(i+1); exit}}')" 418 | DEV128="$(ip route show '128.0.0.0/1' 2>/dev/null | awk '{for(i=1;i<=NF;i++) if($i=="dev"){print $(i+1); exit}}')" 419 | 420 | if [ -n "$DEV0" ] && [ -n "$DEV128" ] && [ "$DEV0" = "$DEV_IF" ] && [ "$DEV128" = "$DEV_IF" ]; then 421 | echo "Default route dev : (split default) $DEV_IF (0.0.0.0/1 + 128.0.0.0/1)" 422 | echo "[ok] Full-tunnel via split default detected" 423 | else 424 | [ -n "${DEF_DEV:-}" ] || DEF_DEV="" 425 | echo "Default route dev : $DEF_DEV" 426 | if [ "$DEF_DEV" = "$DEV_IF" ]; then 427 | echo "[ok] Default route is via $DEV_IF" 428 | else 429 | echo "[warn] Default route is NOT via $DEV_IF" 430 | fi 431 | fi 432 | echo 433 | 434 | echo "### ip route get $PUBLIC_TEST_IPv4" 435 | ip route get "$PUBLIC_TEST_IPv4" 2>/dev/null || true 436 | echo 437 | 438 | echo "### ip -6 route get $PUBLIC_TEST_IPv6" 439 | ip -6 route get "$PUBLIC_TEST_IPv6" 2>/dev/null || echo "[info] no IPv6 route" 440 | echo 441 | 442 | # Netfilter snapshot 443 | echo "### nftables / iptables rules" 444 | if [ -n "$NFT_BIN" ]; then 445 | "$NFT_BIN" list ruleset 2>/dev/null || echo "[info] nft present, empty ruleset or no perms" 446 | else 447 | echo "[info] nft not present" 448 | fi 449 | 450 | echo 451 | echo "### iptables -S (filter)" 452 | run4 -S 2>/dev/null || true 453 | 454 | echo 455 | echo "### iptables -t nat -S (IPv4 NAT)" 456 | run4 -t nat -S 2>/dev/null || echo "(no IPv4 NAT rules or table unavailable)" 457 | 458 | # Optional counters view (comment out if too verbose) 459 | # echo 460 | # echo "### iptables -t nat -L -n -v (counters)" 461 | # "$IPT" -t nat -L -n -v 2>/dev/null || true 462 | 463 | if [ -n "${IP6T}" ]; then 464 | echo 465 | echo "### ip6tables -S (filter)" 466 | run6 -S 2>/dev/null || true 467 | 468 | echo 469 | echo "### ip6tables -t nat -S (IPv6 NAT)" 470 | run6 -t nat -S 2>/dev/null || echo "(no IPv6 NAT rules or table unavailable)" 471 | fi 472 | echo 473 | 474 | # Public IP & rough geo (JSON from same provider as summary) 475 | echo "### Public IP / Geo (best-effort)" 476 | RESP="$(curl -s --max-time 4 https://ipinfo.io/json 2>/dev/null || printf '')" 477 | if [ -z "$RESP" ]; then 478 | RESP="$(curl -s --max-time 4 https://ifconfig.co/json 2>/dev/null || printf '')" 479 | fi 480 | if [ -n "$RESP" ]; then 481 | echo "$RESP" | jq . 2>/dev/null || echo "$RESP" 482 | else 483 | echo "[warn] could not fetch public info" 484 | fi 485 | echo 486 | 487 | # DNS configuration & identity probe 488 | echo "### DNS configuration" 489 | if command -v resolvectl >/dev/null 2>&1; then 490 | resolvectl status "$DEV_IF" 2>/dev/null || resolvectl status 2>/dev/null || true 491 | else 492 | echo "/etc/resolv.conf:" 493 | cat /etc/resolv.conf 2>/dev/null || echo "(no /etc/resolv.conf)" 494 | fi 495 | echo 496 | 497 | print_dns_geo 498 | 499 | # Resolver identity (with fallbacks) 500 | NS_ACTIVE="$(awk '/^nameserver/ {print $2; exit}' /etc/resolv.conf 2>/dev/null)" 501 | if [ -n "$NS_ACTIVE" ]; then 502 | echo "### resolver identity via $NS_ACTIVE" 503 | OUT="$(dig +timeout=3 +short TXT whoami.cloudflare @"$NS_ACTIVE" 2>/dev/null | sed -n '1p')" 504 | if [ -z "$OUT" ]; then 505 | OUT="$(dig +timeout=3 +short CHAOS TXT id.server @"$NS_ACTIVE" 2>/dev/null | sed -n '1p')" 506 | fi 507 | if [ -z "$OUT" ]; then 508 | OUT="$(dig +timeout=3 +short CHAOS TXT hostname.bind @"$NS_ACTIVE" 2>/dev/null | sed -n '1p')" 509 | fi 510 | if [ -n "$OUT" ]; then 511 | echo "$OUT" 512 | else 513 | echo "(no identity TXT available from resolver)" 514 | fi 515 | echo 516 | fi 517 | 518 | # Connectivity probes 519 | echo "### Ping checks" 520 | ping -w 3 -c 3 "$PUBLIC_TEST_IPv4" 2>&1 || echo "[warn] ping to $PUBLIC_TEST_IPv4 failed" 521 | ping6 -w 3 -c 3 "$PUBLIC_TEST_IPv6" 2>&1 || echo "[info] ping6 to $PUBLIC_TEST_IPv6 failed" 522 | echo 523 | 524 | if [ -n "$TR_BIN" ]; then 525 | echo "### Short trace to $PUBLIC_TEST_IPv4 (first hop should be VPN)" 526 | BN="$(basename "$TR_BIN" 2>/dev/null)" 527 | case "$BN" in 528 | tracepath) "$TR_BIN" -n -m 4 "$PUBLIC_TEST_IPv4" 2>&1 || true ;; 529 | *) "$TR_BIN" -n -m 4 -w 2 "$PUBLIC_TEST_IPv4" 2>&1 || true ;; 530 | esac 531 | echo 532 | fi 533 | 534 | # Quick verdicts 535 | echo "### Quick verdicts" 536 | ROUTE_DEV="$(ip route get "$PUBLIC_TEST_IPv4" 2>/dev/null | awk '{for(i=1;i<=NF;i++) if($i=="dev"){print $(i+1); exit}}')" 537 | if [ -n "$ROUTE_DEV" ] && [ "$ROUTE_DEV" = "$DEV_IF" ]; then 538 | echo "[ok] Route to $PUBLIC_TEST_IPv4 goes via $DEV_IF" 539 | else 540 | echo "[warn] Route to $PUBLIC_TEST_IPv4 uses ${ROUTE_DEV:-} (expected $DEV_IF)" 541 | fi 542 | 543 | fetch_public_info 544 | if [ -n "${IP:-}" ]; then 545 | echo "Public IPv4 seen : $IP" 546 | [ -n "$CITY" ] || CITY="Unknown" 547 | [ -n "$CC" ] || CC="??" 548 | echo "Location : $CITY $CC" 549 | fi 550 | 551 | echo "Done." 552 | echo "================================================================" 553 | echo 554 | exit 0 555 | -------------------------------------------------------------------------------- /root/usr/local/bin/backend-functions: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # shellcheck shell=sh 3 | 4 | # Reuse backend selected by entrypoint 5 | [ -r /run/xt/backend.env ] && . /run/xt/backend.env 6 | 7 | # Create lowercase copies of environment variables 8 | country="${COUNTRY:-}" 9 | city="${CITY:-}" 10 | technology="${TECHNOLOGY:-OpenVPN UDP}" 11 | group="${GROUP:-}" 12 | random_top="${RANDOM_TOP:-0}" 13 | openvpn_opts="${OPENVPN_OPTS:-}" 14 | network_diagnostic_enabled="${NETWORK_DIAGNOSTIC_ENABLED:-false}" 15 | check_connection_attempts="${CHECK_CONNECTION_ATTEMPTS:-5}" 16 | check_connection_url="${CHECK_CONNECTION_URL:-https://www.google.com}" 17 | check_connection_attempt_interval="${CHECK_CONNECTION_ATTEMPT_INTERVAL:-10}" 18 | user="${USER:-}" 19 | pass="${PASS:-}" 20 | recreate_vpn_cron="${RECREATE_VPN_CRON:-}" 21 | check_connection_cron="${CHECK_CONNECTION_CRON:-}" 22 | nordvpnapi_ip="${NORDVPNAPI_IP:-104.16.208.203;104.19.159.190}" 23 | network="${NETWORK:-}" 24 | puid=${PUID:-912} 25 | pgid=${PGID:-912} 26 | 27 | # Common file paths 28 | ovpntemplatefile="/usr/local/share/nordvpn/data/template.ovpn" 29 | ovpnfile="/run/xt/nordvpn.ovpn" 30 | authfile="/run/xt/auth" 31 | mgmtsockfile="/run/xt/openvpn-mgmt.sock" 32 | 33 | run4() { 34 | if ! ${IPT} "$@" 2>/dev/null; then 35 | log "BACKEND" "(IPv4) skipped: $*" 36 | fi 37 | } 38 | 39 | run4_critical() { 40 | if ! ${IPT} "$@" 2>/dev/null; then 41 | log_error "BACKEND" "(IPv4) CRITICAL ERROR: Failed to execute: $* - sleeping infinite" 42 | sleep infinity 43 | fi 44 | } 45 | 46 | run6() { 47 | if [ -n "$IP6T" ]; then 48 | if ! ${IP6T} "$@" 2>/dev/null; then 49 | log "BACKEND" "(IPv6) skipped: $*" 50 | fi 51 | fi 52 | } 53 | 54 | run6_critical() { 55 | if [ -n "$IP6T" ]; then 56 | if ! ${IP6T} "$@" 2>/dev/null; then 57 | log_error "BACKEND" "(IPv6) CRITICAL ERROR: Failed to execute: $*" 58 | fi 59 | fi 60 | } 61 | 62 | # Check if VPN is connected by checking for tun interface 63 | is_vpn_connected() { 64 | if ip link show tun0 >/dev/null 2>&1; then 65 | return 0 66 | else 67 | return 1 68 | fi 69 | } 70 | 71 | log() 72 | { 73 | script_name="${1:-unknown}" 74 | shift 75 | printf "%s [%s] %s\n" "$(date +'%Y-%m-%d %H:%M:%S')" "${script_name}" "$*" 76 | } 77 | 78 | log_error() 79 | { 80 | script_name="${1:-unknown}" 81 | shift 82 | printf "%s [%s] [ERROR] %s\n" "$(date +'%Y-%m-%d %H:%M:%S')" "${script_name}" "$*" >&2 83 | } 84 | 85 | log_warning() 86 | { 87 | script_name="${1:-unknown}" 88 | shift 89 | printf "%s [%s] [WARNING] %s\n" "$(date +'%Y-%m-%d %H:%M:%S')" "${script_name}" "$*" 90 | } 91 | 92 | # Parse cron expression and return human-readable description 93 | parse_cron() 94 | { 95 | cron_expr="$1" 96 | 97 | # Handle 6-field cron (with seconds) by ignoring seconds 98 | if echo "$cron_expr" | awk '{print NF}' | grep -q '^6$'; then 99 | cron_expr=$(echo "$cron_expr" | awk '{print $2,$3,$4,$5,$6}') 100 | fi 101 | 102 | # Split cron expression into fields 103 | minute=$(echo "$cron_expr" | awk '{print $1}') 104 | hour=$(echo "$cron_expr" | awk '{print $2}') 105 | day=$(echo "$cron_expr" | awk '{print $3}') 106 | month=$(echo "$cron_expr" | awk '{print $4}') 107 | weekday=$(echo "$cron_expr" | awk '{print $5}') 108 | 109 | description="" 110 | 111 | # Helper function to parse cron field values 112 | parse_field() { 113 | local field_value="$1" 114 | local field_type="$2" 115 | local result="" 116 | 117 | # Handle asterisk (every value) 118 | if [ "$field_value" = "*" ]; then 119 | case "$field_type" in 120 | "minute") result="every minute" ;; 121 | "hour") result="every hour" ;; 122 | "day") result="every day" ;; 123 | "month") result="every month" ;; 124 | "weekday") result="every day" ;; 125 | esac 126 | echo "$result" 127 | return 128 | fi 129 | 130 | # Handle step values (*/n) 131 | if echo "$field_value" | grep -q '^*/'; then 132 | step=$(echo "$field_value" | cut -d'/' -f2) 133 | case "$field_type" in 134 | "minute") 135 | if [ "$step" = "1" ]; then 136 | result="every minute" 137 | else 138 | result="every $step minutes" 139 | fi ;; 140 | "hour") 141 | if [ "$step" = "1" ]; then 142 | result="every hour" 143 | else 144 | result="every $step hours" 145 | fi ;; 146 | "day") 147 | if [ "$step" = "1" ]; then 148 | result="every day" 149 | else 150 | result="every $step days" 151 | fi ;; 152 | "month") 153 | if [ "$step" = "1" ]; then 154 | result="every month" 155 | else 156 | result="every $step months" 157 | fi ;; 158 | "weekday") 159 | if [ "$step" = "1" ]; then 160 | result="every day" 161 | else 162 | result="every $step days" 163 | fi ;; 164 | esac 165 | echo "$result" 166 | return 167 | fi 168 | 169 | # Handle ranges with steps (start-end/step) 170 | if echo "$field_value" | grep -q '/'; then 171 | range=$(echo "$field_value" | cut -d'/' -f1) 172 | step=$(echo "$field_value" | cut -d'/' -f2) 173 | start=$(echo "$range" | cut -d'-' -f1) 174 | end=$(echo "$range" | cut -d'-' -f2) 175 | 176 | case "$field_type" in 177 | "minute") result="every $step minutes from minute $start to $end" ;; 178 | "hour") 179 | # Convert to readable time format 180 | if [ "$start" = "0" ]; then start_time="midnight" 181 | elif [ "$start" -lt 12 ]; then start_time="${start}:00 AM" 182 | elif [ "$start" = "12" ]; then start_time="noon" 183 | else start_time="$((start-12)):00 PM" 184 | fi 185 | 186 | if [ "$end" = "0" ]; then end_time="midnight" 187 | elif [ "$end" -lt 12 ]; then end_time="${end}:00 AM" 188 | elif [ "$end" = "12" ]; then end_time="noon" 189 | else end_time="$((end-12)):00 PM" 190 | fi 191 | 192 | result="every $step hours from $start_time to $end_time" ;; 193 | "day") result="every $step days from day $start to $end" ;; 194 | "month") result="every $step months from month $start to $end" ;; 195 | "weekday") result="every $step days from weekday $start to $end" ;; 196 | esac 197 | echo "$result" 198 | return 199 | fi 200 | 201 | # Handle ranges (start-end) - but not if it contains commas (handled by lists) 202 | has_comma=$(echo "$field_value" | grep -c ',') 203 | if echo "$field_value" | grep -q '-' && [ "$has_comma" = "0" ]; then 204 | start=$(echo "$field_value" | cut -d'-' -f1) 205 | end=$(echo "$field_value" | cut -d'-' -f2) 206 | 207 | case "$field_type" in 208 | "minute") result="minutes $start-$end" ;; 209 | "hour") 210 | # Convert to readable time format 211 | if [ "$start" = "0" ]; then start_time="midnight" 212 | elif [ "$start" -lt 12 ]; then start_time="${start}:00 AM" 213 | elif [ "$start" = "12" ]; then start_time="noon" 214 | else start_time="$((start-12)):00 PM" 215 | fi 216 | 217 | if [ "$end" = "0" ]; then end_time="midnight" 218 | elif [ "$end" -lt 12 ]; then end_time="${end}:00 AM" 219 | elif [ "$end" = "12" ]; then end_time="noon" 220 | else end_time="$((end-12)):00 PM" 221 | fi 222 | 223 | result="$start_time to $end_time" ;; 224 | "day") result="days $start-$end" ;; 225 | "month") 226 | start_month=$(case "$start" in 1) echo "January";; 2) echo "February";; 3) echo "March";; 4) echo "April";; 5) echo "May";; 6) echo "June";; 7) echo "July";; 8) echo "August";; 9) echo "September";; 10) echo "October";; 11) echo "November";; 12) echo "December";; *) echo "$start";; esac) 227 | end_month=$(case "$end" in 1) echo "January";; 2) echo "February";; 3) echo "March";; 4) echo "April";; 5) echo "May";; 6) echo "June";; 7) echo "July";; 8) echo "August";; 9) echo "September";; 10) echo "October";; 11) echo "November";; 12) echo "December";; *) echo "$end";; esac) 228 | result="from $start_month to $end_month" ;; 229 | "weekday") 230 | # Special cases for common weekday ranges 231 | if [ "$start" = "1" ] && [ "$end" = "5" ]; then 232 | result="weekdays" 233 | elif [ "$start" = "0" ] && [ "$end" = "6" ]; then 234 | result="every day" 235 | else 236 | start_day=$(case "$start" in 0) echo "Sunday";; 1) echo "Monday";; 2) echo "Tuesday";; 3) echo "Wednesday";; 4) echo "Thursday";; 5) echo "Friday";; 6) echo "Saturday";; *) echo "$start";; esac) 237 | end_day=$(case "$end" in 0) echo "Sunday";; 1) echo "Monday";; 2) echo "Tuesday";; 3) echo "Wednesday";; 4) echo "Thursday";; 5) echo "Friday";; 6) echo "Saturday";; *) echo "$end";; esac) 238 | result="from $start_day to $end_day" 239 | fi ;; 240 | esac 241 | echo "$result" 242 | return 243 | fi 244 | 245 | # Handle lists (val1,val2,val3) - can include multiple ranges 246 | has_comma=$(echo "$field_value" | grep -c ',') 247 | if [ "$has_comma" -gt 0 ]; then 248 | result="" 249 | IFS=',' 250 | for val in $field_value; do 251 | if [ -n "$result" ]; then 252 | result="$result, " 253 | fi 254 | 255 | # Handle ranges within lists (like 1-7,15-21) 256 | if echo "$val" | grep -q '-'; then 257 | start=$(echo "$val" | cut -d'-' -f1) 258 | end=$(echo "$val" | cut -d'-' -f2) 259 | 260 | case "$field_type" in 261 | "minute") result="${result}minutes $start-$end" ;; 262 | "hour") 263 | # Convert to readable time format for ranges in lists 264 | if [ "$start" = "0" ]; then start_time="midnight" 265 | elif [ "$start" -lt 12 ]; then start_time="${start}:00 AM" 266 | elif [ "$start" = "12" ]; then start_time="noon" 267 | else start_time="$((start-12)):00 PM" 268 | fi 269 | 270 | if [ "$end" = "0" ]; then end_time="midnight" 271 | elif [ "$end" -lt 12 ]; then end_time="${end}:00 AM" 272 | elif [ "$end" = "12" ]; then end_time="noon" 273 | else end_time="$((end-12)):00 PM" 274 | fi 275 | 276 | result="${result}$start_time to $end_time" ;; 277 | "day") result="${result}days $start-$end" ;; 278 | "month") 279 | start_month=$(case "$start" in 1) echo "January";; 2) echo "February";; 3) echo "March";; 4) echo "April";; 5) echo "May";; 6) echo "June";; 7) echo "July";; 8) echo "August";; 9) echo "September";; 10) echo "October";; 11) echo "November";; 12) echo "December";; *) echo "$start";; esac) 280 | end_month=$(case "$end" in 1) echo "January";; 2) echo "February";; 3) echo "March";; 4) echo "April";; 5) echo "May";; 6) echo "June";; 7) echo "July";; 8) echo "August";; 9) echo "September";; 10) echo "October";; 11) echo "November";; 12) echo "December";; *) echo "$end";; esac) 281 | result="${result}$start_month-$end_month" ;; 282 | "weekday") 283 | start_day=$(case "$start" in 0) echo "Sunday";; 1) echo "Monday";; 2) echo "Tuesday";; 3) echo "Wednesday";; 4) echo "Thursday";; 5) echo "Friday";; 6) echo "Saturday";; *) echo "$start";; esac) 284 | end_day=$(case "$end" in 0) echo "Sunday";; 1) echo "Monday";; 2) echo "Tuesday";; 3) echo "Wednesday";; 4) echo "Thursday";; 5) echo "Friday";; 6) echo "Saturday";; *) echo "$end";; esac) 285 | result="${result}$start_day-$end_day" ;; 286 | esac 287 | else 288 | # Handle individual values in the list 289 | case "$field_type" in 290 | "minute") result="${result}minute $val" ;; 291 | "hour") 292 | if [ "$val" = "0" ]; then result="${result}midnight" 293 | elif [ "$val" -lt 12 ]; then result="${result}$val:00 AM" 294 | elif [ "$val" = "12" ]; then result="${result}noon" 295 | else result="${result}$(($val-12)):00 PM" 296 | fi ;; 297 | "day") 298 | # Format day numbers nicely (1st, 2nd, 3rd, etc.) 299 | case "$val" in 300 | 1) result="${result}the 1st" ;; 301 | 2) result="${result}the 2nd" ;; 302 | 3) result="${result}the 3rd" ;; 303 | 21) result="${result}the 21st" ;; 304 | 22) result="${result}the 22nd" ;; 305 | 23) result="${result}the 23rd" ;; 306 | 31) result="${result}the 31st" ;; 307 | *) 308 | if [ "$val" -ge 4 ] && [ "$val" -le 20 ]; then 309 | result="${result}the ${val}th" 310 | elif [ "$val" -ge 24 ] && [ "$val" -le 30 ]; then 311 | result="${result}the ${val}th" 312 | else 313 | result="${result}day $val" 314 | fi ;; 315 | esac ;; 316 | "month") 317 | case "$val" in 318 | 1) result="${result}January" ;; 319 | 2) result="${result}February" ;; 320 | 3) result="${result}March" ;; 321 | 4) result="${result}April" ;; 322 | 5) result="${result}May" ;; 323 | 6) result="${result}June" ;; 324 | 7) result="${result}July" ;; 325 | 8) result="${result}August" ;; 326 | 9) result="${result}September" ;; 327 | 10) result="${result}October" ;; 328 | 11) result="${result}November" ;; 329 | 12) result="${result}December" ;; 330 | *) result="${result}month $val" ;; 331 | esac ;; 332 | "weekday") 333 | case "$val" in 334 | 0) result="${result}Sunday" ;; 335 | 1) result="${result}Monday" ;; 336 | 2) result="${result}Tuesday" ;; 337 | 3) result="${result}Wednesday" ;; 338 | 4) result="${result}Thursday" ;; 339 | 5) result="${result}Friday" ;; 340 | 6) result="${result}Saturday" ;; 341 | *) result="${result}weekday $val" ;; 342 | esac ;; 343 | esac 344 | fi 345 | done 346 | 347 | echo "$result" 348 | return 349 | fi 350 | 351 | # Handle single values 352 | case "$field_type" in 353 | "minute") result="minute $field_value" ;; 354 | "hour") 355 | if [ "$field_value" = "0" ]; then result="midnight" 356 | elif [ "$field_value" -lt 12 ]; then result="$field_value:00 AM" 357 | elif [ "$field_value" = "12" ]; then result="noon" 358 | else result="$(($field_value-12)):00 PM" 359 | fi ;; 360 | "day") 361 | # Format day numbers nicely (1st, 2nd, 3rd, etc.) 362 | case "$field_value" in 363 | 1) result="the 1st" ;; 364 | 2) result="the 2nd" ;; 365 | 3) result="the 3rd" ;; 366 | 21) result="the 21st" ;; 367 | 22) result="the 22nd" ;; 368 | 23) result="the 23rd" ;; 369 | 31) result="the 31st" ;; 370 | *) 371 | if [ "$field_value" -ge 4 ] && [ "$field_value" -le 20 ]; then 372 | result="the ${field_value}th" 373 | elif [ "$field_value" -ge 24 ] && [ "$field_value" -le 30 ]; then 374 | result="the ${field_value}th" 375 | else 376 | result="day $field_value" 377 | fi ;; 378 | esac ;; 379 | "month") 380 | case "$field_value" in 381 | 1) result="January" ;; 382 | 2) result="February" ;; 383 | 3) result="March" ;; 384 | 4) result="April" ;; 385 | 5) result="May" ;; 386 | 6) result="June" ;; 387 | 7) result="July" ;; 388 | 8) result="August" ;; 389 | 9) result="September" ;; 390 | 10) result="October" ;; 391 | 11) result="November" ;; 392 | 12) result="December" ;; 393 | *) result="month $field_value" ;; 394 | esac ;; 395 | "weekday") 396 | case "$field_value" in 397 | 0) result="Sunday" ;; 398 | 1) result="Monday" ;; 399 | 2) result="Tuesday" ;; 400 | 3) result="Wednesday" ;; 401 | 4) result="Thursday" ;; 402 | 5) result="Friday" ;; 403 | 6) result="Saturday" ;; 404 | *) result="weekday $field_value" ;; 405 | esac ;; 406 | esac 407 | 408 | echo "$result" 409 | } 410 | 411 | # Parse each field 412 | minute_desc=$(parse_field "$minute" "minute") 413 | hour_desc=$(parse_field "$hour" "hour") 414 | day_desc=$(parse_field "$day" "day") 415 | month_desc=$(parse_field "$month" "month") 416 | weekday_desc=$(parse_field "$weekday" "weekday") 417 | 418 | # Build description 419 | description="" 420 | 421 | # Determine frequency based on which fields are restricted 422 | has_day_restriction=$([ "$day" != "*" ] && echo "yes" || echo "no") 423 | has_month_restriction=$([ "$month" != "*" ] && echo "yes" || echo "no") 424 | has_weekday_restriction=$([ "$weekday" != "*" ] && echo "yes" || echo "no") 425 | 426 | # Handle time components 427 | if [ "$minute_desc" = "every minute" ] && [ "$hour_desc" = "every hour" ]; then 428 | time_desc="every minute" 429 | elif echo "$minute_desc" | grep -q "every.*minute" && [ "$hour_desc" = "every hour" ]; then 430 | # Minute has step, hour is * (every hour) - just use minute description 431 | time_desc="$minute_desc" 432 | elif echo "$minute_desc" | grep -q "every.*minute" && echo "$hour_desc" | grep -q "every.*hour"; then 433 | # Both have step values 434 | time_desc="$minute_desc, $hour_desc" 435 | elif echo "$minute_desc" | grep -q "every.*minute"; then 436 | # Minute has step, hour is specific 437 | time_desc="$minute_desc at $hour_desc" 438 | elif [ "$minute_desc" = "minute 0" ] && [ "$hour_desc" = "every hour" ]; then 439 | time_desc="hourly" 440 | elif [ "$minute_desc" = "minute 0" ] && echo "$hour_desc" | grep -q "^every [0-9]* hours$"; then 441 | time_desc="$hour_desc" 442 | elif [ "$minute_desc" = "minute 0" ] && [ "$hour_desc" = "midnight" ]; then 443 | time_desc="daily at midnight" 444 | elif [ "$minute_desc" = "minute 0" ] && echo "$hour_desc" | grep -q "to"; then 445 | # Minute 0 with hour range = hourly during that range 446 | time_desc="hourly from $hour_desc" 447 | elif echo "$hour_desc" | grep -q "to"; then 448 | # Hour range like "9:00 AM to 5:00 PM" 449 | if echo "$minute_desc" | grep -q "every.*minute"; then 450 | time_desc="$minute_desc from $hour_desc" 451 | else 452 | time_desc="$minute_desc at $hour_desc" 453 | fi 454 | else 455 | time_desc="$minute_desc at $hour_desc" 456 | fi 457 | 458 | # Simplify "minute 0 at X" to just "at X" 459 | if [ "$time_desc" = "minute 0 at $hour_desc" ]; then 460 | time_desc="at $hour_desc" 461 | fi 462 | 463 | # Build final description based on restrictions 464 | if [ "$has_weekday_restriction" = "yes" ] && [ "$has_day_restriction" = "no" ] && [ "$has_month_restriction" = "no" ]; then 465 | # Weekly pattern 466 | if [ "$time_desc" = "daily at midnight" ]; then 467 | description="weekly at midnight on $weekday_desc" 468 | elif [ "$time_desc" = "at noon" ]; then 469 | description="weekly at noon on $weekday_desc" 470 | else 471 | description="$time_desc on $weekday_desc" 472 | fi 473 | elif [ "$has_day_restriction" = "yes" ] && [ "$has_weekday_restriction" = "no" ] && [ "$has_month_restriction" = "no" ]; then 474 | # Monthly pattern (specific days) 475 | if [ "$time_desc" = "daily at midnight" ]; then 476 | description="monthly at midnight on $day_desc" 477 | elif [ "$time_desc" = "at noon" ]; then 478 | description="monthly at noon on $day_desc" 479 | else 480 | description="$time_desc on $day_desc" 481 | fi 482 | elif [ "$has_month_restriction" = "yes" ] && [ "$has_day_restriction" = "no" ] && [ "$has_weekday_restriction" = "no" ]; then 483 | # Yearly pattern (specific months) 484 | if [ "$time_desc" = "daily at midnight" ]; then 485 | description="yearly at midnight in $month_desc" 486 | elif [ "$time_desc" = "at noon" ]; then 487 | description="yearly at noon in $month_desc" 488 | else 489 | description="$time_desc in $month_desc" 490 | fi 491 | else 492 | # Daily or complex pattern 493 | description="$time_desc" 494 | if [ "$has_day_restriction" = "yes" ]; then 495 | description="$description on $day_desc" 496 | fi 497 | if [ "$has_month_restriction" = "yes" ]; then 498 | description="$description in $month_desc" 499 | fi 500 | if [ "$has_weekday_restriction" = "yes" ]; then 501 | description="$description on $weekday_desc" 502 | fi 503 | fi 504 | 505 | echo "$description" 506 | } 507 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![logo](https://github.com/azinchen/nordvpn/raw/master/NordVpn_logo.png)](https://www.nordvpn.com/) 2 | 3 | # NordVPN OpenVPN Docker Container 4 | 5 | 6 | [![GitHub release][github-release]][github-releases] 7 | [![GitHub release date][github-releasedate]][github-releases] 8 | [![GitHub build][github-build]][github-actions] 9 | 10 | 11 | [![GitHub stars][github-stars]][github-link] 12 | [![GitHub forks][github-forks]][github-link] 13 | [![Open issues][github-issues]][github-issues-link] 14 | [![GitHub last commit][github-lastcommit]][github-link] 15 | 16 | 17 | [![Docker pulls][dockerhub-pulls]][dockerhub-link] 18 | [![Docker stars][dockerhub-stars]][dockerhub-link] 19 | [![Docker image size][dockerhub-size]][dockerhub-link] 20 | 21 | 22 | [![Multi-arch][multiarch-badge]](#supported-platforms) 23 | 24 | OpenVPN client docker container that routes other containers' traffic through NordVPN servers automatically. 25 | 26 | ## ✨ Key Features 27 | 28 | - **🚀 Easy Setup**: Route any container's traffic through VPN with `--net=container:vpn` 29 | - **🌍 Smart Server Selection**: Automatically selects optimal NordVPN servers by country, city, or group 30 | - **⚖️ Load Balancing**: Intelligent sorting by server load when multiple locations specified 31 | - **🔄 Auto-Reconnection**: Periodic server switching and connection health monitoring with cron 32 | - **🛡️ Strict(er) Kill Switch**: Blocks all traffic when VPN is down except exempt networks 33 | - **🔒 Local/LAN Access (explicit)**: Allow specific LAN or inter‑container CIDRs with `NETWORK=...` 34 | - **📌 Pinned NordVPN API IPs**: Bootstrap uses `NORDVPNAPI_IP` to reach `api.nordvpn.com` **without DNS** 35 | - **📵 IPv6**: IPv6 firewall is applied — built-in chains default to **DROP** if IPv6 is enabled 36 | - **🧱 iptables compatibility**: Automatically falls back to **iptables‑legacy** on older or nft‑broken hosts 37 | 38 | --- 39 | 40 | 41 | ## Table of contents 42 | 43 | - [✨ Key Features](#key-features) 44 | - [Quick Start](#quick-start) 45 | - [Basic Usage](#basic-usage) 46 | - [Requirements](#requirements) 47 | - [Security Features](#security-features) 48 | - [Container Registries](#container-registries) 49 | - [Firewall backends (nft vs legacy)](#firewall-backends-nft-vs-legacy) 50 | - [Getting Service Credentials](#getting-service-credentials) 51 | - [Configuration Options](#configuration-options) 52 | - [Server Selection](#server-selection) 53 | - [IPv6 behavior](#ipv6-behavior) 54 | - [Automatic Reconnection](#automatic-reconnection) 55 | - [Scheduled Reconnection](#scheduled-reconnection) 56 | - [Connection Failure Handling](#connection-failure-handling) 57 | - [Connection Health Monitoring](#connection-health-monitoring) 58 | - [Local Network Access](#local-network-access) 59 | - [Docker Compose Examples](#docker-compose-examples) 60 | - [Simple VPN + Application Setup](#simple-vpn--application-setup) 61 | - [Advanced Setup with Local Access](#advanced-setup-with-local-access) 62 | - [Web Proxy Setup](#web-proxy-setup) 63 | - [Docker Run Examples](#docker-run-examples) 64 | - [Basic Example](#basic-example) 65 | - [Advanced Example with Port Mapping](#advanced-example-with-port-mapping) 66 | - [Environment Variables](#environment-variables) 67 | - [Supported Platforms](#supported-platforms) 68 | - [Updating the VPN container & dependent services](#updating-the-vpn-container--dependent-services) 69 | - [With Docker Compose](#with-docker-compose) 70 | - [With plain Docker (no Compose)](#with-plain-docker-no-compose) 71 | - [Safer automated updates for Compose stacks](#safer-automated-updates-for-compose-stacks) 72 | - [Issues](#issues) 73 | 74 | 75 | ## Quick Start 76 | 77 | ### Basic Usage 78 | 79 | **From Docker Hub:** 80 | ```bash 81 | docker run -d --cap-add=NET_ADMIN --device /dev/net/tun --name vpn \ 82 | -e USER=service_username -e PASS=service_password \ 83 | azinchen/nordvpn 84 | ``` 85 | 86 | **From GitHub Container Registry:** 87 | ```bash 88 | docker run -d --cap-add=NET_ADMIN --device /dev/net/tun --name vpn \ 89 | -e USER=service_username -e PASS=service_password \ 90 | ghcr.io/azinchen/nordvpn 91 | ``` 92 | 93 | Route other containers through VPN: 94 | ```bash 95 | docker run --net=container:vpn -d your/application 96 | ``` 97 | 98 | ### Requirements 99 | 100 | - Docker with `--cap-add=NET_ADMIN` and `--device /dev/net/tun` 101 | - **NordVPN Service Credentials** (not regular account credentials) 102 | - The image includes both nftables and **iptables‑legacy** and auto‑selects the working backend at runtime — no manual config needed. 103 | 104 | ### Security Features 105 | 106 | **🛡️ Traffic Control & Kill Switch** 107 | - **Default‑deny (egress):** All outbound traffic is blocked unless it goes through the VPN interface, matches `NETWORK` (CIDRs you define) or is directed to NordVPN's API. 108 | - **Bootstrap (pre‑VPN):** DNS egress is **blocked**. The container contacts NordVPN’s API via **pinned IP addresses** from `NORDVPNAPI_IP` to select a server (no DNS queries before the tunnel is up). 109 | - **Kill switch:** If the VPN drops, traffic remains blocked **except** for destinations within your `NETWORK` CIDRs (e.g., local/LAN ranges you explicitly allowed) and to NordVPN's API. 110 | - **Container routing:** Containers using `network_mode: "service:vpn"` share the VPN container’s network namespace and inherit these policies. 111 | - **Inbound (local/LAN only):** No connections from the host or LAN reach the stack **unless you publish ports on the VPN container**. **Public inbound via NordVPN is not supported** (no port forwarding). 112 | 113 | **🔒 Network Access Control (Exceptions)** 114 | - **Local/LAN access (bidirectional, explicit):** Set `NETWORK=192.168.1.0/24` (semicolon‑separated CIDRs supported) to allow access to those subnets **regardless of VPN status**. 115 | - **No domain names allowed:** Use IPs in `NETWORK` for any non‑VPN access you require. 116 | 117 | **⚖️ Rule Precedence** 118 | 1. **Bootstrap-only (when VPN is down & before first connect):** Allow HTTPS only to the **NordVPN API IPs from `NORDVPNAPI_IP`** used by the image’s bootstrap script. 119 | 2. **Exceptions:** If destination matches `NETWORK` (CIDR), allow (bypass/LAN), regardless of VPN state. 120 | 3. **VPN path:** If VPN is **up** and traffic is not an exception, allow only via the VPN interface. 121 | 4. **Default‑deny:** Otherwise, block. 122 | 123 | **⚠️ Security Note** 124 | Because `NETWORK` remains open when the VPN is down, this is **not a strict kill switch** if you include broad CIDRs. Keep `NETWORK` as narrow as possible (e.g., just your LAN / management subnets). 125 | 126 | ### Container Registries 127 | 128 | The image is available from two registries: 129 | 130 | - **Docker Hub**: `azinchen/nordvpn` — Main distribution, publicly accessible 131 | - **GitHub Container Registry**: `ghcr.io/azinchen/nordvpn` — Alternative source, same image 132 | 133 | Both registries contain identical images. Use whichever is more convenient for your setup. 134 | 135 | ### Firewall backends (nft vs legacy) 136 | 137 | This image ships **both** `iptables` (nft-backed) and `iptables-legacy` (xtables). 138 | At runtime, the entrypoint selects a working backend: 139 | 140 | - **New kernels (≥ 4.18)** → prefer **nft** (`iptables`) if it can change policy; otherwise fall back to legacy. 141 | - **Old kernels (< 4.18, e.g., 4.4)** → prefer **legacy** (`iptables-legacy`); fall back to nft only if legacy is unavailable. 142 | 143 | The selection is verified by attempting to toggle a chain policy (DROP ↔ ACCEPT) on `OUTPUT`. If that fails for a backend, it is not used. If legacy is selected and nft tables already contain rules in this network namespace, the entrypoint flushes nft tables **once** to avoid mixed stacks. 144 | 145 | You’ll see logs like: 146 | 147 | ``` 148 | [ENTRYPOINT] Kernel: 6.8.0-xx 149 | [ENTRYPOINT] Using IPv4 backend: iptables 150 | ``` 151 | 152 | or on older systems: 153 | 154 | ``` 155 | [ENTRYPOINT] Kernel: 4.4.0-xxx 156 | [ENTRYPOINT] Using IPv4 backend: iptables-legacy 157 | ``` 158 | 159 | ### Getting Service Credentials 160 | 161 | 1. Log into your [Nord Account Dashboard](https://my.nordaccount.com/) 162 | 2. Click on **NordVPN** 163 | 3. Under **Advanced Settings**, click **Set up NordVPN manually** 164 | 4. Go to the **Service credentials** tab 165 | 5. Copy the **Username** and **Password** shown there 166 | 167 | **Note**: These are different from your regular NordVPN login credentials and are specifically required for OpenVPN connections. 168 | 169 | ## Configuration Options 170 | 171 | ### Server Selection 172 | 173 | Filter NordVPN servers using location and server criteria: 174 | 175 | ```bash 176 | docker run -d --cap-add=NET_ADMIN --device /dev/net/tun \ 177 | -e USER=service_username -e PASS=service_password \ 178 | -e COUNTRY="United States;CA;153" \ 179 | -e CITY="New York;2619989;es1234" \ 180 | -e GROUP="Standard VPN servers" \ 181 | -e RANDOM_TOP=5 \ 182 | azinchen/nordvpn 183 | ``` 184 | 185 | **Location Specification Options:** 186 | - **Country**: name (`United States`), code (`US`), or ID (`228`) 187 | - **City**: name (`New York`) or ID (`8971718`) 188 | - **Specific Server**: Use hostname (e.g., `es1234`, `uk2567`) in either COUNTRY or CITY — these get priority with load=0 189 | 190 | **Server Selection Behavior:** 191 | - **Specific servers**: Named servers are placed at the top of the list with load=0 192 | - **Multiple locations**: Combined and sorted by load (lowest first) 193 | - **Single location**: Keeps NordVPN’s recommended order 194 | - **RANDOM_TOP**: Applies after filtering and sorting 195 | 196 | ### IPv6 behavior 197 | 198 | This image **applies an IPv6 firewall** (when `ip6tables` is available and IPv6 is enabled): `INPUT`/`FORWARD`/`OUTPUT` default to `DROP`. It does **not** change IPv6 sysctls from inside the container (many environments mount `/proc/sys` read-only). 199 | 200 | If your Docker runtime assigns IPv6 addresses and you want to avoid IPv6 leaks, choose **one** of the following: 201 | 202 | #### Option A — Disable IPv6 for the Docker daemon/network (recommended) 203 | - Daemon-wide: set `"ipv6": false` in Docker’s `daemon.json` and restart Docker. 204 | - Per-network: create the network with `--ipv6=false`. 205 | 206 | #### Option B — Disable IPv6 per container via runtime sysctls 207 | Pass sysctls at **run** time (works even when `/proc/sys` is read-only inside the container): 208 | 209 | ```bash 210 | docker run -d --cap-add=NET_ADMIN --device /dev/net/tun --name vpn \ 211 | --sysctl net.ipv6.conf.all.disable_ipv6=1 \ 212 | --sysctl net.ipv6.conf.default.disable_ipv6=1 \ 213 | --sysctl net.ipv6.conf.eth0.disable_ipv6=1 \ 214 | azinchen/nordvpn 215 | ``` 216 | 217 | **docker-compose:** 218 | 219 | ```yaml 220 | services: 221 | vpn: 222 | image: azinchen/nordvpn 223 | sysctls: 224 | net.ipv6.conf.all.disable_ipv6: "1" 225 | net.ipv6.conf.default.disable_ipv6: "1" 226 | net.ipv6.conf.eth0.disable_ipv6: "1" 227 | ``` 228 | 229 | #### Option C — Disable IPv6 on the host 230 | Use host sysctls or OS network settings to turn off IPv6 globally. 231 | 232 | #### How to verify IPv6 is truly off 233 | 234 | Inside the container: 235 | 236 | ```bash 237 | cat /proc/net/if_inet6 # no output means no IPv6 addresses 238 | ip -6 addr show dev eth0 # should show "Device not found" or no inet6 lines 239 | ip6tables -S 2>/dev/null || true # may be empty/unavailable 240 | ``` 241 | 242 | > Note: Because this image does **not** touch `ip6tables`, if your environment leaves IPv6 **enabled**, IPv6 traffic may bypass the IPv4 firewall. Use one of the options above to disable IPv6 at runtime. 243 | 244 | ### Automatic Reconnection 245 | 246 | #### Scheduled Reconnection 247 | ```bash 248 | # Reconnect every 6 hours at minute 0 249 | -e RECREATE_VPN_CRON="0 */6 * * *" 250 | 251 | # Reconnect daily at 3 AM 252 | -e RECREATE_VPN_CRON="0 3 * * *" 253 | 254 | # Reconnect every 4 hours 255 | -e RECREATE_VPN_CRON="0 */4 * * *" 256 | ``` 257 | 258 | #### Connection Failure Handling 259 | ```bash 260 | # Force reconnect to different server on connection loss 261 | -e OPENVPN_OPTS="--pull-filter ignore ping-restart --ping-exit 180" 262 | 263 | # Alternative: More aggressive reconnection 264 | -e OPENVPN_OPTS="--ping 10 --ping-exit 60 --ping-restart 300" 265 | ``` 266 | 267 | #### Connection Health Monitoring 268 | ```bash 269 | # Check connection every 5 minutes 270 | -e CHECK_CONNECTION_CRON="*/5 * * * *" 271 | -e CHECK_CONNECTION_URL="https://1.1.1.1;https://8.8.8.8" 272 | -e CHECK_CONNECTION_ATTEMPTS=3 273 | -e CHECK_CONNECTION_ATTEMPT_INTERVAL=10 274 | ``` 275 | 276 | ### Local Network Access 277 | 278 | Allow local services or inter‑container networks **explicitly**: 279 | 280 | ```bash 281 | # Find your local network 282 | ip route | awk '!/ (docker0|br-)/ && /src/ {print $1}' 283 | 284 | # Configure container with local network access 285 | docker run -d --cap-add=NET_ADMIN --device /dev/net/tun \ 286 | -e NETWORK=192.168.1.0/24 \ 287 | -e USER=service_username -e PASS=service_password \ 288 | azinchen/nordvpn 289 | ``` 290 | 291 | - Docker subnets are **not** auto‑allowed. If containers sharing the VPN namespace need to talk to each other or to services on your LAN/host, include those CIDRs in `NETWORK`. 292 | 293 | ## Docker Compose Examples 294 | 295 | ### Simple VPN + Application Setup 296 | 297 | ```yaml 298 | version: "3.8" 299 | services: 300 | vpn: 301 | image: azinchen/nordvpn:latest 302 | container_name: nordvpn 303 | cap_add: 304 | - NET_ADMIN 305 | devices: 306 | - /dev/net/tun 307 | environment: 308 | - USER=service_username 309 | - PASS=service_password 310 | - COUNTRY=United States;CA;38 311 | - CITY=New York;Los Angeles;Toronto 312 | - RANDOM_TOP=10 313 | - RECREATE_VPN_CRON=0 */6 * * * # Reconnect every 6 hours 314 | - NETWORK=192.168.1.0/24 # Your local network 315 | ports: 316 | - "8080:8080" # Expose ports for services using VPN 317 | - "3000:3000" # Application web UI 318 | restart: unless-stopped 319 | 320 | # Application using VPN webapp: 321 | webapp: 322 | image: nginx:alpine 323 | container_name: webapp 324 | network_mode: "service:vpn" # Route through VPN 325 | depends_on: 326 | - vpn 327 | volumes: 328 | - ./html:/usr/share/nginx/html:ro 329 | restart: unless-stopped 330 | 331 | # Another application using VPN 332 | api-service: 333 | image: node:alpine 334 | container_name: api-service 335 | network_mode: "service:vpn" # Route through VPN 336 | depends_on: 337 | - vpn 338 | working_dir: /app 339 | volumes: 340 | - ./app:/app 341 | command: ["npm", "start"] 342 | restart: unless-stopped 343 | ``` 344 | 345 | ### Advanced Setup with Local Access 346 | 347 | ```yaml 348 | version: "3.8" 349 | services: 350 | # VPN Container with health monitoring 351 | vpn: 352 | image: azinchen/nordvpn:latest 353 | container_name: nordvpn 354 | cap_add: 355 | - NET_ADMIN 356 | devices: 357 | - /dev/net/tun 358 | environment: 359 | - USER=service_username 360 | - PASS=service_password 361 | - COUNTRY=Netherlands;DE;209 362 | - CITY=Amsterdam;Berlin;Frankfurt 363 | - GROUP=Standard VPN servers 364 | - RANDOM_TOP=5 365 | - RECREATE_VPN_CRON=0 */4 * * * 366 | - CHECK_CONNECTION_CRON=*/5 * * * * 367 | - CHECK_CONNECTION_URL=https://1.1.1.1;https://8.8.8.8 368 | - NETWORK=192.168.1.0/24;172.20.0.0/16 369 | - OPENVPN_OPTS=--mute-replay-warnings --ping-exit 60 370 | ports: 371 | - "8080:8080" # Web application 372 | - "3000:3000" # API service 373 | - "9000:9000" # Monitoring dashboard 374 | - "6379:6379" # Redis 375 | restart: unless-stopped 376 | 377 | # Web application using VPN 378 | webapp: 379 | image: nginx:alpine 380 | container_name: webapp 381 | network_mode: "service:vpn" 382 | depends_on: 383 | vpn: 384 | condition: service_healthy 385 | volumes: 386 | - ./config/nginx.conf:/etc/nginx/nginx.conf:ro 387 | - ./web:/usr/share/nginx/html:ro 388 | restart: unless-stopped 389 | 390 | api-service: 391 | image: node:alpine 392 | container_name: api-service 393 | network_mode: "service:vpn" 394 | depends_on: 395 | - vpn 396 | - webapp 397 | working_dir: /app 398 | volumes: 399 | - ./api:/app 400 | command: ["npm", "start"] 401 | restart: unless-stopped 402 | 403 | redis: 404 | image: redis:alpine 405 | container_name: redis 406 | network_mode: "service:vpn" 407 | depends_on: 408 | - vpn 409 | volumes: 410 | - ./config/redis:/data 411 | restart: unless-stopped 412 | 413 | # Service that DOESN'T use VPN (runs on host network) 414 | monitoring: 415 | image: grafana/grafana:latest 416 | container_name: monitoring 417 | network_mode: host # Direct host access for local monitoring 418 | environment: 419 | - GF_SECURITY_ADMIN_PASSWORD=admin 420 | volumes: 421 | - ./config/grafana:/var/lib/grafana 422 | restart: unless-stopped 423 | ``` 424 | 425 | ### Web Proxy Setup 426 | 427 | ```yaml 428 | version: "3.8" 429 | services: 430 | vpn: 431 | image: azinchen/nordvpn:latest 432 | container_name: nordvpn 433 | cap_add: 434 | - NET_ADMIN 435 | devices: 436 | - /dev/net/tun 437 | environment: 438 | - USER=service_username 439 | - PASS=service_password 440 | - COUNTRY=CA;38 441 | - CITY=Toronto;Montreal 442 | - NETWORK=192.168.1.0/24 443 | restart: unless-stopped 444 | 445 | # Application behind VPN 446 | app: 447 | image: nginx:alpine 448 | container_name: webapp 449 | network_mode: "service:vpn" 450 | depends_on: 451 | - vpn 452 | volumes: 453 | - ./html:/usr/share/nginx/html 454 | restart: unless-stopped 455 | 456 | # Reverse proxy for local access 457 | nginx-proxy: 458 | image: nginx:alpine 459 | container_name: proxy 460 | ports: 461 | - "80:80" 462 | - "443:443" 463 | volumes: 464 | - ./nginx.conf:/etc/nginx/nginx.conf:ro 465 | depends_on: 466 | - vpn 467 | restart: unless-stopped 468 | ``` 469 | 470 | ## Docker Run Examples 471 | 472 | ### Basic Example 473 | ```bash 474 | docker run -d --name vpn \ 475 | --cap-add=NET_ADMIN \ 476 | --device /dev/net/tun \ 477 | -e USER=service_username \ 478 | -e PASS=service_password \ 479 | azinchen/nordvpn 480 | 481 | # Run application through VPN 482 | docker run -d --name app --net=container:vpn nginx 483 | ``` 484 | 485 | ### Advanced Example with Port Mapping 486 | ```bash 487 | docker run -d --name vpn \ 488 | --cap-add=NET_ADMIN \ 489 | --device /dev/net/tun \ 490 | -p 8080:8080 \ 491 | -p 9091:9091 \ 492 | -e USER=service_username \ 493 | -e PASS=service_password \ 494 | -e COUNTRY="Germany;NL;202" \ 495 | -e CITY="Amsterdam;6076868;uk2567" \ 496 | -e GROUP="Standard VPN servers" \ 497 | -e RANDOM_TOP=3 \ 498 | -e RECREATE_VPN_CRON="0 */6 * * *" \ 499 | -e NETWORK=192.168.1.0/24 \ 500 | azinchen/nordvpn 501 | 502 | # Applications using VPN (access via host ports) 503 | docker run -d --name webapp --net=container:vpn \ 504 | nginx:alpine 505 | 506 | docker run -d --name api-service --net=container:vpn \ 507 | -v ./app:/app -w /app \ 508 | node:alpine npm start 509 | ``` 510 | 511 | ## Environment Variables 512 | 513 | | Variable | Details | 514 | |---|---| 515 | | **USER** | **Required** — NordVPN service credentials username.
**Default:** —
**Example:** `service_username` | 516 | | **PASS** | **Required** — NordVPN service credentials password.
**Default:** —
**Example:** `service_password` | 517 | | **COUNTRY** | Filter by countries: names, codes, IDs, or specific server hostnames ([list][nordvpn-countries]). Use semicolons to separate multiple values.
**Default:** All countries
**Example:** `United States;CA;228;es1234` | 518 | | **CITY** | Filter by cities: names, IDs, or specific server hostnames ([list][nordvpn-cities]). Use semicolons to separate multiple values.
**Default:** All cities
**Example:** `New York;8971718;uk2567` | 519 | | **GROUP** | Filter by server group ([list][nordvpn-groups]).
**Default:** Not defined
**Example:** `Standard VPN servers` | 520 | | **TECHNOLOGY** | Filter by technology — OpenVPN only supported ([list][nordvpn-technologies]).
**Default:** OpenVPN UDP
**Example:** `openvpn_udp` | 521 | | **RANDOM_TOP** | Randomize top **N** servers from the filtered list.
**Default:** `0`
**Example:** `10` | 522 | | **RECREATE_VPN_CRON** | Schedule for server switching (cron format).
**Default:** Disabled
**Example:** `0 */6 * * *` *(every 6 hours)* | 523 | | **CHECK_CONNECTION_CRON** | Schedule for connection monitoring (cron format).
**Default:** Disabled
**Example:** `*/5 * * * *` *(every 5 minutes)* | 524 | | **CHECK_CONNECTION_URL** | URLs to test connectivity; semicolon‑separated.
**Default:** `https://www.google.com`
**Example:** `https://1.1.1.1;https://8.8.8.8` | 525 | | **CHECK_CONNECTION_ATTEMPTS** | Number of connection test attempts.
**Default:** `5`
**Example:** `5` | 526 | | **CHECK_CONNECTION_ATTEMPT_INTERVAL** | Seconds between failed attempts.
**Default:** `10`
**Example:** `10` | 527 | | **NETWORK** | Local/LAN or inter‑container networks to allow; semicolon‑separated CIDRs.
**Default:** None
**Example:** `10.0.0.0/8;172.16.0.0/12;192.168.0.0/16` | 528 | | **NORDVPNAPI_IP** | IPv4 list of `api.nordvpn.com` addresses (semicolon‑separated) used during **pre‑VPN bootstrap** to avoid DNS (HTTPS only).
**Default:** `104.16.208.203;104.19.159.190`
**Example:** `104.19.159.190;104.16.208.203` | 529 | | **OPENVPN_OPTS** | Additional OpenVPN parameters.
**Default:** None
**Example:** `--mute-replay-warnings` | 530 | | **NETWORK_DIAGNOSTIC_ENABLED** | Enable automatic network diagnostics on VPN connection and reconnection.
**Default:** `false`
**Example:** `true` | 531 | 532 | ## Supported Platforms 533 | 534 | This container supports multiple architectures and can run on various platforms: 535 | 536 | | Architecture | Platform | Notes | 537 | |--------------|----------|-------| 538 | | `linux/386` | 32-bit x86 | Legacy systems | 539 | | `linux/amd64` | 64-bit x86 | Most common desktop/server | 540 | | `linux/arm/v6` | ARM v6 | Older ARM devices | 541 | | `linux/arm/v7` | ARM v7 | Raspberry Pi 2/3, many ARM SBCs | 542 | | `linux/arm64` | 64-bit ARM | Raspberry Pi 4/5, Apple M1, modern ARM | 543 | | `linux/ppc64le` | PowerPC 64-bit LE | IBM Power Systems | 544 | | `linux/riscv64` | 64-bit RISC-V | Emerging RISC-V hardware | 545 | | `linux/s390x` | IBM System z | Enterprise mainframes | 546 | 547 | Docker will automatically pull the correct architecture. 548 | 549 | ## Updating the VPN container & dependent services 550 | 551 | When the `vpn` container is restarted — whether due to an image update or a manual restart — every container that uses `network_mode: "service:vpn"` must also be restarted so they reattach to the recreated network namespace. 552 | 553 | ### With Docker Compose 554 | ```bash 555 | docker compose pull 556 | docker compose up -d --force-recreate 557 | ``` 558 | 559 | ### With plain Docker (no Compose) 560 | ```bash 561 | docker pull azinchen/nordvpn:latest # or ghcr.io/azinchen/nordvpn:latest 562 | docker stop vpn && docker rm vpn 563 | # Re-run your "vpn" container with the same args as before... 564 | # Then restart each dependent container: 565 | docker restart webapp api-service redis 566 | ``` 567 | 568 | ### Safer automated updates for Compose stacks 569 | Consider using 570 | [azinchen/update-docker-containers](https://github.com/azinchen/update-docker-containers). 571 | 572 | ## Issues 573 | 574 | If you have any problems with or questions about this image, please contact me through a [GitHub issue][github-issues-link] or [email][email-link]. 575 | 576 | [dockerhub-link]: https://hub.docker.com/r/azinchen/nordvpn 577 | [dockerhub-pulls]: https://img.shields.io/docker/pulls/azinchen/nordvpn?logo=docker&logoColor=white 578 | [dockerhub-size]: https://img.shields.io/docker/image-size/azinchen/nordvpn/latest?logo=docker&logoColor=white 579 | [dockerhub-stars]: https://img.shields.io/docker/stars/azinchen/nordvpn?logo=docker&logoColor=white 580 | [github-link]: https://github.com/azinchen/nordvpn 581 | [github-issues]: https://img.shields.io/github/issues/azinchen/nordvpn?logo=github&logoColor=white 582 | [github-issues-link]: https://github.com/azinchen/nordvpn/issues 583 | [github-releases]: https://github.com/azinchen/nordvpn/releases 584 | [github-actions]: https://github.com/azinchen/nordvpn/actions 585 | [github-stars]: https://img.shields.io/github/stars/azinchen/nordvpn?style=flat-square&logo=github&logoColor=white 586 | [github-forks]: https://img.shields.io/github/forks/azinchen/nordvpn?style=flat-square&logo=github&logoColor=white 587 | [github-release]: https://img.shields.io/github/v/release/azinchen/nordvpn?logo=github&logoColor=white 588 | [github-releasedate]: https://img.shields.io/github/release-date/azinchen/nordvpn?logo=github&logoColor=white 589 | [github-build]: https://img.shields.io/github/actions/workflow/status/azinchen/nordvpn/ci-build-deploy.yml?branch=master&label=build&logo=github&logoColor=white 590 | [github-lastcommit]: https://img.shields.io/github/last-commit/azinchen/nordvpn?logo=github&logoColor=white 591 | [multiarch-badge]: https://img.shields.io/badge/multi--arch-linux%2F386%20%7C%20linux%2Famd64%20%7C%20linux%2Farm%2Fv6%20%7C%20linux%2Farm%2Fv7%20%7C%20linux%2Farm64%20%7C%20linux%2Fppc64le%20%7C%20linux%2Friscv64%20%7C%20linux%2Fs390x-blue?logo=docker&logoColor=white 592 | [nordvpn-cities]: https://github.com/azinchen/nordvpn/blob/master/CITIES.md 593 | [nordvpn-countries]: https://github.com/azinchen/nordvpn/blob/master/COUNTRIES.md 594 | [nordvpn-groups]: https://github.com/azinchen/nordvpn/blob/master/GROUPS.md 595 | [nordvpn-technologies]: https://github.com/azinchen/nordvpn/blob/master/TECHNOLOGIES.md 596 | [email-link]: mailto:alexander@zinchenko.com 597 | --------------------------------------------------------------------------------