From 47a43c82b453a2b8f16babc51d5831ac84dde78f Mon Sep 17 00:00:00 2001 From: litinoveweedle <15144712+litinoveweedle@users.noreply.github.com> Date: Sun, 25 Jan 2026 09:21:49 +0100 Subject: [PATCH] initial VPN logic implementation both Wireguard and OpenVPN are now handled by single service file This remove dependency to external tools Simplify vpn routing by using dedicated routing table which is used by qbittorrent torrent client listenning directly on the vpn interface. To prevent DNS leeks traffic to addon configured DNS servers is forced to use same dedicated routing table --- qbittorrent/config.yaml | 4 +- .../s6-overlay/s6-rc.d/svc-qbittorrent/run | 138 +------ .../rootfs/etc/services.d/vpn-monitor/run | 17 +- qbittorrent/rootfs/usr/local/bin/resolvconf | 86 ----- .../rootfs/usr/local/sbin/ip6tables-restore | 75 ---- .../rootfs/usr/local/sbin/iptables-restore | 54 --- qbittorrent/rootfs/usr/local/sbin/sysctl | 17 - qbittorrent/rootfs/usr/local/sbin/vpn | 363 ++++++++++++++++++ 8 files changed, 377 insertions(+), 377 deletions(-) delete mode 100644 qbittorrent/rootfs/usr/local/bin/resolvconf delete mode 100644 qbittorrent/rootfs/usr/local/sbin/ip6tables-restore delete mode 100644 qbittorrent/rootfs/usr/local/sbin/iptables-restore delete mode 100644 qbittorrent/rootfs/usr/local/sbin/sysctl create mode 100644 qbittorrent/rootfs/usr/local/sbin/vpn diff --git a/qbittorrent/config.yaml b/qbittorrent/config.yaml index 432146422..9bbae572b 100644 --- a/qbittorrent/config.yaml +++ b/qbittorrent/config.yaml @@ -130,7 +130,7 @@ schema: localdisks: str? networkdisks: str? openvpn_alt_mode: bool? - openvpn_config: str? + openvpn_config: match(^\w+\.conf$)? openvpn_enabled: bool? openvpn_password: str? openvpn_username: str? @@ -138,7 +138,7 @@ schema: run_duration: str? silent: bool? ssl: bool - wireguard_config: str? + wireguard_config: match(^\w+\.conf$)? wireguard_enabled: bool? whitelist: str? slug: qbittorrent 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 b0f1a99cf..ec0e73b49 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,125 +10,6 @@ if bashio::config.true 'silent'; then sed -i 's|/proc/1/fd/1 hassio;|off;|g' /etc/nginx/nginx.conf fi -# --- WireGuard Specific Logic --- - -_setup_wireguard() { - local WIREGUARD_STATE_DIR="/var/run/wireguard" - local output="" - local status=0 - - if ! bashio::fs.file_exists "${WIREGUARD_STATE_DIR}/config"; then - bashio::exit.nok 'WireGuard runtime configuration not prepared. Please restart the add-on.' - fi - - local wireguard_config - wireguard_config="$(cat "${WIREGUARD_STATE_DIR}/config")" - local wireguard_interface - wireguard_interface="$(cat "${WIREGUARD_STATE_DIR}/interface" 2>/dev/null || echo 'wg0')" - - if ip link show "${wireguard_interface}" >/dev/null 2>&1; then - bashio::log.warning "WireGuard interface ${wireguard_interface} already exists. Resetting." - wg-quick down "${wireguard_config}" >/dev/null 2>&1 || true - fi - - bashio::log.info "Starting WireGuard interface ${wireguard_interface}..." - - # Internal helper: fallback for iptables-legacy - _wg_prepare_legacy() { - local legacy_bin_dir="${WIREGUARD_STATE_DIR}/iptables-legacy-bin" - mkdir -p "${legacy_bin_dir}" - local cmd - for cmd in iptables iptables-save iptables-restore ip6tables ip6tables-save ip6tables-restore; do - if command -v "${cmd}-legacy" >/dev/null 2>&1; then - ln -sf "$(command -v "${cmd}-legacy")" "${legacy_bin_dir}/${cmd}" - fi - done - chmod 700 "${legacy_bin_dir}" 2>/dev/null || true - export PATH="${legacy_bin_dir}:${PATH}" - bashio::log.warning 'Retrying WireGuard using iptables-legacy wrappers.' - } - - # Internal helper: Attempt connection - _wg_up_attempt() { - local config_path="$1" - output="$(wg-quick up "${config_path}" 2>&1)" || status=$? - - if [ "${status}" -eq 0 ]; then return 0; fi - - # Allow sysctl failures on read-only hosts while keeping the interface up - if echo "${output}" | grep -qi 'net\.ipv4\.conf\.all\.src_valid_mark=1'; then - if echo "${output}" | grep -qiE 'read-only file system|operation not permitted'; then - if ip link show "${wireguard_interface}" >/dev/null 2>&1; then - bashio::log.warning 'WireGuard applied but sysctl net.ipv4.conf.all.src_valid_mark=1 could not be set (read-only). Continuing.' - status=0 - return 0 - fi - fi - fi - - # Check for iptables errors and try legacy fallback - if echo "${output}" | grep -qiE 'iptables-restore|ip6tables-restore|xtables'; then - if command -v iptables-legacy >/dev/null 2>&1; then - wg-quick down "${config_path}" >/dev/null 2>&1 || true - _wg_prepare_legacy - output="$(wg-quick up "${config_path}" 2>&1)" || status=$? - else - bashio::log.warning 'iptables errors detected but iptables-legacy missing.' - status=1 - fi - fi - return "${status}" - } - - # 1. First Attempt - if ! _wg_up_attempt "${wireguard_config}"; then - bashio::log.warning 'Initial WireGuard connection failed. Trying IPv4-only endpoints.' - bashio::log.debug "Output: ${output}" - - # 2. IPv4 Fallback Preparation - local ipv4_config="${WIREGUARD_STATE_DIR}/${wireguard_interface}-ipv4.conf" - : > "${ipv4_config}" - chmod 600 "${ipv4_config}" 2>/dev/null || true - - local line endpoint endpoint_host endpoint_port - while IFS= read -r line || [ -n "$line" ]; do - if [[ "${line}" =~ ^Endpoint ]]; then - endpoint="${line#Endpoint = }" - endpoint_host="${endpoint%:*}" - endpoint_port="${endpoint##*:}" - - # Resolve hostname to IPv4 - mapfile -t ipv4_candidates < <(getent ahostsv4 "${endpoint_host}" | awk '{print $1}' | uniq) - - if [ ${#ipv4_candidates[@]} -gt 0 ]; then - bashio::log.debug "Resolved ${endpoint_host} to ${ipv4_candidates[0]}" - echo "Endpoint = ${ipv4_candidates[0]}:${endpoint_port}" >> "${ipv4_config}" - else - echo "${line}" >> "${ipv4_config}" - fi - else - echo "${line}" >> "${ipv4_config}" - fi - done < "${wireguard_config}" - - wg-quick down "${wireguard_config}" >/dev/null 2>&1 || true - - # 3. Second Attempt (IPv4 only) - if ! _wg_up_attempt "${ipv4_config}"; then - bashio::log.error 'WireGuard failed to establish connection.' - bashio::log.error "${output}" - bashio::exit.nok 'WireGuard start failed.' - fi - 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.' - fi -} - # --- Main Execution --- openvpn_enabled=false @@ -148,24 +29,9 @@ if [[ "${openvpn_enabled}" == true && "${wireguard_enabled}" == true ]]; then fi if [[ "${openvpn_enabled}" == true ]]; then - - exec /usr/sbin/openvpn \ - --config /config/openvpn/config.ovpn \ - --script-security 2 \ - --up /etc/openvpn/up.sh \ - --down /etc/openvpn/down.sh \ - --pull-filter ignore "route-ipv6" \ - --pull-filter ignore "ifconfig-ipv6" \ - --pull-filter ignore "tun-ipv6" \ - --pull-filter ignore "redirect-gateway ipv6" \ - --pull-filter ignore "dhcp-option DNS6" \ - & - + /usr/local/sbin/vpn openvpn up elif [[ "${wireguard_enabled}" == true ]]; then - - # Run modularized WireGuard setup - _setup_wireguard - + /usr/local/sbin/vpn wireguard up fi # --- Launch qBittorrent --- diff --git a/qbittorrent/rootfs/etc/services.d/vpn-monitor/run b/qbittorrent/rootfs/etc/services.d/vpn-monitor/run index 5117c5fa3..4ed9f4812 100755 --- a/qbittorrent/rootfs/etc/services.d/vpn-monitor/run +++ b/qbittorrent/rootfs/etc/services.d/vpn-monitor/run @@ -129,13 +129,6 @@ if bashio::config.true 'openvpn_enabled'; then vpn_openvpn=true fi -if [[ "${vpn_openvpn}" == true ]] && ! bashio::config.true 'openvpn_alt_mode'; then - VPN_INTERFACE="tun0" - bashio::log.info "VPN monitor set to query external IP through interface ${VPN_INTERFACE} (interface binding)." -else - VPN_INTERFACE="" -fi - if bashio::config.true 'wireguard_enabled'; then vpn_wireguard=true fi @@ -151,6 +144,16 @@ if [[ "${vpn_openvpn}" == true && "${vpn_wireguard}" == true ]]; then exit 1 fi +if [[ "${vpn_openvpn}" == true ]] && ! bashio::config.true 'openvpn_alt_mode'; then + VPN_INTERFACE=$(cat "/var/run/openvpn/interface") + bashio::log.info "VPN monitor set to query external IP through interface ${VPN_INTERFACE} (interface binding)." +elif [[ "${vpn_wireguard}" == true ]]; then + VPN_INTERFACE=$(cat "/var/run/wireguard/interface") + bashio::log.info "VPN monitor set to query external IP through interface ${VPN_INTERFACE} (interface binding)." +else + VPN_INTERFACE="" +fi + REAL_IP="$(read_real_ip)" if [[ -n "${REAL_IP}" ]]; then diff --git a/qbittorrent/rootfs/usr/local/bin/resolvconf b/qbittorrent/rootfs/usr/local/bin/resolvconf deleted file mode 100644 index 3e2a91880..000000000 --- a/qbittorrent/rootfs/usr/local/bin/resolvconf +++ /dev/null @@ -1,86 +0,0 @@ -#!/bin/sh -set -eu - -STATE_DIR="/var/run/wireguard/resolvconf" -BACKUP_FILE="${STATE_DIR}/resolv.conf.backup" - -mkdir -p "${STATE_DIR}" - -if [ "$#" -eq 0 ]; then - exit 0 -fi - -command="$1" -shift || true - -restore_backup() { - if [ -f "${BACKUP_FILE}" ]; then - cat "${BACKUP_FILE}" > /etc/resolv.conf - fi -} - -apply_dns() { - iface="$1" - shift || true - - # Skip optional arguments such as -m or -x - while [ "$#" -gt 0 ]; do - case "$1" in - -m|-p|-w) - shift 2 || true - ;; - -x|-y|-Z) - shift 1 || true - ;; - --) - shift - break - ;; - *) - break - ;; - esac - done - - tmp_file="${STATE_DIR}/${iface}.conf" - cat > "${tmp_file}" - - if [ ! -f "${BACKUP_FILE}" ]; then - cp /etc/resolv.conf "${BACKUP_FILE}" 2>/dev/null || true - fi - - { - echo "# Generated by WireGuard add-on resolvconf shim" - cat "${tmp_file}" - } > /etc/resolv.conf -} - -case "${command}" in - -a) - if [ "$#" -eq 0 ]; then - exit 0 - fi - apply_dns "$@" - ;; - -d) - if [ "$#" -gt 0 ]; then - rm -f "${STATE_DIR}/$1.conf" - fi - restore_backup - ;; - -u) - latest_conf="$(find "${STATE_DIR}" -maxdepth 1 -type f -name '*.conf' -print | head -n 1 || true)" - if [ -n "${latest_conf}" ] && [ -f "${latest_conf}" ]; then - { - echo "# Generated by WireGuard add-on resolvconf shim" - cat "${latest_conf}" - } > /etc/resolv.conf - else - restore_backup - fi - ;; - *) - # Treat other commands as successful no-ops to remain compatible with wg-quick. - exit 0 - ;; -esac diff --git a/qbittorrent/rootfs/usr/local/sbin/ip6tables-restore b/qbittorrent/rootfs/usr/local/sbin/ip6tables-restore deleted file mode 100644 index 23bff73d3..000000000 --- a/qbittorrent/rootfs/usr/local/sbin/ip6tables-restore +++ /dev/null @@ -1,75 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -REAL_IP6TABLES_RESTORE="/sbin/ip6tables-restore" -if [[ ! -x "${REAL_IP6TABLES_RESTORE}" ]]; then - REAL_IP6TABLES_RESTORE="/usr/sbin/ip6tables-restore" -fi - -cleanup() { - local exit_code=$? - [[ -n "${RULES_FILE:-}" && -f "${RULES_FILE}" ]] && rm -f "${RULES_FILE}" - [[ -n "${SANITIZED_FILE:-}" && -f "${SANITIZED_FILE}" ]] && rm -f "${SANITIZED_FILE}" - return $exit_code -} -trap cleanup EXIT - -RULES_FILE="$(mktemp)" -cat > "${RULES_FILE}" - -ipv6_unavailable() { - local message="$1" - [[ $message =~ [Tt]able[[:space:]]does[[:space:]]not[[:space:]]exist ]] && return 0 - [[ $message =~ address[[:space:]]family[[:space:]]not[[:space:]]supported ]] && return 0 - [[ $message =~ can[[:punct:]]t[[:space:]]initialize[[:space:]]ip6tables[[:space:]]table ]] && return 0 - [[ $message =~ IPv6[[:space:]]support[[:space:]]not[[:space:]]available ]] && return 0 - return 1 -} - -# First attempt with the original ruleset -output="" -if output="$(${REAL_IP6TABLES_RESTORE} "$@" < "${RULES_FILE}" 2>&1)"; then - [[ -n "${output}" ]] && printf '%s\n' "${output}" >&2 - exit 0 -fi -status=$? - -# Retry without comment matches if the kernel is missing the comment module -SANITIZED_FILE="$(mktemp)" -sed -E 's/-m[[:space:]]+comment[[:space:]]+--comment[[:space:]]+"[^"]*"//g' "${RULES_FILE}" > "${SANITIZED_FILE}" - -retry_output="" -if retry_output="$(${REAL_IP6TABLES_RESTORE} "$@" < "${SANITIZED_FILE}" 2>&1)"; then - printf '%s\n' "ip6tables-restore failed with comment matches; reapplied without comments." >&2 - printf '%s\n' "Original error: ${output}" >&2 - [[ -n "${retry_output}" ]] && printf '%s\n' "${retry_output}" >&2 - exit 0 -fi -retry_status=$? - -# Final fallback: try legacy backend if available -legacy_output="" -for legacy in /sbin/ip6tables-restore-legacy /usr/sbin/ip6tables-restore-legacy; do - if [[ -x "${legacy}" ]]; then - if legacy_output="$(${legacy} "$@" < "${RULES_FILE}" 2>&1)"; then - printf '%s\n' "ip6tables-restore failed; succeeded using legacy backend." >&2 - printf '%s\n' "Original error: ${output}" >&2 - [[ -n "${legacy_output}" ]] && printf '%s\n' "${legacy_output}" >&2 - exit 0 - fi - fi - -done - -if ipv6_unavailable "${output}" || ipv6_unavailable "${retry_output}" || ipv6_unavailable "${legacy_output}"; then - printf '%s\n' "IPv6 firewall support not detected; skipping IPv6 ruleset restore and continuing." >&2 - printf '%s\n' "Original error: ${output}" >&2 - [[ -n "${retry_output}" ]] && printf '%s\n' "Sanitized retry error: ${retry_output}" >&2 - [[ -n "${legacy_output}" ]] && printf '%s\n' "Legacy backend error: ${legacy_output}" >&2 - exit 0 -fi - -printf '%s\n' "ip6tables-restore failed and fallbacks were unsuccessful." >&2 -printf '%s\n' "Original error: ${output}" >&2 -printf '%s\n' "Sanitized retry error: ${retry_output}" >&2 -exit ${retry_status} diff --git a/qbittorrent/rootfs/usr/local/sbin/iptables-restore b/qbittorrent/rootfs/usr/local/sbin/iptables-restore deleted file mode 100644 index 2219b563c..000000000 --- a/qbittorrent/rootfs/usr/local/sbin/iptables-restore +++ /dev/null @@ -1,54 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -REAL_IPTABLES_RESTORE="/sbin/iptables-restore" -if [[ ! -x "${REAL_IPTABLES_RESTORE}" ]]; then - REAL_IPTABLES_RESTORE="/usr/sbin/iptables-restore" -fi - -cleanup() { - local exit_code=$? - [[ -n "${RULES_FILE:-}" && -f "${RULES_FILE}" ]] && rm -f "${RULES_FILE}" - [[ -n "${SANITIZED_FILE:-}" && -f "${SANITIZED_FILE}" ]] && rm -f "${SANITIZED_FILE}" - return $exit_code -} -trap cleanup EXIT - -RULES_FILE="$(mktemp)" -cat > "${RULES_FILE}" - -# First attempt with the original ruleset -if output="$(${REAL_IPTABLES_RESTORE} "$@" < "${RULES_FILE}" 2>&1)"; then - [[ -n "${output}" ]] && printf '%s\n' "${output}" >&2 - exit 0 -fi -status=$? - -# Retry without comment matches if the kernel is missing the comment module -SANITIZED_FILE="$(mktemp)" -sed -E 's/-m[[:space:]]+comment[[:space:]]+--comment[[:space:]]+"[^"]*"//g' "${RULES_FILE}" > "${SANITIZED_FILE}" - -if retry_output="$(${REAL_IPTABLES_RESTORE} "$@" < "${SANITIZED_FILE}" 2>&1)"; then - printf '%s\n' "iptables-restore failed with comment matches; reapplied without comments." >&2 - printf '%s\n' "Original error: ${output}" >&2 - [[ -n "${retry_output}" ]] && printf '%s\n' "${retry_output}" >&2 - exit 0 -fi -retry_status=$? - -# Final fallback: try legacy backend if available -for legacy in /sbin/iptables-restore-legacy /usr/sbin/iptables-restore-legacy; do - if [[ -x "${legacy}" ]]; then - if legacy_output="$(${legacy} "$@" < "${RULES_FILE}" 2>&1)"; then - printf '%s\n' "iptables-restore failed; succeeded using legacy backend." >&2 - printf '%s\n' "Original error: ${output}" >&2 - [[ -n "${legacy_output}" ]] && printf '%s\n' "${legacy_output}" >&2 - exit 0 - fi - fi -done - -printf '%s\n' "iptables-restore failed and fallbacks were unsuccessful." >&2 -printf '%s\n' "Original error: ${output}" >&2 -printf '%s\n' "Sanitized retry error: ${retry_output}" >&2 -exit ${retry_status} diff --git a/qbittorrent/rootfs/usr/local/sbin/sysctl b/qbittorrent/rootfs/usr/local/sbin/sysctl deleted file mode 100644 index b76c18c88..000000000 --- a/qbittorrent/rootfs/usr/local/sbin/sysctl +++ /dev/null @@ -1,17 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -REAL_SYSCTL="/sbin/sysctl" -if [[ ! -x "${REAL_SYSCTL}" ]]; then - REAL_SYSCTL="/usr/sbin/sysctl" -fi - -if [[ "$#" -ge 2 && "$1" == "-q" && "$2" == "net.ipv4.conf.all.src_valid_mark=1" ]]; then - if "${REAL_SYSCTL}" "$@" >/dev/null 2>&1; then - exit 0 - fi - # Suppress failure for this specific key to keep wg-quick from aborting in unprivileged environments. - exit 0 -fi - -exec "${REAL_SYSCTL}" "$@" diff --git a/qbittorrent/rootfs/usr/local/sbin/vpn b/qbittorrent/rootfs/usr/local/sbin/vpn new file mode 100644 index 000000000..b3ac0f7fd --- /dev/null +++ b/qbittorrent/rootfs/usr/local/sbin/vpn @@ -0,0 +1,363 @@ +#!/usr/bin/with-contenv bashio +# shellcheck shell=bas + +# --- WireGuard Specific Logic --- + +declare -A config +config["MySelf"]="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/$(basename "${BASH_SOURCE[0]}")" +IFS=',' read -ra dns_servers <<< $(bashio::config 'DNS_server' | tr -d ' ') +config["DnsServers"]="${dns_servers[*]}" + +_parse_config() { + local -n config_ref="$1" + local config_file="$2" + + while IFS= read -r line || [[ -n "$line" ]]; do + # Skip comments and empty lines + [[ "$line" =~ ^[#!] ]] && continue + # Extract key and value using regex (trim spaces) + #if [[ "$line" =~ ^[[:space:]]*([^ =]+)[[:space:]]*=[[:space:]]*(.*)[[:space:]]* ]]; then + if [[ "$line" =~ ^[[:space:]]*([^=[:space:]]+)[=[:space:]]+(.*)[[:space:]]* ]]; then + key="${BASH_REMATCH[1]}" + value="${BASH_REMATCH[2]}" + config_ref["$key"]="$value" + fi + done < "$config_file" +} + +_cmd() { + cmd="$1" + bashio::log.info "Executing command: ${cmd}" + eval "${cmd}" +} + +_is_ip_address() { + if [ "$1" != "${1#*[0-9].[0-9]}" ]; then + return 1 # IPv4 + elif [ "$1" != "${1#*:[0-9a-fA-F]}" ]; then + return 2 # IPv6 + else + return 0 # Not an IP address + fi +} + +_resolvconf() { + local mode=$1 + local resolv_conf="/etc/resolv.conf" + local resolv_backup="/etc/resolv.conf.bak" + + if [ "${mode}" = "reset" ]; then + bashio::log.info "Resetting ${resolv_conf} to default DNS servers." + if bashio::fs.file_exists "${resolv_backup}"; then + cp "${resolv_backup}" "${resolv_conf}" + else + bashio::log.warning "No original resolv.conf backup found. Leaving as is." + fi + elif [ "${mode}" = "update" ]; then + bashio::log.info "Updating ${resolv_conf} with DNS servers: ${config["DnsServers"]}" + if ! bashio::fs.file_exists "${resolv_backup}"; then + cp "${resolv_conf}" "${resolv_backup}" 2>/dev/null || true + fi + { + local dns_ip + echo "# Generated by addon VPN script" + for dns_ip in ${config["DnsServers"]}; do + _is_ip_address "${dns_ip}" + local is_ip=$? + if [ "${is_ip}" -eq 0 ]; then + bashio::log.warning "Ignoring invalid DNS server address: ${dns_ip}" + continue + else + echo "nameserver ${dns_ip}" + fi + done + } > "${resolv_conf}" + fi +} + +_resolve_hostname() { + local hostname=$1 + local ips="" + + # Resolve hostname to IPv6 + mapfile -t ipv6_candidates < <(getent ahostsv6 "${hostname}" | awk '{print $1}' | uniq) + + # Resolve hostname to IPv4 + mapfile -t ipv4_candidates < <(getent ahostsv4 "${hostname}" | awk '{print $1}' | uniq) + + if [ ${#ipv6_candidates[@]} -gt 0 ]; then + bashio::log.debug "Resolved ${hostname} to ${ipv6_candidates[@]}" + ips=${ipv6_candidates[@]} + fi + + if [ ${#ipv4_candidates[@]} -gt 0 ]; then + bashio::log.debug "Resolved ${hostname} to ${ipv4_candidates[@]}" + ips="${ips} ${ipv4_candidates[@]}" + fi + + return $ips +} + +_routing_add() { + local local_ipv4=$(ip addr show ${config["Interface"]} | grep 'inet ' | awk '{print $2}' | cut -d'/' -f1) + local local_ipv6=$(ip addr show ${config["Interface"]} | grep 'inet6 ' | awk '{print $2}' | cut -d'/' -f1) + for ipv4 in ${local_ipv4}; do + config["IPv4Enabled"]="true" + _cmd "ip -4 route add default dev ${config["Interface"]} table ${config["Table"]}" || return 1 + _cmd "ip -4 rule add priority 1 from ${local_ip} table ${config["Table"]}" || return 1 + done + for ipv6 in ${local_ipv6}; do + config["IPv6Enabled"]="true" + _cmd "ip -6 route add default dev ${config["Interface"]} table ${config["Table"]}" || return 1 + _cmd "ip -6 rule add priority 1 from ${local_ip} table ${config["Table"]}" || return 1 + done + + local dns_ip + for dns_ip in ${config["DnsServers"]}; do + _is_ip_address "${dns_ip}" + local is_ip=$? + if [ "${is_ip}" -eq 0 ]; then + bashio::log.warning "Ignoring invalid DNS server address: ${dns_ip}" + continue + elif [ "${is_ip}" -eq 1 ] && [ ${config["IPv4Enabled"]} = "true" ]; then + #_cmd "ip -4 route add ${dns_ip} dev ${config["Interface"]}" || return 1 + _cmd "ip -4 rule add priority 1 to ${dns_ip} table ${config["Table"]}" || return 1 + elif [ "${is_ip}" -eq 2 ] && [ "${config["IPv6Enabled"]}" = "true" ]; then + #_cmd "ip -6 route add ${dns_ip} dev ${config["Interface"]}" || return 1 + _cmd "ip -6 rule add priority 1 to ${dns_ip} table ${config["Table"]}" || return 1 + else + bashio::log.warning "Failed to add route for DNS server: ${dns_ip}" + fi + done + _resolvconf "update" +} + +_routing_del() { + _resolvconf "reset" + while _cmd "ip -4 rule del priority 1 from all table ${config["Table"]} 2>/dev/null"; do :; done + while _cmd "ip -4 rule del priority 1 to all table ${config["Table"]} 2>/dev/null"; do :; done + while _cmd "ip -4 route del default dev ${config["Interface"]} table ${config["Table"]} 2>/dev/null"; do :; done + while _cmd "ip -6 rule del priority 1 from all table ${config["Table"]} 2>/dev/null"; do :; done + while _cmd "ip -6 rule del priority 1 to all table ${config["Table"]} 2>/dev/null"; do :; done + while _cmd "ip -6 route del default dev ${config["Interface"]} table ${config["Table"]} 2>/dev/null"; do :; done +} + +_wireguard_up() { + bashio::log.warn "Bringing up Wireguard interface on ${config["Interface"]}..." + bashio::log.warn "Using Wireguard configuration file: ${config["ConfigFile"]}" + bashio::log.warn "This script force Wireguard to ignore any routes and DNS settings." + bashio::log.warn "Default route will be inserted into custom routing table: ${config["Table"]}" + bashio::log.warn "This routing table will be used for traffic from the VPN interface and to configured DNS servers." + bashio::log.warn "Qbittorrent bittorrent client shall be set to use the VPN interface ${config["Interface"]} only." + + _cmd "ip link add dev ${config["Interface"]} type wireguard" || return 1 + local allowed_ips="" + for local_ip in ${config["Address"]}; do + _is_ip_address "${local_ip}" + local is_ip=$? + if [ "${is_ip}" -eq 1 ]; then + allowed_ips="${allowed_ips},0.0.0.0/0" + _cmd "ip addr add ${local_ip} dev ${config["Interface"]}" || return 1 + elif [ "${is_ip}" -eq 2 ]; then + allowed_ips="${allowed_ips},::/0" + _cmd "ip addr add ${local_ip} dev ${config["Interface"]}" || return 1 + else + bashio::log.warning "Ignoring invalid local IP address: ${local_ip}" + fi + done + allowed_ips="${allowed_ips#,}" + if [ -z "${allowed_ips}" ]; then + bashio::log.error "No valid local IP addresses configured." + return 1 + fi + _cmd "wg set ${config["Interface"]} listen-port ${config["ListenPort"]} private-key ${config["PrivateKey"]}" || return 1 + _cmd "wg set ${config["Interface"]} peer ${config["PublicKey"]} endpoint ${config["EndpointIP"]}:${config["EndpointPort"]} allowed-ips ${allowed_ips}" || return 1 + if [ -n "${config["PersistentKeepalive"]}" ]; then + _cmd "wg set ${config["Interface"]} peer ${config["PublicKey"]} persistent-keepalive ${config["PersistentKeepalive"]}" || return 1 + fi + _cmd "ip link set ${config["Interface"]} up" || return 1 + _routing_add +} + +_wireguard_down() { + _routing_del + _cmd "ip link set ${config["Interface"]} down" 2>/dev/null || true + _cmd "ip link del dev ${config["Interface"]}" 2>/dev/null || true +} + +wireguard() { + local mode=$1 + local interface + local config_file + local WIREGUARD_STATE_DIR="/var/run/wireguard" + + if ! bashio::fs.file_exists "${WIREGUARD_STATE_DIR}/interface"; then + bashio::exit.nok 'WireGuard runtime configuration not prepared. Please restart the add-on.' + fi + interface=$(cat "${WIREGUARD_STATE_DIR}/interface") + if [ -z "${interface}" ]; then + bashio::exit.nok 'WireGuard runtime configuration not prepared. Please restart the add-on.' + fi + if ! bashio::fs.file_exists "${WIREGUARD_STATE_DIR}/config"; then + bashio::exit.nok 'WireGuard runtime configuration not prepared. Please restart the add-on.' + fi + config_file=$(cat "${WIREGUARD_STATE_DIR}/config") + if [ -z "${config_file}" ]; then + bashio::exit.nok 'WireGuard runtime configuration not prepared. Please restart the add-on.' + fi + + _parse_config config "${config_file}" + config["Interface"]="${interface}" + config["ConfigFile"]="${config_file}" + config["Table"]="${config["Table"]:-1000}" + config["ListenPort"]="${config["ListenPort"]:-51820}" + config["EndpointHost"]="${config["Endpoint"]%:*}" + config["EndpointPort"]="${config["Endpoint"]##*:}" + config["IPv4Enabled"]="false" + config["IPv6Enabled"]="false" + for key in "${!config[@]}"; do + bashio::log.debug "${key}: ${config[$key]}" + done + + echo ${config["PrivateKey"]} > ${WIREGUARD_STATE_DIR}/privatekey + config["PrivateKey"]="${WIREGUARD_STATE_DIR}/privatekey" + + if [ "${mode}" = "up" ]; then + bashio::log.info "Starting WireGuard interface ${config["Interface"]}..." + if _is_ip_address ${config["EndpointHost"]}; then + local endpoint_ips=$(_resolve_hostname ${config["EndpointHost"]}) + if [ ${#endpoint_ips[@]} -eq 0 ]; then + bashio::log.error "Failed to resolve WireGuard endpoint hostname: ${config["EndpointHost"]}" + bashio::exit.nok 'WireGuard start failed.' + fi + for endpoint_ip in "${endpoint_ips[@]}"; do + bashio::log.info "Resolved WireGuard endpoint hostname ${config["EndpointHost"]} to IP: ${endpoint_ip}" + config["EndpointIP"]="${endpoint_ip}" + if _wireguard_up; then + bashio::log.info "WireGuard interface ${config["Interface"]} is up." + bashio::exit.ok 'WireGuard started.' + fi + bashio::log.error 'WireGuard failed to establish connection.' + _wireguard_down + done + else + bashio::log.info "WireGuard endpoint ${config["EndpointHost"]} is a valid IP address. Using as is." + config["EndpointIP"]="${config["EndpointHost"]}" + if _wireguard_up; then + bashio::log.info "WireGuard interface ${config["Interface"]} is up." + bashio::exit.ok 'WireGuard started.' + fi + bashio::log.error 'WireGuard failed to establish connection.' + _wireguard_down + fi + elif [ "${mode}" = "down" ]; then + bashio::log.info "Stopping WireGuard interface ${config["Interface"]}..." + _wireguard_down + bashio::log.info "WireGuard interface ${config["Interface"]} is down." + bashio::exit.ok 'WireGuard stopped.' + else + bashio::log.error "Invalid WireGuard mode specified. Use 'up' or 'down'." + bashio::exit.nok 'WireGuard start failed.' + fi + + bashio::exit.nok 'WireGuard start failed.' +} + +_openvpn_up() { + bashio::log.warn "Bringing up OpenVPN interface on ${config["Interface"]}..." + bashio::log.warn "Using OpenVPN configuration file: ${config["ConfigFile"]}" + bashio::log.warn "This script force OpenvPN to ignore any routes and DNS settings pushed by the server." + bashio::log.warn "Default route will be inserted into custom routing table: ${config["Table"]}" + bashio::log.warn "This routing table will be used for traffic from the VPN interface and to configured DNS servers." + bashio::log.warn "Qbittorrent bittorrent client shall be set to use the VPN interface ${config["Interface"]} only." + + # Register this script as OpenVPN up/down handlers to manage routing + echo "${config["MySelf"]} openvpn postup" > ${config["PostUpScript"]} + chmod 755 ${config["PostUpScript"]} + echo "${config["MySelf"]} openvpn postdown" > ${config["PostDownScript"]} + chmod 755 ${config["PostDownScript"]} + + # Start OpenVPN in the background + # (maybe use setsid instead of nohup to detach completely?) + nohup /usr/sbin/openvpn \ + --config "${config["ConfigFile"]}" \ + --script-security 2 \ + --up ${config["PostUpScript"]} \ + --down ${config["PostDownScript"]} \ + --route-nopull \ + --pull-filter ignore "route" \ + --pull-filter ignore "redirect-gateway" \ + --pull-filter ignore "dhcp-option DNS" \ + --pull-filter ignore "route-ipv6" \ + --pull-filter ignore "redirect-gateway ipv6" \ + --pull-filter ignore "dhcp-option DNS6" \ + & +} + +_openvpn_down() { + # Terminate OpenVPN process + pkill -f "openvpn --config ${config["ConfigFile"]}" || true + _routing_del +} + +openvpn() { + local mode=$1 + local interface + local config_file + local OPENVPN_STATE_DIR="/var/run/openvpn" + + if ! bashio::fs.file_exists "${OPENVPN_STATE_DIR}/interface"; then + bashio::exit.nok 'OpenVPN runtime configuration not prepared. Please restart the add-on.' + fi + interface=$(cat "${OPENVPN_STATE_DIR}/interface") + if [ -z "${interface}" ]; then + bashio::exit.nok 'OpenVPN runtime configuration not prepared. Please restart the add-on.' + fi + if ! bashio::fs.file_exists "${OPENVPN_STATE_DIR}/config"; then + bashio::exit.nok 'OpenVPN runtime configuration not prepared. Please restart the add-on.' + fi + config_file=$(cat "${OPENVPN_STATE_DIR}/config") + if [ -z "${config_file}" ]; then + bashio::exit.nok 'OpenVPN runtime configuration not prepared. Please restart the add-on.' + fi + + _parse_config config "${config_file}" + config["Interface"]="${interface}" + config["ConfigFile"]="${config_file}" + config["Table"]="${config["Table"]:-1000}" + config["PostUpScript"]="${OPENVPN_STATE_DIR}/up.sh" + config["PostDownScript"]="${OPENVPN_STATE_DIR}/down.sh" + + if [ "${mode}" = "up" ]; then + # register up and down scripts + bashio::log.info "Starting OpenVPN with configuration file: ${config_file}..." + _openvpn_up + bashio::exit.ok 'OpenVPN started.' + elif [ "${mode}" = "down" ]; then + bashio::log.info "Stopping OpenVPN..." + _openvpn_down + bashio::exit.ok 'OpenVPN stopped.' + elif [ "${mode}" = "postup" ]; then + _routing_add + elif [ "${mode}" = "postdown" ]; then + _routing_del + else + bashio::log.error "Invalid OpenVPN mode specified. Use 'up', 'down', 'postup', or 'postdown'." + bashio::exit.nok 'OpenVPN start failed.' + fi + bashio::log.info "Starting OpenVPN with configuration file: ${config_file}" +} + + +if [ $# -ne 2 ]; then + bashio::log.error "Invalid number of arguments. Usage: vpn.sh " + bashio::exit.nok 'VPN start failed.' +fi +if [[ "$1" == "wireguard" ]]; then + wireguard "$2" +elif [[ "$1" == "openvpn" ]]; then + openvpn "$2" +else + bashio::log.error "Invalid VPN type specified. Use 'wireguard' or 'openvpn'." + bashio::exit.nok 'VPN start failed.' +fi