diff --git a/qbittorrent/rootfs/etc/cont-init.d/95-vpn-monitor.sh b/qbittorrent/rootfs/etc/cont-init.d/95-vpn-monitor.sh new file mode 100755 index 000000000..d23e8d2a4 --- /dev/null +++ b/qbittorrent/rootfs/etc/cont-init.d/95-vpn-monitor.sh @@ -0,0 +1,19 @@ +#!/usr/bin/with-contenv bashio +# shellcheck shell=bash +set -euo pipefail + +vpn_openvpn=false +vpn_wireguard=false + +if bashio::config.true 'openvpn_enabled'; then + vpn_openvpn=true +fi + +if bashio::config.true 'wireguard_enabled'; then + vpn_wireguard=true +fi + +if [[ "${vpn_openvpn}" != true && "${vpn_wireguard}" != true ]]; then + # No VPN enabled: remove monitoring service to avoid unnecessary restarts + rm -rf /etc/services.d/vpn-monitor +fi diff --git a/qbittorrent/rootfs/etc/s6-overlay/s6-rc.d/svc-qbittorrent/run b/qbittorrent/rootfs/etc/s6-overlay/s6-rc.d/svc-qbittorrent/run index d63b76fa6..72cb96561 100644 --- a/qbittorrent/rootfs/etc/s6-overlay/s6-rc.d/svc-qbittorrent/run +++ b/qbittorrent/rootfs/etc/s6-overlay/s6-rc.d/svc-qbittorrent/run @@ -39,88 +39,6 @@ _fetch_public_ip() { return 1 } -_fetch_country_code() { - local resp - local url - local urls=( - "https://ipapi.co/country/" - "http://ip-api.com/line/?fields=countryCode" - "https://ipinfo.io/country" - ) - local shuffled_urls_output - shuffled_urls_output=$(printf '%s\n' "${urls[@]}" | shuf) - while IFS= read -r url; do - # Skip empty lines if any - [[ -z "${url}" ]] && continue - # Fetch the response with a 5-second timeout - resp=$(curl -fsS --max-time 5 "${url}" 2>/dev/null || true) - # Clean whitespace/newlines - resp="${resp//[[:space:]]/}" - # Validation: Ensure the response is exactly 2 letters (ISO 3166-1 alpha-2) - if [[ "${resp}" =~ ^[A-Za-z]{2}$ ]]; then - # Convert to uppercase and print - printf '%s\n' "${resp^^}" - return 0 - fi - done <<< "${shuffled_urls_output}" # Process the shuffled output - return 1 -} - -_vpn_monitor_public_ip() { - local vpn_label="${1:-VPN}" - local current_ip_file="/currentip" - local baseline_ip vpn_ip country - local interval=${VPN_LEAK_CHECK_INTERVAL:-300} - local initial_delay=${VPN_LEAK_INITIAL_DELAY:-30} - - # Pre-flight checks - if ! command -v curl >/dev/null 2>&1; then - bashio::log.warning "${vpn_label}: curl not found; VPN leak monitoring disabled." - return 0 - fi - - if [[ ! -s "${current_ip_file}" ]]; then - bashio::log.warning "${vpn_label}: public ip could not be reached; VPN leak monitoring disabled." - return 0 - fi - - if ! bashio::fs.file_exists "${current_ip_file}"; then - bashio::log.warning "${vpn_label}: baseline IP file ${current_ip_file} not found; VPN leak monitoring disabled." - return 0 - fi - - baseline_ip="$(tr -d '[:space:]' < "${current_ip_file}")" - if [[ -z "${baseline_ip}" ]]; then - bashio::log.warning "${vpn_label}: baseline IP in ${current_ip_file} is empty; VPN leak monitoring disabled." - return 0 - fi - - bashio::log.debug "${vpn_label}: Waiting ${initial_delay}s before first leak check." - sleep "${initial_delay}" - - while true; do - vpn_ip="$(_fetch_public_ip || true)" - - if [[ -z "${vpn_ip}" ]]; then - bashio::log.warning "${vpn_label}: Failed to fetch public IP (rate limited or connection down)." - else - if country="$(_fetch_country_code || true)"; then - bashio::log.info "${vpn_label}: Current IP: ${vpn_ip} (${country})" - else - bashio::log.info "${vpn_label}: Current IP: ${vpn_ip} (Country Unknown)" - fi - - # LEAK DETECTED - if [[ "${vpn_ip}" == "${baseline_ip}" ]]; then - bashio::log.fatal "${vpn_label}: VPN LEAK DETECTED! Current IP ${vpn_ip} matches baseline. Stopping container." - bashio::addon.stop - exit 1 - fi - fi - sleep "${interval}" - done -} - # --- WireGuard Specific Logic --- _setup_wireguard() { @@ -231,11 +149,25 @@ _setup_wireguard() { # --- Main Execution --- -echo "$(_fetch_public_ip || true)" > /currentip +openvpn_enabled=false +wireguard_enabled=false if bashio::config.true 'openvpn_enabled'; then - # Start Leak Monitor - _vpn_monitor_public_ip "OpenVPN" & + openvpn_enabled=true +fi + +if bashio::config.true 'wireguard_enabled'; then + wireguard_enabled=true +fi + +if [[ "${openvpn_enabled}" == true && "${wireguard_enabled}" == true ]]; then + bashio::log.error "Both OpenVPN and WireGuard are enabled. Only one VPN mode is supported." + exit 1 +fi + +echo "$(_fetch_public_ip || true)" > /currentip + +if [[ "${openvpn_enabled}" == true ]]; then exec /usr/sbin/openvpn \ --config /config/openvpn/config.ovpn \ @@ -249,14 +181,11 @@ if bashio::config.true 'openvpn_enabled'; then --pull-filter ignore "dhcp-option DNS6" \ & -elif bashio::config.true 'wireguard_enabled'; then +elif [[ "${wireguard_enabled}" == true ]]; then # Run modularized WireGuard setup _setup_wireguard - # Start Leak Monitor - _vpn_monitor_public_ip "WireGuard" & - fi # --- Launch qBittorrent --- diff --git a/qbittorrent/rootfs/etc/services.d/nginx/run b/qbittorrent/rootfs/etc/services.d/nginx/run old mode 100644 new mode 100755 index 98f93abdb..96337b963 --- a/qbittorrent/rootfs/etc/services.d/nginx/run +++ b/qbittorrent/rootfs/etc/services.d/nginx/run @@ -1,235 +1,8 @@ #!/usr/bin/with-contenv bashio # shellcheck shell=bash - set -euo pipefail export PATH="/usr/local/sbin:/usr/local/bin:${PATH}" -# File where the real (non-VPN) public IP is stored -REAL_IP_FILE="/currentip" - -# How often to re-check VPN IP (seconds) -VPN_CHECK_INTERVAL="${VPN_CHECK_INTERVAL:-300}" - -# Service used to get country for a given IP. -# If it contains "%s", the IP will be formatted into it. -# Otherwise we assume an ipinfo-like base URL and call "${VPN_INFO_URL%/}/${ip}/json". -VPN_INFO_URL="${VPN_INFO_URL:-https://ipinfo.io}" - -# ------------------------------- -# Helpers -# ------------------------------- - -_fetch_public_ip() { - local resp - local url - local urls=( - "https://icanhazip.com" - "https://ifconfig.me/ip" - "https://api64.ipify.org" - "https://checkip.amazonaws.com" - "https://domains.google.com/checkip" - "https://ipinfo.io/ip" - ) - local shuffled_urls - mapfile -t shuffled_urls < <(printf "%s\n" "${urls[@]}" | shuf) - - for url in "${shuffled_urls[@]}"; do - resp=$(curl -fsS --max-time 5 "${url}" 2>/dev/null || true) - resp="${resp//[[:space:]]/}" - - # Validate IPv4 or IPv6 - if [[ "${resp}" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]] || [[ "${resp}" =~ ^[0-9a-fA-F:]+$ ]]; then - printf '%s\n' "${resp}" - return 0 - fi - done - - return 1 -} - -read_real_ip() { - # Reads the "real" IP saved before VPN start - if [[ -f "${REAL_IP_FILE}" ]]; then - local ip - ip="$(tr -d '[:space:]' < "${REAL_IP_FILE}")" - if [[ -n "${ip}" ]]; then - echo "${ip}" - return 0 - fi - fi - # No baseline is not fatal: just return empty - echo "" - return 0 -} - -get_ip_info() { - # Outputs: " " on success - local ip country json info_url url - - if ! ip="$(_fetch_public_ip)"; then - bashio::log.warning "Unable to determine external IP from fallback IP services." - return 1 - fi - - country="Unknown" - info_url="${VPN_INFO_URL:-}" - - if [[ -n "${info_url}" ]]; then - # Build URL used to get country for this IP - if [[ "${info_url}" == *"%s"* ]]; then - # Template style, e.g. "https://ipinfo.io/%s/json" - printf -v url "${info_url}" "${ip}" - else - # ipinfo-style base URL - url="${info_url%/}/${ip}/json" - fi - - if json="$(curl -fsS --max-time 10 "${url}" 2>/dev/null || true)" && [[ -n "${json}" ]]; then - country="$( - printf '%s\n' "${json}" \ - | sed -n 's/.*"country"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' \ - | head -n1 - )" - [[ -z "${country}" ]] && country="Unknown" - fi - fi - - printf '%s %s\n' "${ip}" "${country}" -} - -wait_for_vpn_ip() { - # Waits until: - # - we can read an external IP, AND - # - it is different from REAL_IP (if REAL_IP is known) - # Outputs " " when ready. - local attempt out ip country - - for attempt in {1..5}; do - if out="$(get_ip_info)"; then - read -r ip country <<< "${out}" - - if [[ -n "${REAL_IP:-}" ]] && [[ "${ip}" == "${REAL_IP}" ]]; then - bashio::log.warning \ - "External IP still equals real IP (${ip}); VPN not ready yet (attempt ${attempt}/5)." - else - printf '%s %s\n' "${ip}" "${country}" - return 0 - fi - else - bashio::log.warning "Unable to query external IP (attempt ${attempt}/5)." - fi - - sleep 5 - done - - return 1 -} - -start_nginx_background() { - bashio::log.info "Starting nginx..." - nginx & - echo $! -} - -# ------------------------------- -# Main logic -# ------------------------------- - -# Detect VPN mode(s) -vpn_openvpn=false -vpn_wireguard=false - -if bashio::config.true 'openvpn_enabled'; then - vpn_openvpn=true -fi - -if bashio::config.true 'wireguard_enabled'; then - vpn_wireguard=true -fi - -# If both are enabled, that's a configuration error -if [[ "${vpn_openvpn}" == true && "${vpn_wireguard}" == true ]]; then - bashio::log.error "Both OpenVPN and WireGuard are enabled. Only one VPN mode is supported." - exit 1 -fi - -vpn_enabled=false -vpn_mode="none" - -if [[ "${vpn_openvpn}" == true ]]; then - vpn_enabled=true - vpn_mode="OpenVPN" -fi - -if [[ "${vpn_wireguard}" == true ]]; then - vpn_enabled=true - vpn_mode="WireGuard" -fi - -if [[ "${vpn_enabled}" != true ]]; then - # No VPN: just boot nginx, no IP leak monitoring - bashio::log.info "No VPN enabled (OpenVPN/WireGuard). Starting nginx without IP monitoring." - exec nginx -fi - -# Read the baseline "real" IP from /currentip -REAL_IP="$(read_real_ip)" - -if [[ -n "${REAL_IP}" ]]; then - bashio::log.info "Real (non-VPN) IP from ${REAL_IP_FILE}: ${REAL_IP}" -else - bashio::log.warning "Real IP file ${REAL_IP_FILE} missing or empty; IP leak detection will be less strict." -fi - -bashio::log.info "VPN mode detected: ${vpn_mode}. Enabling IP leak protection and periodic monitoring." - -# Wait until VPN is up and external IP != REAL_IP -if ! VPN_INFO_OUT="$(wait_for_vpn_ip)"; then - bashio::log.error "Unable to obtain a VPN external IP different from real IP. Exiting to avoid leaking traffic." - bashio::addon.stop -fi - -read -r VPN_IP VPN_COUNTRY <<< "${VPN_INFO_OUT}" - -bashio::log.info "VPN external IP: ${VPN_IP} (${VPN_COUNTRY})" - -# Start nginx in background and monitor -nginx_pid="$(start_nginx_background)" - -# Forward termination signals -trap ' - bashio::log.info "Signal received, stopping nginx..." - kill "'"${nginx_pid}"'" 2>/dev/null || true - wait "'"${nginx_pid}"'" 2>/dev/null || true - exit 0 -' SIGTERM SIGINT SIGHUP - -# Monitoring loop -while true; do - # If nginx died, stop this service and let s6 handle restart policy - if ! kill -0 "${nginx_pid}" 2>/dev/null; then - bashio::log.error "nginx process exited unexpectedly; leaving service." - exit 1 - fi - - sleep "${VPN_CHECK_INTERVAL}" - - if ! current_out="$(get_ip_info)"; then - bashio::log.warning "Failed to refresh external IP; keeping previous assumptions." - continue - fi - - read -r current_ip current_country <<< "${current_out}" - - bashio::log.info "Current external IP: ${current_ip} (${current_country})" - - # If we know the real IP, treat equality as a leak - if [[ -n "${REAL_IP}" ]] && [[ "${current_ip}" == "${REAL_IP}" ]]; then - bashio::log.error "IP LEAK DETECTED: current external IP ${current_ip} matches real IP ${REAL_IP}." - bashio::log.error "Stopping nginx and exiting so the supervisor can restart the add-on." - kill "${nginx_pid}" 2>/dev/null || true - wait "${nginx_pid}" 2>/dev/null || true - exit 1 - fi -done +bashio::log.info "Starting nginx..." +exec nginx diff --git a/qbittorrent/rootfs/etc/services.d/vpn-monitor/run b/qbittorrent/rootfs/etc/services.d/vpn-monitor/run new file mode 100755 index 000000000..d1a9c68c8 --- /dev/null +++ b/qbittorrent/rootfs/etc/services.d/vpn-monitor/run @@ -0,0 +1,184 @@ +#!/usr/bin/with-contenv bashio +# shellcheck shell=bash +set -euo pipefail + +export PATH="/usr/local/sbin:/usr/local/bin:${PATH}" + +REAL_IP_FILE="/currentip" +VPN_CHECK_INTERVAL="${VPN_CHECK_INTERVAL:-300}" +VPN_INFO_URL="${VPN_INFO_URL:-https://ipinfo.io}" + +# ------------------------------- +# Helpers +# ------------------------------- + +_fetch_public_ip() { + local resp + local url + local urls=( + "https://icanhazip.com" + "https://ifconfig.me/ip" + "https://api64.ipify.org" + "https://checkip.amazonaws.com" + "https://domains.google.com/checkip" + "https://ipinfo.io/ip" + ) + local shuffled_urls + mapfile -t shuffled_urls < <(printf "%s\n" "${urls[@]}" | shuf) + + for url in "${shuffled_urls[@]}"; do + resp=$(curl -fsS --max-time 5 "${url}" 2>/dev/null || true) + resp="${resp//[[:space:]]/}" + + if [[ "${resp}" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]] || [[ "${resp}" =~ ^[0-9a-fA-F:]+$ ]]; then + printf '%s\n' "${resp}" + return 0 + fi + done + + return 1 +} + +read_real_ip() { + local attempt ip + + for attempt in {1..6}; do + if [[ -f "${REAL_IP_FILE}" ]]; then + ip="$(tr -d '[:space:]' < "${REAL_IP_FILE}")" + if [[ -n "${ip}" ]]; then + echo "${ip}" + return 0 + fi + fi + sleep 5 + done + + echo "" + return 0 +} + +get_ip_info() { + local ip country json info_url url + + if ! ip="$(_fetch_public_ip)"; then + bashio::log.warning "Unable to determine external IP from fallback IP services." + return 1 + fi + + country="Unknown" + info_url="${VPN_INFO_URL:-}" + + if [[ -n "${info_url}" ]]; then + if [[ "${info_url}" == *"%s"* ]]; then + printf -v url "${info_url}" "${ip}" + else + url="${info_url%/}/${ip}/json" + fi + + if json="$(curl -fsS --max-time 10 "${url}" 2>/dev/null || true)" && [[ -n "${json}" ]]; then + country="$( + printf '%s\n' "${json}" \ + | sed -n 's/.*"country"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' \ + | head -n1 + )" + [[ -z "${country}" ]] && country="Unknown" + fi + fi + + printf '%s %s\n' "${ip}" "${country}" +} + +wait_for_vpn_ip() { + local attempt out ip country + + for attempt in {1..5}; do + if out="$(get_ip_info)"; then + read -r ip country <<< "${out}" + + if [[ -n "${REAL_IP:-}" ]] && [[ "${ip}" == "${REAL_IP}" ]]; then + bashio::log.warning \ + "External IP still equals real IP (${ip}); VPN not ready yet (attempt ${attempt}/5)." + else + printf '%s %s\n' "${ip}" "${country}" + return 0 + fi + else + bashio::log.warning "Unable to query external IP (attempt ${attempt}/5)." + fi + + sleep 5 + done + + return 1 +} + +# ------------------------------- +# Main logic +# ------------------------------- + +vpn_openvpn=false +vpn_wireguard=false + +if bashio::config.true 'openvpn_enabled'; then + vpn_openvpn=true +fi + +if bashio::config.true 'wireguard_enabled'; then + vpn_wireguard=true +fi + +if [[ "${vpn_openvpn}" != true && "${vpn_wireguard}" != true ]]; then + bashio::log.info "VPN leak monitor not started because no VPN is enabled." + exit 0 +fi + +if [[ "${vpn_openvpn}" == true && "${vpn_wireguard}" == true ]]; then + bashio::log.error "Both OpenVPN and WireGuard are enabled. Only one VPN mode is supported." + bashio::addon.stop + exit 1 +fi + +REAL_IP="$(read_real_ip)" + +if [[ -n "${REAL_IP}" ]]; then + bashio::log.info "Real (non-VPN) IP from ${REAL_IP_FILE}: ${REAL_IP}" +else + bashio::log.warning "Real IP file ${REAL_IP_FILE} missing or empty; IP leak detection will be less strict." +fi + +bashio::log.info "VPN detected; enabling IP leak protection and periodic monitoring." + +if ! VPN_INFO_OUT="$(wait_for_vpn_ip)"; then + bashio::log.error "Unable to obtain a VPN external IP different from real IP. Stopping add-on." + bashio::addon.stop + exit 1 +fi + +read -r VPN_IP VPN_COUNTRY <<< "${VPN_INFO_OUT}" + +bashio::log.info "VPN external IP: ${VPN_IP} (${VPN_COUNTRY})" + +while true; do + sleep "${VPN_CHECK_INTERVAL}" + + if ! current_out="$(get_ip_info)"; then + bashio::log.warning "Failed to refresh external IP; keeping previous assumptions." + continue + fi + + read -r current_ip current_country <<< "${current_out}" + + bashio::log.info "Current external IP: ${current_ip} (${current_country})" + + if [[ -n "${REAL_IP}" ]] && [[ "${current_ip}" == "${REAL_IP}" ]]; then + bashio::log.error "IP LEAK DETECTED: current external IP ${current_ip} matches real IP ${REAL_IP}. Stopping add-on." + bashio::addon.stop + exit 1 + fi + + if [[ -z "${REAL_IP}" ]] && [[ "${current_ip}" == "${VPN_IP}" ]]; then + # No baseline to compare; only report changes in IP for visibility + VPN_IP="${current_ip}" + fi + +done