Files
hassio-addons/qbittorrent/rootfs/usr/local/sbin/vpn
2026-02-08 17:41:32 +01:00

510 lines
20 KiB
Plaintext
Executable File

#!/usr/bin/with-contenv bashio
# shellcheck shell=bash
# --- Common Functions ---
declare -A config
config["MySelf"]="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/$(basename "${BASH_SOURCE[0]}")"
declare -a dns_servers_ipv4=()
declare -a dns_servers_ipv6=()
log_level="$(bashio::config "log_level")"
[[ "$log_level" == "debug" ]] && bashio::log.warning "--- Debug mode is active ---"
[[ "$log_level" == "debug" ]] && set -x
_parse_config() {
local -n config_ref="$1"
local config_file="$2"
local line
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
local key="${BASH_REMATCH[1]}"
local value="${BASH_REMATCH[2]}"
if [[ "$key" == "Address" ]]; then
if [[ -n "${config_ref["Address"]:-}" ]]; then
config_ref["Address"]+=",${value}"
else
config_ref["Address"]="${value}"
fi
else
config_ref["$key"]="$value"
fi
fi
done < "$config_file"
}
_parse_dns() {
local -a dns_conf=()
local -a dns_backup_ipv4=("8.8.8.8" "1.1.1.1")
local -a dns_backup_ipv6=("2001:4860:4860::8888" "2606:4700:4700::1111")
local dns_servers=$(bashio::config 'DNS_server')
mapfile -d ',' -t dns_conf < <(echo "${dns_servers}" | tr -d ' ' | tr -d '\n')
if [ ${config["IPv4Enabled"]} = "true" ]; then
for dns_ip in "${dns_conf[@]}"; do
local result=0
_check_host "${dns_ip}" || result=$?
if [ "${result}" -eq 1 ]; then
dns_servers_ipv4+=("${dns_ip}")
fi
done
if [ ${#dns_servers_ipv4[@]} -eq 0 ]; then
bashio::log.warning "No valid IPv4 DNS servers configured. Using addon defaults ${dns_backup_ipv4[@]}"
dns_servers_ipv4=("${dns_backup_ipv4[@]}")
fi
fi
if [ ${config["IPv6Enabled"]} = "true" ]; then
for dns_ip in "${dns_conf[@]}"; do
local result=0
_check_host "${dns_ip}" || result=$?
if [ "${result}" -eq 2 ]; then
dns_servers_ipv6+=("${dns_ip}")
fi
done
if [ ${#dns_servers_ipv6[@]} -eq 0 ]; then
bashio::log.warning "No valid IPv6 DNS servers configured. Using addon defaults ${dns_backup_ipv6[@]}"
dns_servers_ipv6=("${dns_backup_ipv6[@]}")
fi
fi
}
_cmd() {
cmd="$1"
bashio::log.debug "Executing command: ${cmd}"
eval "${cmd}"
}
_check_host() {
if ipcalc -c -4 "$1" >/dev/null 2>&1; then
return 1 # IPv4
elif ipcalc -c -6 "$1" >/dev/null 2>&1; then
return 2 # IPv6
elif getent ahosts "$1" >/dev/null 2>&1; then
return 3 # resolvable hostnamee
else
return 0 # neither IP nor resolvable hostname
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 VPN DNS servers."
if ! bashio::fs.file_exists "${resolv_backup}"; then
bashio::log.debug "Creating backup of original resolv.conf at ${resolv_backup}"
cp "${resolv_conf}" "${resolv_backup}" 2>/dev/null || true
fi
bashio::log.debug "Updating ${resolv_conf} with DNS servers: ${dns_servers_ipv4[*]} ${dns_servers_ipv6[*]}"
{
echo "# Generated by vpn script"
local dns_ip
for dns_ip in ${dns_servers_ipv4[@]} ${dns_servers_ipv6[@]}; do
echo "nameserver ${dns_ip}"
done
} > "${resolv_conf}"
else
bashio::exit.nok "Invalid resolvconf mode specified. Use 'update' or 'reset'."
fi
}
_resolve_hostname() {
local hostname=$1
local -a ips=()
local -a ipv4_candidates=()
local -a ipv6_candidates=()
# Resolve hostname to IPv4
mapfile -t ipv4_candidates < <(getent ahostsv4 "${hostname}" | awk '{print $1}' | uniq)
# Resolve hostname to IPv6
mapfile -t ipv6_candidates < <(getent ahostsv6 "${hostname}" | awk '{print $1}' | uniq)
if [ ${#ipv4_candidates[@]} -gt 0 ]; then
bashio::log.debug "Resolved ${hostname} to ${ipv4_candidates[@]}"
ips+=("${ipv4_candidates[@]}")
fi
if [ ${#ipv6_candidates[@]} -gt 0 ]; then
bashio::log.debug "Resolved ${hostname} to ${ipv6_candidates[@]}"
ips+=("${ipv6_candidates[@]}")
fi
echo "${ips[@]}"
}
_routing_add() {
bashio::log.info "Adding routing rules for VPN interface ${config["Interface"]}..."
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)
local ipv4
local ipv6
# add routing rules for local IPs
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 ${ipv4} table ${config["Table"]}" || return 1
done
for ipv6 in ${local_ipv6}; do
config["IPv6Enabled"]="true"
_cmd "ip -6 rule add priority 1 from ${ipv6} table ${config["Table"]}" || return 1
done
if [ "${config["IPv6Enabled"]}" = "true" ]; then
_cmd "ip -6 route add default dev ${config["Interface"]} table ${config["Table"]}" || true
fi
# get valid DNS servers
_parse_dns
# add routing rules for DNS servers
for dns_ip in "${dns_servers_ipv4[@]}"; do
#_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
done
for dns_ip in "${dns_servers_ipv6[@]}"; do
#_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
done
# Update resolv.conf with VPN DNS servers
_resolvconf "update"
}
_routing_del() {
bashio::log.info "Removing routing rules for VPN interface ${config["Interface"]}..."
_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 Specific Logic ---
_wg_wait_handshake() {
local timeout="${1:-20}"
local iface="${config["Interface"]}"
local peer_pk="${config["PublicKey"]}"
local deadline ts
deadline=$(( $(date +%s) + timeout ))
while [ "$(date +%s)" -lt "${deadline}" ]; do
ping -I "${iface}" -c1 -W1 1.1.1.1 >/dev/null 2>&1 || true
ts="$(wg show "${iface}" latest-handshakes 2>/dev/null | awk -v pk="${peer_pk}" '$1==pk{print $2; exit}')"
if [ -n "${ts}" ] && [ "${ts}" -gt 0 ] 2>/dev/null; then
return 0
fi
sleep 1
done
bashio::log.error "WireGuard handshake not established after ${timeout}s (latest-handshake=${ts:-0})."
wg show "${iface}" 2>&1 | while IFS= read -r l; do bashio::log.error "${l}"; done
return 1
}
_wireguard_up() {
bashio::log.warning "This script force Wireguard to ignore any routes and DNS settings."
bashio::log.warning "Default route will be inserted into custom routing table: ${config["Table"]}"
bashio::log.warning "This routing table will be used for traffic from the VPN interface and to the configured DNS servers."
bashio::log.warning "Qbittorrent bittorrent client shall be set to use the VPN interface ${config["Interface"]} only."
for key in "Interface" "ListenPort" "PrivateKey" "PublicKey" "EndpointIP" "EndpointPort" ; do
if [ ! -v config[$key] ] || [ -z "${config[$key]}" ]; then
bashio::log.error "Missing required WireGuard configuration parameter: ${key}"
return 1
fi
done
_cmd "ip link add ${config["Interface"]} type wireguard" || return 1
local allowed_ips=""
local -a local_ips=()
mapfile -d ',' -t local_ips < <(echo "${config["Address"]}" | tr -d ' ')
for local_ip in ${local_ips[@]}; do
local result=0
_check_host "${local_ip}" || result=$?
if [ "${result}" -eq 1 ]; then
allowed_ips="${allowed_ips},0.0.0.0/0"
_cmd "ip addr add ${local_ip} dev ${config["Interface"]}" || return 1
elif [ "${result}" -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
local endpoint="${config["EndpointIP"]}:${config["EndpointPort"]}"
if [[ "${config["EndpointIP"]}" == *:* ]]; then
endpoint="[${config["EndpointIP"]}]:${config["EndpointPort"]}"
fi
local peer_cmd="wg set ${config["Interface"]} peer ${config["PublicKey"]} endpoint ${endpoint} allowed-ips ${allowed_ips}"
if [ -n "${config["PresharedKey"]:-}" ]; then
peer_cmd="${peer_cmd} preshared-key ${config["PresharedKey"]}"
fi
if [ -n "${config["PersistentKeepalive"]:-}" ]; then
peer_cmd="${peer_cmd} persistent-keepalive ${config["PersistentKeepalive"]}"
fi
_cmd "${peer_cmd}" || return 1
if [ -v config["MTU"] ] && [ -n "${config["MTU"]}" ]; then
_cmd "ip link set ${config["Interface"]} mtu ${config["MTU"]}" || return 1
fi
_cmd "ip link set ${config["Interface"]} up" || return 1
_routing_add
_wg_wait_handshake 10 || return 1
}
_wireguard_down() {
_routing_del
_cmd "ip link set ${config["Interface"]} down" 2>/dev/null || true
_cmd "ip link del ${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
bashio::log.info "Using Wireguard configuration file: ${config_file}"
_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
printf '%s\n' "${config["PrivateKey"]}" > "${WIREGUARD_STATE_DIR}/privatekey"
chmod 600 "${WIREGUARD_STATE_DIR}/privatekey" || true
config["PrivateKey"]="${WIREGUARD_STATE_DIR}/privatekey"
if [ -n "${config["PresharedKey"]:-}" ]; then
printf '%s\n' "${config["PresharedKey"]}" > "${WIREGUARD_STATE_DIR}/presharedkey"
chmod 600 "${WIREGUARD_STATE_DIR}/presharedkey" || true
config["PresharedKey"]="${WIREGUARD_STATE_DIR}/presharedkey"
fi
if [ "${mode}" = "up" ]; then
bashio::log.info "Starting WireGuard on interface ${config["Interface"]}..."
local result=0
_check_host "${config["EndpointHost"]}" || result=$?
if [ "${result}" -eq 0 ]; then
bashio::log.error "WireGuard endpoint ${config["EndpointHost"]} is neither a valid IP address nor a resolvable hostname."
bashio::exit.nok 'WireGuard start failed.'
elif [ "${result}" -eq 3 ]; then
local -a endpoint_ips=()
mapfile -d ' ' -t 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.debug "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 on interface ${config["Interface"]}..."
_wireguard_down
bashio::log.info "WireGuard on 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 Specific Logic ---
_openvpn_up() {
bashio::log.warning "This script force OpenvPN to ignore any routes and DNS settings pushed by the server."
bashio::log.warning "Default route will be inserted into custom routing table: ${config["Table"]}"
bashio::log.warning "This routing table will be used for traffic from the VPN interface and to the configured DNS servers."
bashio::log.warning "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 '#!/bin/bash' > ${config["PostUpScript"]}
echo "${config["MySelf"]} openvpn postup" >> ${config["PostUpScript"]}
chmod 755 ${config["PostUpScript"]}
echo '#!/bin/bash' > ${config["PostDownScript"]}
echo "${config["MySelf"]} openvpn postdown" >> ${config["PostDownScript"]}
chmod 755 ${config["PostDownScript"]}
# Define logging
log_path="/dev/null"
[[ "$log_level" == "debug" ]] && log_path="/proc/1/fd/1"
# Start OpenVPN in the background
_cmd "/usr/sbin/openvpn \
--config "${config["ConfigFile"]}" \
--client \
--daemon \
--log "$log_path" \
--script-security 2 \
--auth-user-pass "${OPENVPN_STATE_DIR}/credentials.conf" \
--auth-retry none \
--up ${config["PostUpScript"]} \
--down ${config["PostDownScript"]} \
--up-delay \
--up-restart \
--route-nopull \
--route-noexec" || return 1
#wait for slow OpenVPN interface to come up
for i in {1..10}; do
if ip link show "${config["Interface"]}" > /dev/null 2>&1 ; then
return 0
fi
sleep 2
done
bashio::log.error "OpenVPN interface ${config["Interface"]} failed to come up."
return 1
}
_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
bashio::log.warning "Using OpenVPN configuration file: ${config_file}"
_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 on interface ${config["Interface"]}..."
if _openvpn_up; then
bashio::log.info "OpenVPN interface ${config["Interface"]} is up."
bashio::exit.ok 'OpenVPN started.'
fi
bashio::log.error 'OpenVPN failed to establish connection.'
_openvpn_down
elif [ "${mode}" = "down" ]; then
bashio::log.info "Stopping OpenVPN on interface ${config["Interface"]}..."
_openvpn_down
bashio::log.info "OpenVPN on interface ${config["Interface"]} is down."
bashio::exit.ok 'OpenVPN stopped.'
elif [ "${mode}" = "postup" ]; then
_routing_add
bashio::exit.ok 'OpenVPN routes added.'
elif [ "${mode}" = "postdown" ]; then
_routing_del
bashio::exit.ok 'OpenVPN routes deleted.'
else
bashio::log.error "Invalid OpenVPN mode specified. Use 'up', 'down', 'postup', or 'postdown'."
bashio::exit.nok 'OpenVPN start failed.'
fi
bashio::exit.nok 'OpenVPN start failed.'
}
# --- Entry Point ---
if [ $# -ne 2 ]; then
bashio::log.error "Invalid number of arguments. Usage: vpn.sh <wireguard|openvpn> <up|down>"
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