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
This commit is contained in:
litinoveweedle
2026-01-25 09:21:49 +01:00
parent 18f6519f00
commit 47a43c82b4
8 changed files with 377 additions and 377 deletions

View File

@@ -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}

View File

@@ -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}

View File

@@ -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}" "$@"

View File

@@ -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 <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