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 b1014804c..d63b76fa6 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 @@ -10,7 +10,8 @@ if bashio::config.true 'silent'; then sed -i 's|/proc/1/fd/1 hassio;|off;|g' /etc/nginx/nginx.conf fi -# --- Helper for ip +# --- Helper Functions --- + _fetch_public_ip() { local resp local url @@ -24,20 +25,102 @@ _fetch_public_ip() { ) local shuffled_urls mapfile -t shuffled_urls < <(printf "%s\n" "${urls[@]}" | shuf) - + # Loop through the now-randomized list 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 } +_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() { @@ -139,7 +222,7 @@ _setup_wireguard() { fi bashio::log.info "WireGuard interface ${wireguard_interface} is up." - + # DNS Refresh if command -v resolvconf >/dev/null 2>&1; then resolvconf -u >/dev/null 2>&1 || bashio::log.warning 'resolvconf -u failed.' @@ -147,9 +230,13 @@ _setup_wireguard() { } # --- Main Execution --- + echo "$(_fetch_public_ip || true)" > /currentip if bashio::config.true 'openvpn_enabled'; then + # Start Leak Monitor + _vpn_monitor_public_ip "OpenVPN" & + exec /usr/sbin/openvpn \ --config /config/openvpn/config.ovpn \ --script-security 2 \ @@ -161,8 +248,15 @@ if bashio::config.true 'openvpn_enabled'; then --pull-filter ignore "redirect-gateway ipv6" \ --pull-filter ignore "dhcp-option DNS6" \ & -elif bashio::config.true 'wireguard_enabled'; then + +elif bashio::config.true 'wireguard_enabled'; 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 index 623775fc4..98f93abdb 100644 --- a/qbittorrent/rootfs/etc/services.d/nginx/run +++ b/qbittorrent/rootfs/etc/services.d/nginx/run @@ -1,11 +1,235 @@ #!/usr/bin/with-contenv bashio # shellcheck shell=bash -set -e -# ============================================================================== -# Wait for transmission to become available -bashio::net.wait_for 8080 localhost 900 +set -euo pipefail -bashio::log.info "Starting NGinx..." +export PATH="/usr/local/sbin:/usr/local/bin:${PATH}" -exec nginx +# 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 diff --git a/qbittorrent/rootfs/etc/services.d/vpn_guard/finish b/qbittorrent/rootfs/etc/services.d/vpn_guard/finish deleted file mode 100644 index 444240135..000000000 --- a/qbittorrent/rootfs/etc/services.d/vpn_guard/finish +++ /dev/null @@ -1,8 +0,0 @@ -#!/usr/bin/execlineb -S0 -# ============================================================================== -# Take down the S6 supervision tree when Nginx fails -# ============================================================================== -if { s6-test ${1} -ne 0 } -if { s6-test ${1} -ne 256 } - -s6-svscanctl -t /var/run/s6/services diff --git a/qbittorrent/rootfs/etc/services.d/vpn_guard/run b/qbittorrent/rootfs/etc/services.d/vpn_guard/run deleted file mode 100644 index 424d3c9d0..000000000 --- a/qbittorrent/rootfs/etc/services.d/vpn_guard/run +++ /dev/null @@ -1,212 +0,0 @@ -#!/usr/bin/with-contenv bashio -# shellcheck shell=bash - -set -euo pipefail - -export PATH="/usr/local/sbin:/usr/local/bin:${PATH}" - -REAL_IP_FILE="/currentip" -READY_FLAG="/run/nginx.ready" -CHECK_INTERVAL="${VPN_CHECK_INTERVAL:-${VPN_LEAK_CHECK_INTERVAL:-300}}" -INITIAL_DELAY="${VPN_LEAK_INITIAL_DELAY:-30}" -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() { - 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 - - 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 -} - -allow_nginx_start() { - mkdir -p "$(dirname "${READY_FLAG}")" - touch "${READY_FLAG}" -} - -# ------------------------------- -# 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.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 - bashio::log.info "No VPN enabled. Allowing nginx start without leak monitoring." - allow_nginx_start - exec tail -f /dev/null -fi - -REAL_IP="$(read_real_ip)" - -if [[ -z "${REAL_IP}" ]]; then - for attempt in {1..5}; do - sleep 2 - REAL_IP="$(read_real_ip)" - [[ -n "${REAL_IP}" ]] && break - done -fi - -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}. Waiting for VPN to become active." - -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 - exit 1 -fi - -read -r VPN_IP VPN_COUNTRY <<< "${VPN_INFO_OUT}" -bashio::log.info "VPN external IP: ${VPN_IP} (${VPN_COUNTRY})" - -allow_nginx_start - -if [[ "${INITIAL_DELAY}" -gt 0 ]]; then - bashio::log.debug "Initial leak-check delay: ${INITIAL_DELAY}s" - sleep "${INITIAL_DELAY}" -fi - -bashio::log.info "Starting VPN leak monitoring (interval: ${CHECK_INTERVAL}s)." - -trap 'exit 0' SIGTERM SIGINT SIGHUP - -while true; do - sleep "${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.fatal "IP LEAK DETECTED: current external IP ${current_ip} matches real IP ${REAL_IP}. Stopping add-on." - bashio::addon.stop - exit 1 - fi - -done