WireGuard client setup script with policy based routing

I’m sharing my WireGuard client setup script that supports tunneled IPv4 and/or IPv6. It is working with my Route10 configuration. Tested with Surfshark (IPv4), Proton (IPv4/IPv6), my ISP provided VPN (IPv4/IPv6) with firmware version v1.4k.

Feature Status Description
Policy-Based Routing (IPv4/IPv6) :white_check_mark: Tested working. Routes all IPv4/IPv6 traffic for specified clients (defined by IP or subnet) through the WireGuard tunnel, leaving other traffic unaffected.
Internet Kill Switch (IPv4/IPv6) :shield: :white_check_mark: Tested working. Blocks all IPv4/IPv6 internet access for specified clients if the WireGuard tunnel goes down, preventing connection leaks.
IPv6 Leak Prevention :droplet: :white_check_mark: Tested working. Uses a dedicated firewall chain to block a client’s IPv6 traffic until it can verify that a secure route through the VPN has been established.
IPv4/IPv6 DNS Leak Protection :droplet: :yellow_circle: Partially working. VPN DNS servers don’t leak to other interfaces. DNS servers used for a direct connection leaks to VPN.

:desktop_computer: Running the script

Usage: ./wg-client-setup.sh [-f|--force] <interface_name> -c <config_file> -r <positive_number> -t <IPs_comma_separated> [-n]
  <interface_name>:   WireGuard interface name (max 11 chars)
  -c, --conf <file>:      Relative or absolute path to the wg conf file
  -r, --routing-table <N>: Positive number for the routing table
  -t, --target-ips <IPs>:  Comma-separated list of IPv4 addresses or subnets
  -n, --no-restart:       Optional. Do not restart services (default: restart)
  -f, --force:        Optional. Force reconfiguration.

:warning: By default the script restarts network, firewall and dnsmasq services after a WireGuard interface has been setup. If you want to create a batch of WireGuard interfaces (probably after reboot using your init script), it’s recommended to pass the -n (no-restart) argument and manually restart services after:

./wg-client-setup.sh wg0 --conf conf/wg0.conf --routing-table 110 --target-ips 10.21.5.0/24,10.15.15.4 --no-restart
./wg-client-setup.sh wg1 -c conf/wg1.conf -r 111 -t 10.21.6.0/24 -n
...
./wg-client-setup.sh wg10 -c conf/wg10.conf -r 121 -t 10.21.20.0/24 -n

# restart services after all interfaces have been setup
/etc/init.d/network restart && /etc/init.d/firewall restart && /etc/init.d/dnsmasq reload

:warning: :red_exclamation_mark: The script uses hotplugs. If you plan to modify it, first disable your init script from starting on reboot. Then make sure there’s no circular code executions as it will cause a hotplug storm (usually turning the router off and on will allow you to access your router again, but if you have your init script executing on reboot you’ll have to do a factory reset).

:warning: Review the whole script before executing it in your Route10. It has long lines of codes. Supporting IPv6 and DNS leak protection (partially working) introduced a lot of complexities.

Rather than using an external link, I’ll post the code here in 2 splits.

wg-client-setup-v1.0.0 [lines 1-528/1061]
#!/bin/ash

set -e
trap 'rm -f /tmp/neigh_*_$$' EXIT

usage() {
    echo "Usage: $0 [-f|--force] <interface_name> -c <config_file> -r <positive_number> -t <IPs_comma_separated> [-n]"
    echo "  <interface_name>:   WireGuard interface name (max 11 chars)"
    echo "  -c, --conf <file>:      Relative or absolute path to the wg conf file"
    echo "  -r, --routing-table <N>: Positive number for the routing table"
    echo "  -t, --target-ips <IPs>:  Comma-separated list of IPv4 addresses or subnets"
    echo "  -n, --no-restart:       Optional. Do not restart services (default: restart)"
    echo "  -f, --force:        Optional. Force reconfiguration."
    exit 1
}

trim() {
    echo "$1" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//'
}

FORCE_RECONFIG=0
INTERFACE_NAME=""
CONFIG_FILE=""
ROUTING_TABLE_OVERRIDE=""
VPN_IPS_OVERRIDE=""
RESTART_SERVICES=1

while [ $# -gt 0 ]; do
    case "$1" in
        -f|--force)
            FORCE_RECONFIG=1
            shift
            ;;
        -c|--conf)
            [ -z "$2" ] && echo "Error: --conf requires a value" && usage
            CONFIG_FILE=$(trim "$2")
            shift 2
            ;;
        -r|--routing-table)
            [ -z "$2" ] && echo "Error: --routing-table requires a value" && usage
            ROUTING_TABLE_OVERRIDE=$(trim "$2")
            shift 2
            ;;
        -t|--target-ips)
            [ -z "$2" ] && echo "Error: --target-ips requires a value" && usage
            VPN_IPS_OVERRIDE=$(trim "$2")
            shift 2
            ;;
        -n|--no-restart)
            RESTART_SERVICES=0
            shift
            ;;
        -*)
            echo "Error: Unknown option: $1"
            usage
            ;;
        *)
            # The first non-option argument is the interface name
            if [ -z "$INTERFACE_NAME" ]; then
                INTERFACE_NAME=$(trim "$1")
            else
                echo "Error: Unknown positional argument: $1"
                usage
            fi
            shift
            ;;
    esac
done

# Validate arguments
if [ -z "$INTERFACE_NAME" ]; then
    echo "Error: WireGuard interface name required"
    usage
fi

if [ ${#INTERFACE_NAME} -gt 11 ]; then
    echo "Error: Interface name must be 11 characters or less."
    usage
fi

if [ -z "$CONFIG_FILE" ]; then
    echo "Error: --conf <config_file> is required"
    usage
fi

if [ -z "$ROUTING_TABLE_OVERRIDE" ]; then
    echo "Error: --routing-table <positive_number> is required"
    usage
fi

if [ -z "$VPN_IPS_OVERRIDE" ]; then
    echo "Error: --target-ips <IPs_comma_separated> is required"
    usage
fi

# Validate routing table is a positive number
if ! echo "$ROUTING_TABLE_OVERRIDE" | grep -Eq '^[1-9][0-9]*$'; then
    echo "Error: --routing-table must be a positive number (e.g., 100)"
    usage
fi

if [ ! -f "$CONFIG_FILE" ]; then
    echo "Error: Configuration file not found: $CONFIG_FILE"
    exit 1
fi

echo "Setting up WireGuard interface: $INTERFACE_NAME"
echo "Reading configuration from: $CONFIG_FILE"

# Parse configuration file
parse_config() {
    local section=""
    local line key value value_spaced
    while IFS= read -r line || [ -n "$line" ]; do
        line="${line%%#*}"; line="$(echo "$line" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')"
        [ -z "$line" ] && continue
        case "$line" in
            \[*\]) section="${line#\[}"; section="${section%]}"; continue;;
        esac
        case "$line" in
            *=*)
                key="${line%%=*}"; value="${line#*=}"
                key="$(echo "$key" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')"
                value="$(echo "$value" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')"
                case "$section" in
                    Interface)
                        case "$key" in
                            PrivateKey) PRIVATE_KEY="$value" ;;
                            Address)
                                value_spaced=$(echo "$value" | sed 's/,/ /g')
                                for addr in $value_spaced; do
                                    case "$addr" in
                                        *:*) CLIENT_IP6="$CLIENT_IP6 $addr" ;;
                                        *)   CLIENT_IP="$CLIENT_IP $addr" ;;
                                    esac
                                done
                                ;;
                            DNS)
                                value_spaced=$(echo "$value" | sed 's/,/ /g')
                                for dns in $value_spaced; do
                                    DNS_SERVERS="$DNS_SERVERS $dns"
                                done
                                ;;
                        esac;;
                    Peer)
                        case "$key" in
                            PublicKey) PEER_PUBLIC_KEY="$value" ;;
                            PresharedKey) PRESHARED_KEY="$value" ;;
                            Endpoint) ENDPOINT="$value" ;;
                            AllowedIPs) ALLOWED_IPS="$value" ;;
                            PersistentKeepalive) KEEPALIVE="$value" ;;
                        esac;;
                esac;;
        esac
    done < "$CONFIG_FILE"
}

parse_config

# Trim whitespace from parsed variables
CLIENT_IP=$(echo "$CLIENT_IP" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
CLIENT_IP6=$(echo "$CLIENT_IP6" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
DNS_SERVERS=$(echo "$DNS_SERVERS" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')

if [ -z "$PRIVATE_KEY" ] || [ -z "$PEER_PUBLIC_KEY" ] || [ -z "$ENDPOINT" ]; then
    echo "Error: PrivateKey, Peer PublicKey, or Endpoint not found in config"
    exit 1
fi

ALLOWED_IPS="${ALLOWED_IPS:-0.0.0.0/0, ::/0}"
KEEPALIVE="${KEEPALIVE:-25}"

# --- START VARIABLE OVERRIDE ---

# Set routing table from the required CLI parameter
ROUTING_TABLE="$ROUTING_TABLE_OVERRIDE"
echo "Using Routing Table: $ROUTING_TABLE (from --routing-table)"

ROUTING_TABLE_NAME="${INTERFACE_NAME}_rt"

# Set VPN_IPS from the required CLI parameter
# Convert comma-separated string to space-separated list
VPN_IPS=$(echo "$VPN_IPS_OVERRIDE" | sed 's/,/ /g' | tr -s ' ')
echo "Using Target IPs: $VPN_IPS (from --target-ips)"

# --- END VARIABLE OVERRIDE ---

VPN_DNS_SERVERS="$DNS_SERVERS"
VPN_IP6S=""
DHCP_HOTPLUG_SCRIPT="/etc/hotplug.d/dhcp/99-${INTERFACE_NAME}-pbr"

# Define ipset and dnsmasq config paths
IPSET_NAME="vpn_${INTERFACE_NAME}"
DNSMASQ_DIR="/tmp/dnsmasq.d"
DNSMASQ_CONF="${DNSMASQ_DIR}/99-${INTERFACE_NAME}-dns.conf"
mkdir -p $DNSMASQ_DIR

if [ -n "$VPN_DNS_SERVERS" ]; then
    echo "Configuring dnsmasq for selective DNS routing via ipset..."
    # Create or flush the ipset
    ipset create $IPSET_NAME hash:net 2>/dev/null || ipset flush $IPSET_NAME

    # Add all VPN-bound subnets/IPs to the set
    for item in $VPN_IPS; do
        ipset add $IPSET_NAME $item 2>/dev/null
    done

    # Create the dnsmasq config file
    echo "# Auto-generated by wg-client-setup.sh for $INTERFACE_NAME" > $DNSMASQ_CONF
    echo "# This file forces clients in the $IPSET_NAME ipset to use specific DNS servers." >> $DNSMASQ_CONF
    for dns in $VPN_DNS_SERVERS; do
        # Format: server=<dns_server>@<ipset_name>
        echo "server=$dns@$IPSET_NAME" >> $DNSMASQ_CONF
    done
else
    # If no DNS is set, make sure to remove any old config
    echo "No VPN_DNS servers specified; removing any old dnsmasq config."
    rm -f $DNSMASQ_CONF
fi

INTERFACE_EXISTS=0
if uci get network.$INTERFACE_NAME >/dev/null 2>&1; then INTERFACE_EXISTS=1; fi

if [ "$INTERFACE_EXISTS" -eq 1 ] && [ "$FORCE_RECONFIG" -eq 0 ]; then
    echo "Interface $INTERFACE_NAME already configured. Skipping interface reconfiguration."
    SKIP_INTERFACE_CONFIG=1
else
    SKIP_INTERFACE_CONFIG=0
    if [ "$FORCE_RECONFIG" -eq 1 ]; then
        echo "Force reconfiguration enabled - removing existing setup"
        while uci -q delete network.@wireguard_$INTERFACE_NAME[0]; do :; done
        uci delete network.$INTERFACE_NAME 2>/dev/null || true
        for section in $(uci show firewall | grep "\.name='${INTERFACE_NAME}'" | cut -d. -f2 | cut -d= -f1); do uci delete firewall.$section 2>/dev/null || true; done
        ZONE_NAME=$(echo "$INTERFACE_NAME" | cut -c1-11)
        for section in $(uci show firewall | grep "\.name='${ZONE_NAME}'" | cut -d. -f2 | cut -d= -f1); do uci delete firewall.$section 2>/dev/null || true; done
        rm -f "$DHCP_HOTPLUG_SCRIPT" 2>/dev/null
    fi
fi

rm -f /etc/hotplug.d/iface/99-${INTERFACE_NAME}-routing 2>/dev/null
rm -f /etc/hotplug.d/iface/99-${INTERFACE_NAME}-cleanup 2>/dev/null

if [ "$SKIP_INTERFACE_CONFIG" -eq 0 ]; then
    uci set network.$INTERFACE_NAME=interface
    uci set network.$INTERFACE_NAME.proto='wireguard'
    uci set network.$INTERFACE_NAME.private_key="$PRIVATE_KEY"
    if [ -n "$CLIENT_IP" ]; then for ip in $CLIENT_IP; do uci add_list network.$INTERFACE_NAME.addresses="$ip"; done; fi

    # Determine IPv6 support from wg conf
    IPV6_SUPPORTED=0
    if [ -n "$CLIENT_IP6" ]; then
        IPV6_SUPPORTED=1
        for ip6 in $CLIENT_IP6; do uci add_list network.$INTERFACE_NAME.addresses="$ip6"; done
    elif echo "$ALLOWED_IPS" | grep -q "::"; then
        IPV6_SUPPORTED=1
        echo "INFO: IPv6 routing is enabled via AllowedIPs, but no local IPv6 tunnel address is configured."
    fi

    uci add network wireguard_$INTERFACE_NAME
    uci set network.@wireguard_$INTERFACE_NAME[-1]=wireguard_$INTERFACE_NAME
    uci set network.@wireguard_$INTERFACE_NAME[-1].public_key="$PEER_PUBLIC_KEY"

    # Parse endpoint (supports both IPv4 and IPv6)
    case "$ENDPOINT" in
        \[*\]:*)
            # IPv6 format: [2401:dc20::50]:51820
            ENDPOINT_HOST="${ENDPOINT%]:*}"
            ENDPOINT_HOST="${ENDPOINT_HOST#\[}"
            ENDPOINT_PORT="${ENDPOINT##*]:}"
            ;;
        *:*)
            # IPv4 format: 1.2.3.4:51820
            ENDPOINT_HOST="${ENDPOINT%:*}"
            ENDPOINT_PORT="${ENDPOINT##*:}"
            ;;
        *)
            echo "Error: Invalid endpoint format: $ENDPOINT"
            exit 1
            ;;
    esac

    uci set network.@wireguard_$INTERFACE_NAME[-1].endpoint_host="$ENDPOINT_HOST"
    uci set network.@wireguard_$INTERFACE_NAME[-1].endpoint_port="$ENDPOINT_PORT"

    uci set network.@wireguard_$INTERFACE_NAME[-1].persistent_keepalive="$KEEPALIVE"
    echo "$ALLOWED_IPS" | tr ',' '\n' | while read -r ip; do ip="$(echo "$ip" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')"; [ -n "$ip" ] && uci add_list network.@wireguard_$INTERFACE_NAME[-1].allowed_ips="$ip"; done
    if [ -n "$PRESHARED_KEY" ]; then uci set network.@wireguard_$INTERFACE_NAME[-1].preshared_key="$PRESHARED_KEY"; fi
    ZONE_NAME=$(echo "$INTERFACE_NAME" | cut -c1-11)
    uci set firewall.${INTERFACE_NAME}_zone=zone
    uci set firewall.${INTERFACE_NAME}_zone.name="$ZONE_NAME"
    uci set firewall.${INTERFACE_NAME}_zone.input='REJECT'
    uci set firewall.${INTERFACE_NAME}_zone.output='ACCEPT'
    uci set firewall.${INTERFACE_NAME}_zone.forward='ACCEPT'
    uci set firewall.${INTERFACE_NAME}_zone.masq='1'
    uci add_list firewall.${INTERFACE_NAME}_zone.network="$INTERFACE_NAME"
    uci set firewall.${INTERFACE_NAME}_fwd=forwarding
    uci set firewall.${INTERFACE_NAME}_fwd.src='lan'
    uci set firewall.${INTERFACE_NAME}_fwd.dest="$ZONE_NAME"
else
    # Determine IPv6 support from existing config if not reconfiguring
    IPV6_SUPPORTED=0
    if uci get network.$INTERFACE_NAME.addresses 2>/dev/null | grep -q ':' || \
       uci get network.@wireguard_${INTERFACE_NAME}[-1].allowed_ips 2>/dev/null | grep -q ':'; then
        IPV6_SUPPORTED=1
    fi
fi

echo "Configuring policy-based routing..."
if ! grep -q "^$ROUTING_TABLE[[:space:]]*$ROUTING_TABLE_NAME" /etc/iproute2/rt_tables 2>/dev/null; then
    echo "$ROUTING_TABLE $ROUTING_TABLE_NAME" >> /etc/iproute2/rt_tables
fi

# Create ifup script
cat > /etc/hotplug.d/iface/99-${INTERFACE_NAME}-routing << 'EOF_IFACE_ROUTING'
#!/bin/sh
[ "$ACTION" = "ifup" ] || exit 0
[ "$INTERFACE" = "INTERFACE_NAME_PLACEHOLDER" ] || exit 0

trap 'rm -f /tmp/neigh_${WG_INTERFACE}_$$' EXIT

ROUTING_TABLE="ROUTING_TABLE_PLACEHOLDER"
IPV6_SUPPORTED="IPV6_SUPPORTED_PLACEHOLDER"
WG_INTERFACE="INTERFACE_NAME_PLACEHOLDER"
VPN_IPS="VPN_IPS_PLACEHOLDER"
VPN_DNS="VPN_DNS_PLACEHOLDER"
IPSET_NAME="IPSET_NAME_PLACEHOLDER"
MARK_CHAIN="mark_${WG_INTERFACE}"
DNS_BLOCK_CHAIN_v4="dns_block_v4_${WG_INTERFACE}"
DNS_BLOCK_CHAIN_v6="dns_block_v6_${WG_INTERFACE}"
MARK_VALUE="0x${ROUTING_TABLE}"
KS_CHAIN="${WG_INTERFACE}_killswitch"
BLOCK_CHAIN="${WG_INTERFACE}_ipv6_block"
BLOCK_IPV4_ONLY_CHAIN="${WG_INTERFACE}_ipv4_only_block"

ip_to_int() {
    local a b c d; IFS=. read -r a b c d <<EOF
$1
EOF
    echo "$(( (a << 24) | (b << 16) | (c << 8) | d ))"
}

setup_secure_dns() {
    local vpn_dns_list="$1"
    local vpn_ips="$2"
    local wg_interface="$3"

    [ -z "$vpn_dns_list" ] && return 0

    local nat_chain="vpn_dns_nat_${wg_interface}"
    local filter_chain="vpn_dns_filter_${wg_interface}"
    # SIMPLIFIED: Removed isolation_chain. It is handled globally later in this script.

    logger -t wireguard "[$wg_interface] Setting up secure VPN DNS redirect to $vpn_dns_list"

    # Cleanup old rules first
    iptables -t nat -D PREROUTING -j $nat_chain 2>/dev/null
    iptables -t nat -F $nat_chain 2>/dev/null
    iptables -t nat -X $nat_chain 2>/dev/null

    iptables -D FORWARD -j $filter_chain 2>/dev/null
    iptables -F $filter_chain 2>/dev/null
    iptables -X $filter_chain 2>/dev/null

    # SIMPLIFIED: Removed cleanup for redundant isolation_chain

    # Create new chains
    iptables -t nat -N $nat_chain
    iptables -N $filter_chain
    # SIMPLIFIED: Removed creation of redundant isolation_chain

    for item in $vpn_ips; do
        # 1. DNAT rule to redirect DNS queries to VPN DNS
        if [ "$(echo $vpn_dns_list | wc -w)" -eq 1 ]; then
            iptables -t nat -A $nat_chain -s $item -p udp --dport 53 -j DNAT --to-destination $vpn_dns_list
            iptables -t nat -A $nat_chain -s $item -p tcp --dport 53 -j DNAT --to-destination $vpn_dns_list
        else
            local i=0
            local dns_count=$(echo $vpn_dns_list | wc -w)
            for dns in $vpn_dns_list; do
                iptables -t nat -A $nat_chain -s $item -p udp --dport 53 -m statistic --mode nth --every $((dns_count - i)) --packet 0 -j DNAT --to-destination $dns
                iptables -t nat -A $nat_chain -s $item -p tcp --dport 53 -m statistic --mode nth --every $((dns_count - i)) --packet 0 -j DNAT --to-destination $dns
                i=$((i + 1))
            done
        fi

        # 2. Block DoT (port 853)
        iptables -A $filter_chain -s $item -p tcp --dport 853 -j REJECT --reject-with tcp-reset
        iptables -A $filter_chain -s $item -p udp --dport 853 -j REJECT --reject-with icmp-port-unreachable

        # 3. Block DoH (port 443) by known providers
        iptables -A $filter_chain -s $item -p tcp --dport 443 -m string --algo bm --string "dns.google" -j REJECT --reject-with tcp-reset
        iptables -A $filter_chain -s $item -p tcp --dport 443 -m string --algo bm --string "cloudflare-dns.com" -j REJECT --reject-with tcp-reset
    done

    # SIMPLIFIED: Removed redundant isolation logic. It is handled globally.

    # Insert chains
    iptables -t nat -I PREROUTING 1 -j $nat_chain
    iptables -I FORWARD 1 -j $filter_chain
    # SIMPLIFIED: Removed insert for redundant isolation_chain

    logger -t wireguard "[$wg_interface] Secure VPN DNS configured with leak prevention"
}

is_in_subnet() {
    local ip_to_check="$1"; local subnet_cidr="$2"
    local ip_int subnet prefix network_int mask
    ip_int=$(ip_to_int "$ip_to_check")
    subnet="${subnet_cidr%/*}"; prefix="${subnet_cidr#*/}"
    network_int=$(ip_to_int "$subnet")
    i=0; mask=0
    while [ $i -lt $prefix ]; do
        mask=$(( (mask >> 1) | 0x80000000 )); i=$((i+1))
    done
    [ $(( ip_int & mask )) -eq $(( network_int & mask )) ] && return 0 || return 1
}

apply_ipv6_rules() {
    # This function only runs if IPV6_SUPPORTED=1
    local mac_addr="$1"
    local subnet_item="${2:-}"
    logger -t wireguard "[$WG_INTERFACE] Starting IPv6 discovery for MAC: $mac_addr"
    (
        start_time=$(date +%s 2>/dev/null || echo 0)
        start_time_ns=$(date +%s%N 2>/dev/null || echo ${start_time}000000000)
        MAX_RETRIES=10
        RETRY_COUNT=0
        PING_CLIENT=1

        # If proactive mode, try to trigger IPv6 discovery
        if [ $PING_CLIENT -eq 1 ]; then
            client_ip=$(ip neigh show | grep -i "$mac_addr" | awk '{print $1}' | head -1)
            if [ -n "$client_ip" ]; then
                logger -t wireguard "[$WG_INTERFACE] Proactively pinging $client_ip to discover IPv6..."
                ping -c 3 -W 1 "$client_ip" >/dev/null 2>&1 &
            fi
        fi

        while [ $RETRY_COUNT -lt $MAX_RETRIES ]; do
            sleep 2
            ipv6_addrs=$(ip -6 neigh show | grep -i "$mac_addr" | grep -v "fe80:" | awk '{print $1}')

            if [ -n "$ipv6_addrs" ]; then
                prefix=$(echo "$ipv6_addrs" | head -n1 | cut -d: -f1-4)
                if [ -n "$prefix" ]; then
                    # Add routing FIRST
                    ip -6 rule del from ${prefix}::/64 table $ROUTING_TABLE 2>/dev/null
                    ip -6 rule add from ${prefix}::/64 table $ROUTING_TABLE priority $ROUTING_TABLE

                    # Verify default route is correct, THEN unblock
                    if ip -6 route show table "$ROUTING_TABLE" | grep -q "default dev $WG_INTERFACE"; then
                        lan_ifaces=$(ip link show | grep -o 'br-lan[^:]*' | tr '\n' ' ')
                        [ -z "$lan_ifaces" ] && lan_ifaces="br-lan"
                        for lan_if in $lan_ifaces; do
                            ip6tables -D $BLOCK_CHAIN -i $lan_if ! -o $WG_INTERFACE -m mac --mac-source $mac_addr -j DROP 2>/dev/null
                        done
                        end_time_ns=$(date +%s%N 2>/dev/null || echo ${start_time}000000000)
                        elapsed_ms=$(( (end_time_ns - start_time_ns) / 1000000 ))
                        elapsed_s=$(printf "%d.%03d" $((elapsed_ms / 1000)) $((elapsed_ms % 1000)) 2>/dev/null || echo "$((RETRY_COUNT * 2))")
                        logger -t wireguard "[$WG_INTERFACE] IPv6 unblocked and routed for $mac_addr (${prefix}::/64) in ${elapsed_s}s"
                        return 0
                    else
                         logger -t wireguard "[$WG_INTERFACE] ERROR: Default IPv6 route in table $ROUTING_TABLE is missing or incorrect. IPv6 remains blocked for $mac_addr."
                    fi
                    return 1 # Exit on first valid prefix found, even if routing fails
                fi
            fi

            RETRY_COUNT=$((RETRY_COUNT+1))
            if [ $RETRY_COUNT -eq 1 ]; then
                logger -t wireguard "[$WG_INTERFACE] IPv6 not found for MAC $mac_addr, retrying every 2 seconds..."
            fi
        done

        logger -t wireguard "[$WG_INTERFACE] WARNING: IPv6 not found for MAC $mac_addr after $((MAX_RETRIES * 2))s. IPv6 remains blocked."
    ) &
}

# Disable Kill Switch
logger -t wireguard "[$WG_INTERFACE] Interface is up. Disabling kill switch."
iptables -F $KS_CHAIN 2>/dev/null; ip6tables -F $KS_CHAIN 2>/dev/null
iptables -D FORWARD -j $KS_CHAIN 2>/dev/null; ip6tables -D FORWARD -j $KS_CHAIN 2>/dev/null
iptables -X $KS_CHAIN 2>/dev/null; ip6tables -X $KS_CHAIN 2>/dev/null

# Create appropriate IPv6 blocking chain (leak prevention OR IPv4-only block)
if [ "$IPV6_SUPPORTED" = "1" ]; then
    ip6tables -N $BLOCK_CHAIN 2>/dev/null
    ip6tables -C FORWARD -j $BLOCK_CHAIN 2>/dev/null || ip6tables -I FORWARD 1 -j $BLOCK_CHAIN
else
    logger -t wireguard "[$WG_INTERFACE] IPv4-only tunnel detected. IPv6 will be blocked for specified clients."
    ip6tables -N $BLOCK_IPV4_ONLY_CHAIN 2>/dev/null
    ip6tables -C FORWARD -j $BLOCK_IPV4_ONLY_CHAIN 2>/dev/null || ip6tables -I FORWARD 1 -j $BLOCK_IPV4_ONLY_CHAIN
fi

# Setup routing table
logger -t wireguard "[$WG_INTERFACE] Setting up default routes in table $ROUTING_TABLE."
ip route flush table $ROUTING_TABLE 2>/dev/null; ip -6 route flush table $ROUTING_TABLE 2>/dev/null
ip route add default dev $WG_INTERFACE table $ROUTING_TABLE
if [ "$IPV6_SUPPORTED" = "1" ]; then
    ip -6 route add default dev $WG_INTERFACE table $ROUTING_TABLE 2>/dev/null
fi

# Create/Flush ipset on every ifup to prevent race condition
logger -t wireguard "[$WG_INTERFACE] Creating/flushing ipset $IPSET_NAME."
ipset create $IPSET_NAME hash:net 2>/dev/null || ipset flush $IPSET_NAME
for item in $VPN_IPS; do
    ipset add $IPSET_NAME $item 2>/dev/null
done

# Setup fwmark routing to catch DNS leaks from 'allservers=1'
logger -t wireguard "[$WG_INTERFACE] Setting up DNS leak-prevention firewall mark $MARK_VALUE."
iptables -t mangle -N $MARK_CHAIN 2>/dev/null
iptables -t mangle -F $MARK_CHAIN
# Mark inbound DNS queries from VPN clients
iptables -t mangle -A $MARK_CHAIN -p udp --dport 53 -m set --match-set $IPSET_NAME src -j CONNMARK --set-mark $MARK_VALUE
iptables -t mangle -A $MARK_CHAIN -p tcp --dport 53 -m set --match-set $IPSET_NAME src -j CONNMARK --set-mark $MARK_VALUE
ip6tables -t mangle -N $MARK_CHAIN 2>/dev/null
ip6tables -t mangle -F $MARK_CHAIN
ip6tables -t mangle -A $MARK_CHAIN -p udp --dport 53 -m set --match-set $IPSET_NAME src -j CONNMARK --set-mark $MARK_VALUE
ip6tables -t mangle -A $MARK_CHAIN -p tcp --dport 53 -m set --match-set $IPSET_NAME src -j CONNMARK --set-mark $MARK_VALUE

# Apply mark chain to LAN interfaces
lan_ifaces=$(ip link show | grep -o 'br-lan[^:]*' | tr '\n' ' ')
[ -z "$lan_ifaces" ] && lan_ifaces="br-lan"
for lan_if in $lan_ifaces; do
    iptables -t mangle -C PREROUTING -i $lan_if -j $MARK_CHAIN 2>/dev/null || iptables -t mangle -A PREROUTING -i $lan_if -j $MARK_CHAIN
    ip6tables -t mangle -C PREROUTING -i $lan_if -j $MARK_CHAIN 2>/dev/null || ip6tables -t mangle -A PREROUTING -i $lan_if -j $MARK_CHAIN
done
5 Likes
wg-client-setup-v1.0.0 [lines 529-1061/1061]
# Restore the connection mark onto packets generated by dnsmasq (more specific)
iptables -t mangle -C OUTPUT -p udp --sport 53 -j CONNMARK --restore-mark 2>/dev/null || iptables -t mangle -A OUTPUT -p udp --sport 53 -j CONNMARK --restore-mark
iptables -t mangle -C OUTPUT -p tcp --sport 53 -j CONNMARK --restore-mark 2>/dev/null || iptables -t mangle -A OUTPUT -p tcp --sport 53 -j CONNMARK --restore-mark
ip6tables -t mangle -C OUTPUT -p udp --sport 53 -j CONNMARK --restore-mark 2>/dev/null || ip6tables -t mangle -A OUTPUT -p udp --sport 53 -j CONNMARK --restore-mark
ip6tables -t mangle -C OUTPUT -p tcp --sport 53 -j CONNMARK --restore-mark 2>/dev/null || ip6tables -t mangle -A OUTPUT -p tcp --sport 53 -j CONNMARK --restore-mark

# DNS backstop to allow local responses (IPv4)
logger -t wireguard "[$WG_INTERFACE] Adding IPv4 DNS backstop to block leaks."
iptables -t mangle -N $DNS_BLOCK_CHAIN_v4 2>/dev/null
iptables -t mangle -F $DNS_BLOCK_CHAIN_v4
# Only apply this chain to marked packets
iptables -t mangle -C OUTPUT -m mark --mark $MARK_VALUE -j $DNS_BLOCK_CHAIN_v4 2>/dev/null || iptables -t mangle -A OUTPUT -m mark --mark $MARK_VALUE -j $DNS_BLOCK_CHAIN_v4
# Inside the chain:
# Allow if it's correctly going out the WG interface
iptables -t mangle -A $DNS_BLOCK_CHAIN_v4 -o $WG_INTERFACE -j RETURN
# Allow responses back to the VPN client
iptables -t mangle -A $DNS_BLOCK_CHAIN_v4 -m set --match-set $IPSET_NAME dst -j RETURN
# Log before dropping
iptables -t mangle -A $DNS_BLOCK_CHAIN_v4 -p udp --dport 53 -j LOG --log-prefix "DROP_DNS_LEAK_v4: " --log-level 7
iptables -t mangle -A $DNS_BLOCK_CHAIN_v4 -p tcp --dport 53 -j LOG --log-prefix "DROP_DNS_LEAK_v4: " --log-level 7
# Drop any other DNS packet (leaks)
iptables -t mangle -A $DNS_BLOCK_CHAIN_v4 -p udp --dport 53 -j DROP
iptables -t mangle -A $DNS_BLOCK_CHAIN_v4 -p tcp --dport 53 -j DROP
# DNS backstop to allow local responses (IPv6)
logger -t wireguard "[$WG_INTERFACE] Adding IPv6 DNS backstop to block leaks."
ip6tables -t mangle -N $DNS_BLOCK_CHAIN_v6 2>/dev/null
ip6tables -t mangle -F $DNS_BLOCK_CHAIN_v6
# Only apply this chain to marked packets
ip6tables -t mangle -C OUTPUT -m mark --mark $MARK_VALUE -j $DNS_BLOCK_CHAIN_v6 2>/dev/null || ip6tables -t mangle -A OUTPUT -m mark --mark $MARK_VALUE -j $DNS_BLOCK_CHAIN_v6
# Inside the chain:
# Allow if it's correctly going out the WG interface
ip6tables -A $DNS_BLOCK_CHAIN_v6 -o $WG_INTERFACE -j RETURN
# Allow responses back to the VPN client (if ipset supports IPv6)
ip6tables -A $DNS_BLOCK_CHAIN_v6 -m set --match-set $IPSET_NAME dst -j RETURN 2>/dev/null || true
# Log before dropping
ip6tables -t mangle -A $DNS_BLOCK_CHAIN_v6 -p udp --dport 53 -j LOG --log-prefix "DROP_DNS_LEAK_v6: " --log-level 7
ip6tables -t mangle -A $DNS_BLOCK_CHAIN_v6 -p tcp --dport 53 -j LOG --log-prefix "DROP_DNS_LEAK_v6: " --log-level 7
# Drop any other DNS packet (leaks)
ip6tables -A $DNS_BLOCK_CHAIN_v6 -p udp --dport 53 -j DROP
ip6tables -A $DNS_BLOCK_CHAIN_v6 -p tcp --dport 53 -j DROP

# Add new ip rule to force all marked packets (incl. leaks) into the VPN table
ip rule del fwmark $MARK_VALUE table $ROUTING_TABLE 2>/dev/null
ip rule add fwmark $MARK_VALUE table $ROUTING_TABLE priority $((ROUTING_TABLE - 5))
ip -6 rule del fwmark $MARK_VALUE table $ROUTING_TABLE 2>/dev/null
ip -6 rule add fwmark $MARK_VALUE table $ROUTING_TABLE priority $((ROUTING_TABLE - 5))

# Discover and configure existing clients
logger -t wireguard "[$WG_INTERFACE] Scanning for existing clients and applying rules..."
PROCESSED_MACS=""
for item in $VPN_IPS; do
    case "$item" in
        */*) # This is a subnet
            ip rule add from $item table $ROUTING_TABLE priority $ROUTING_TABLE 2>/dev/null

            # 1. DHCP Leases (Primary)
            if [ -f /cfg/dhcp.leases ]; then
                while read -r exp mac ip host; do
                    if is_in_subnet "$ip" "$item" && ! echo "$PROCESSED_MACS" | grep -q "$mac"; then
                        PROCESSED_MACS="$PROCESSED_MACS $mac"
                        logger -t wireguard "[$WG_INTERFACE] Found existing client $ip ($mac) in $item via DHCP lease."
                        ip rule add from $ip table $ROUTING_TABLE priority $ROUTING_TABLE 2>/dev/null
                        if [ "$IPV6_SUPPORTED" = "1" ]; then
                            lan_ifaces=$(ip link show | grep -o 'br-lan[^:]*' | tr '\n' ' ')
                            [ -z "$lan_ifaces" ] && lan_ifaces="br-lan"
                            for lan_if in $lan_ifaces; do ip6tables -I $BLOCK_CHAIN 1 -i $lan_if ! -o $WG_INTERFACE -m mac --mac-source $mac -j DROP; done
                            logger -t wireguard "[$WG_INTERFACE] IPv6 internet blocked for $ip ($mac) until routing configured"
                            apply_ipv6_rules "$mac"
                        else
                            # IPv4-only tunnel: Block IPv6 for this client
                            logger -t wireguard "[$WG_INTERFACE] Blocking IPv6 for $ip ($mac) on IPv4-only tunnel."
                            ip6tables -A $BLOCK_IPV4_ONLY_CHAIN -m mac --mac-source $mac -j DROP
                        fi
                    fi
                done < /cfg/dhcp.leases
            fi

            # 2. ARP/NDP Table (Fallback)
            ip neigh show > /tmp/neigh_${WG_INTERFACE}_$$
            while read -r line; do
                ip=$(echo $line | awk '{print $1}')
                mac=$(echo $line | grep -o -E '([0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}')
                if [ -n "$mac" ] && is_in_subnet "$ip" "$item" && ! echo "$PROCESSED_MACS" | grep -q "$mac"; then
                    PROCESSED_MACS="$PROCESSED_MACS $mac"
                    logger -t wireguard "[$WG_INTERFACE] Found existing client $ip ($mac) in $item via ARP/NDP table."
                    ip rule add from $ip table $ROUTING_TABLE priority $ROUTING_TABLE 2>/dev/null
                    if [ "$IPV6_SUPPORTED" = "1" ]; then
                        lan_ifaces=$(ip link show | grep -o 'br-lan[^:]*' | tr '\n' ' ')
                        [ -z "$lan_ifaces" ] && lan_ifaces="br-lan"
                        for lan_if in $lan_ifaces; do ip6tables -I $BLOCK_CHAIN 1 -i $lan_if ! -o $WG_INTERFACE -m mac --mac-source $mac -j DROP; done
                        logger -t wireguard "[$WG_INTERFACE] IPv6 internet blocked for $ip ($mac) until routing configured"
                        apply_ipv6_rules "$mac"
                    else
                        # IPv4-only tunnel: Block IPv6 for this client
                        logger -t wireguard "[$WG_INTERFACE] Blocking IPv6 for $ip ($mac) on IPv4-only tunnel."
                        ip6tables -A $BLOCK_IPV4_ONLY_CHAIN -m mac --mac-source $mac -j DROP
                    fi
                fi
            done < /tmp/neigh_${WG_INTERFACE}_$$
            rm -f /tmp/neigh_${WG_INTERFACE}_$$
            ;;
        *) # This is an individual IP
            mac=$(ip neigh show "$item" | grep -o '[0-9a-f:]\{17\}' | head -1)
            if [ -n "$mac" ] && [ "$mac" != "<incomplete>" ] && ! echo "$PROCESSED_MACS" | grep -q "$mac"; then
                PROCESSED_MACS="$PROCESSED_MACS $mac"
                logger -t wireguard "[$WG_INTERFACE] Found existing client $item ($mac)."
                ip rule add from $item table $ROUTING_TABLE priority $ROUTING_TABLE 2>/dev/null
                if [ "$IPV6_SUPPORTED" = "1" ]; then
                    lan_ifaces=$(ip link show | grep -o 'br-lan[^:]*' | tr '\n' ' ')
                    [ -z "$lan_ifaces" ] && lan_ifaces="br-lan"
                    for lan_if in $lan_ifaces; do ip6tables -I $BLOCK_CHAIN 1 -i $lan_if ! -o $WG_INTERFACE -m mac --mac-source $mac -j DROP; done
                    logger -t wireguard "[$WG_INTERFACE] IPv6 internet blocked for $item ($mac) until routing configured"
                    apply_ipv6_rules "$mac"
                else
                    # IPv4-only tunnel: Block IPv6 for this client
                    logger -t wireguard "[$WG_INTERFACE] Blocking IPv6 for $item ($mac) on IPv4-only tunnel."
                    ip6tables -A $BLOCK_IPV4_ONLY_CHAIN -m mac --mac-source $mac -j DROP
                fi
            fi
            ;;
    esac
done

# Setup VPN DNS redirect if DNS servers are configured
if [ -n "$VPN_DNS" ]; then
    setup_secure_dns "$VPN_DNS" "$VPN_IPS" "$WG_INTERFACE"
fi

# ALWAYS block non-VPN DNS from leaking through VPN tunnel (regardless of VPN DNS config)
# This is the global DNS isolation chain.
ISOLATION_CHAIN="vpn_dns_isolate_${WG_INTERFACE}"
iptables -N $ISOLATION_CHAIN 2>/dev/null
iptables -F $ISOLATION_CHAIN 2>/dev/null

# Allow VPN clients' DNS through
for item in $VPN_IPS; do
    iptables -A $ISOLATION_CHAIN -s $item -j RETURN
done

# Block all other DNS from exiting via VPN
iptables -A $ISOLATION_CHAIN -o $WG_INTERFACE -p udp --dport 53 -j REJECT --reject-with icmp-port-unreachable
iptables -A $ISOLATION_CHAIN -o $WG_INTERFACE -p tcp --dport 53 -j REJECT --reject-with tcp-reset

# Insert into FORWARD chain if not already there
iptables -C FORWARD -j $ISOLATION_CHAIN 2>/dev/null || iptables -I FORWARD 1 -j $ISOLATION_CHAIN

logger -t wireguard "[$WG_INTERFACE] DNS isolation active - blocking non-VPN DNS from leaking through tunnel"

ip route flush cache; ip -6 route flush cache 2>/dev/null

# Add a final dnsmasq reload to ensure it binds to the ipset
logger -t wireguard "[$WG_INTERFACE] Performing guaranteed dnsmasq reload."
( /etc/init.d/dnsmasq reload >/dev/null 2>&1 ) &

EOF_IFACE_ROUTING
sed -i "s|INTERFACE_NAME_PLACEHOLDER|$INTERFACE_NAME|g" /etc/hotplug.d/iface/99-${INTERFACE_NAME}-routing
sed -i "s|ROUTING_TABLE_PLACEHOLDER|$ROUTING_TABLE|g" /etc/hotplug.d/iface/99-${INTERFACE_NAME}-routing
sed -i "s|VPN_IPS_PLACEHOLDER|$VPN_IPS|g" /etc/hotplug.d/iface/99-${INTERFACE_NAME}-routing
sed -i "s|IPV6_SUPPORTED_PLACEHOLDER|$IPV6_SUPPORTED|g" /etc/hotplug.d/iface/99-${INTERFACE_NAME}-routing
sed -i "s|VPN_DNS_PLACEHOLDER|$VPN_DNS_SERVERS|g" /etc/hotplug.d/iface/99-${INTERFACE_NAME}-routing
sed -i "s|IPSET_NAME_PLACEHOLDER|$IPSET_NAME|g" /etc/hotplug.d/iface/99-${INTERFACE_NAME}-routing
chmod +x /etc/hotplug.d/iface/99-${INTERFACE_NAME}-routing

# Create DHCP hotplug script
if [ ! -f "$DHCP_HOTPLUG_SCRIPT" ] || [ "$FORCE_RECONFIG" -eq 1 ]; then
    echo "Creating/Updating DHCP hotplug script: $DHCP_HOTPLUG_SCRIPT"
    cat > "$DHCP_HOTPLUG_SCRIPT" << 'EOF_DHCP_PBR'
#!/bin/sh
WG_INTERFACE="INTERFACE_NAME_PLACEHOLDER"
ROUTING_TABLE="ROUTING_TABLE_PLACEHOLDER"
VPN_IPS="VPN_IPS_PLACEHOLDER"
IPV6_SUPPORTED="IPV6_SUPPORTED_PLACEHOLDER"
KS_CHAIN="${WG_INTERFACE}_killswitch"
BLOCK_CHAIN="${WG_INTERFACE}_ipv6_block"
BLOCK_IPV4_ONLY_CHAIN="${WG_INTERFACE}_ipv4_only_block"

ip_to_int() {
    local a b c d; IFS=. read -r a b c d <<EOF
$1
EOF
    echo "$(( (a << 24) | (b << 16) | (c << 8) | d ))"
}

is_in_list() {
    local ip_to_check="$1"; local list="$2"; local ip_int
    ip_int=$(ip_to_int "$ip_to_check")
    for item in $list; do
        case "$item" in
            */*)
                local subnet prefix network_int mask
                subnet="${item%/*}"; prefix="${item#*/}"
                network_int=$(ip_to_int "$subnet"); i=0; mask=0
                while [ $i -lt $prefix ]; do
                    mask=$(( (mask >> 1) | 0x80000000 )); i=$((i+1))
                done
                [ $(( ip_int & mask )) -eq $(( network_int & mask )) ] && return 0
                ;;
            *)
                [ "$item" = "$ip_to_check" ] && return 0
                ;;
        esac
    done
    return 1
}

[ "$ACTION" = "add" ] || [ "$ACTION" = "new" ] || exit 0

if is_in_list "$IPADDR" "$VPN_IPS"; then
    # Check if tunnel interface exists (avoids race condition)
    if ifconfig | grep -q "$WG_INTERFACE"; then
        logger -t wg-dhcp-hotplug "[$WG_INTERFACE] New client $IPADDR ($MACADDR) detected. Applying VPN routing rules."
        iptables -D $KS_CHAIN -s $IPADDR -j REJECT 2>/dev/null
        ip6tables -D $KS_CHAIN -m mac --mac-source $MACADDR -j REJECT 2>/dev/null
        ip rule del from $IPADDR table $ROUTING_TABLE 2>/dev/null
        ip rule add from $IPADDR table $ROUTING_TABLE priority $ROUTING_TABLE

        if [ "$IPV6_SUPPORTED" = "1" ]; then
            # IPv6 Supported: Start leak prevention
            lan_ifaces=$(ip link show | grep -o 'br-lan[^:]*' | tr '\n' ' ')
            [ -z "$lan_ifaces" ] && lan_ifaces="br-lan"
            for lan_if in $lan_ifaces; do ip6tables -I $BLOCK_CHAIN 1 -i $lan_if ! -o $WG_INTERFACE -m mac --mac-source $MACADDR -j DROP; done
            logger -t wg-dhcp-hotplug "[$WG_INTERFACE] IPv6 internet blocked for $IPADDR ($MACADDR) until routing configured"

            (
                start_time=$(date +%s 2>/dev/null || echo 0)
                start_time_ns=$(date +%s%N 2>/dev/null || echo ${start_time}000000000)
                MAX_RETRIES=10
                RETRY_COUNT=0
                PING_CLIENT=1

                # Proactive ping to trigger IPv6 discovery
                if [ $PING_CLIENT -eq 1 ]; then
                    logger -t wg-dhcp-hotplug "[$WG_INTERFACE] Proactively pinging $IPADDR to discover IPv6..."
                    ping -c 3 -W 1 "$IPADDR" >/dev/null 2>&1 &
                fi

                while [ $RETRY_COUNT -lt $MAX_RETRIES ]; do
                    sleep 2
                    ipv6_addrs=$(ip -6 neigh show | grep -i "$MACADDR" | grep -v "fe80:" | awk '{print $1}')
                    if [ -n "$ipv6_addrs" ]; then
                        prefix=$(echo "$ipv6_addrs" | head -n1 | cut -d: -f1-4)
                        if [ -n "$prefix" ]; then
                            # Add routing FIRST
                            ip -6 rule del from ${prefix}::/64 table $ROUTING_TABLE 2>/dev/null
                            ip -6 rule add from ${prefix}::/64 table $ROUTING_TABLE priority $ROUTING_TABLE

                            # Verify default route is correct, THEN unblock
                            if ip -6 route show table "$ROUTING_TABLE" | grep -q "default dev $WG_INTERFACE"; then
                                for lan_if in $lan_ifaces; do
                                    ip6tables -D $BLOCK_CHAIN -i $lan_if ! -o $WG_INTERFACE -m mac --mac-source $MACADDR -j DROP 2>/dev/null
                                done
                                end_time_ns=$(date +%s%N 2>/dev/null || echo ${start_time}000000000)
                                elapsed_ms=$(( (end_time_ns - start_time_ns) / 1000000 ))
                                elapsed_s=$(printf "%d.%03d" $((elapsed_ms / 1000)) $((elapsed_ms % 1000)) 2>/dev/null || echo "$((RETRY_COUNT * 2))")
                                logger -t wg-dhcp-hotplug "[$WG_INTERFACE] IPv6 unblocked and routed for $MACADDR (${prefix}::/64) in ${elapsed_s}s"
                                break
                            else
                                logger -t wg-dhcp-hotplug "[$WG_INTERFACE] ERROR: Default IPv6 route in table $ROUTING_TABLE is missing or incorrect. IPv6 remains blocked for $MACADDR."
                            fi
                            break # Exit on first valid prefix found, even if routing fails
                        fi
                    fi
                    RETRY_COUNT=$((RETRY_COUNT+1))
                    if [ $RETRY_COUNT -eq 1 ]; then
                        logger -t wg-dhcp-hotplug "[$WG_INTERFACE] IPv6 not found for $MACADDR, retrying every 2s..."
                    fi
                done
                if [ $RETRY_COUNT -eq $MAX_RETRIES ]; then
                    logger -t wg-dhcp-hotplug "[$WG_INTERFACE] WARNING: IPv6 not found for $MACADDR after $((MAX_RETRIES * 2))s. IPv6 remains blocked."
                fi
            ) &
        else
            # IPv4-only tunnel: Block IPv6 for this client
            logger -t wg-dhcp-hotplug "[$WG_INTERFACE] Blocking IPv6 for $IPADDR ($MACADDR) on IPv4-only tunnel."
            ip6tables -A $BLOCK_IPV4_ONLY_CHAIN -m mac --mac-source $MACADDR -j DROP
        fi
    else
        logger -t wg-dhcp-hotplug "[$WG_INTERFACE] New client $IPADDR ($MACADDR) detected, but tunnel is down. Applying kill switch."
        iptables -N $KS_CHAIN 2>/dev/null; ip6tables -N $KS_CHAIN 2>/dev/null
        iptables -C FORWARD -j $KS_CHAIN 2>/dev/null || iptables -I FORWARD 1 -j $KS_CHAIN
        ip6tables -C FORWARD -j $KS_CHAIN 2>/dev/null || ip6tables -I FORWARD 1 -j $KS_CHAIN
        iptables -A $KS_CHAIN -s $IPADDR -j REJECT --reject-with icmp-host-prohibited
        ip6tables -A $KS_CHAIN -m mac --mac-source $MACADDR -j REJECT --reject-with icmp6-adm-prohibited
    fi
else
    # This client is NOT in the VPN list, so clean up any old rules
    logger -t wg-dhcp-hotplug "[$WG_INTERFACE] Client $IPADDR ($MACADDR) is on a non-VPN network. Cleaning up any lingering rules."

    # 1. Clean up IPv4 routing rule
    ip rule del from $IPADDR table $ROUTING_TABLE 2>/dev/null
    logger -t wg-dhcp-hotplug "[$WG_INTERFACE] Removed IPv4 routing rule for $IPADDR"

    # 2. Clean up IPv6 firewall block rule (from BOTH potential chains)
    lan_ifaces=$(ip link show | grep -o 'br-lan[^:]*' | tr '\n' ' ')
    [ -z "$lan_ifaces" ] && lan_ifaces="br-lan"
    for lan_if in $lan_ifaces; do
        ip6tables -D $BLOCK_CHAIN -i $lan_if ! -o $WG_INTERFACE -m mac --mac-source $MACADDR -j DROP 2>/dev/null
    done
    ip6tables -D $BLOCK_IPV4_ONLY_CHAIN -m mac --mac-source $MACADDR -j DROP 2>/dev/null

    # 3. Find and clean up lingering IPv6 *routing* rule
    ipv6_addrs=$(ip -6 neigh show | grep -i "$MACADDR" | grep -v "fe80:" | awk '{print $1}')
    if [ -n "$ipv6_addrs" ]; then
        prefix=$(echo "$ipv6_addrs" | head -n1 | cut -d: -f1-4)
        if [ -n "$prefix" ]; then
            logger -t wg-dhcp-hotplug "[$WG_INTERFACE] Removing IPv6 route for ${prefix}::/64 from table $ROUTING_TABLE"
            ip -6 rule del from ${prefix}::/64 table $ROUTING_TABLE 2>/dev/null
        fi
    fi
fi

ip route flush cache
EOF_DHCP_PBR
    sed -i "s|INTERFACE_NAME_PLACEHOLDER|$INTERFACE_NAME|g" "$DHCP_HOTPLUG_SCRIPT"
    sed -i "s|ROUTING_TABLE_PLACEHOLDER|$ROUTING_TABLE|g" "$DHCP_HOTPLUG_SCRIPT"
    sed -i "s|VPN_IPS_PLACEHOLDER|$VPN_IPS|g" "$DHCP_HOTPLUG_SCRIPT"
    sed -i "s|IPV6_SUPPORTED_PLACEHOLDER|$IPV6_SUPPORTED|g" "$DHCP_HOTPLUG_SCRIPT"
    sed -i "s|VPN_DNS_PLACEHOLDER|$VPN_DNS_SERVERS|g" "$DHCP_HOTPLUG_SCRIPT"
    chmod +x "$DHCP_HOTPLUG_SCRIPT"
else
    echo "DHCP hotplug script $DHCP_HOTPLUG_SCRIPT already exists. Skipping (use --force to overwrite)."
fi

# Create cleanup script
cat > /etc/hotplug.d/iface/99-${INTERFACE_NAME}-cleanup << 'EOFCLEANUP'
#!/bin/sh
[ "$ACTION" = "ifdown" ] || exit 0
[ "$INTERFACE" = "INTERFACE_NAME_PLACEHOLDER" ] || exit 0
VPN_IPS="VPN_IPS_PLACEHOLDER"
VPN_DNS="VPN_DNS_PLACEHOLDER"
ROUTING_TABLE="ROUTING_TABLE_PLACEHOLDER"
IPSET_NAME="IPSET_NAME_PLACEHOLDER"
MARK_CHAIN="mark_${WG_INTERFACE}"
DNS_BLOCK_CHAIN_v4="dns_block_v4_${WG_INTERFACE}"
DNS_BLOCK_CHAIN_v6="dns_block_v6_${WG_INTERFACE}"
MARK_VALUE="0x${ROUTING_TABLE}"
WG_INTERFACE="INTERFACE_NAME_PLACEHOLDER"
KS_CHAIN="${WG_INTERFACE}_killswitch"
BLOCK_CHAIN="${WG_INTERFACE}_ipv6_block"
BLOCK_IPV4_ONLY_CHAIN="${WG_INTERFACE}_ipv4_only_block"

ip_to_int() {
    local a b c d; IFS=. read -r a b c d <<EOF
$1
EOF
    echo "$(( (a << 24) | (b << 16) | (c << 8) | d ))"
}
is_in_subnet() {
    local ip_to_check="$1"; local subnet_cidr="$2"
    local ip_int subnet prefix network_int mask
    ip_int=$(ip_to_int "$ip_to_check")
    subnet="${subnet_cidr%/*}"; prefix="${subnet_cidr#*/}"
    network_int=$(ip_to_int "$subnet")
    i=0; mask=0
    while [ $i -lt $prefix ]; do
        mask=$(( (mask >> 1) | 0x80000000 )); i=$((i+1))
    done
    [ $(( ip_int & mask )) -eq $(( network_int & mask )) ] && return 0 || return 1
}
# Cleanup function for DNS rules
cleanup_vpn_dns() {
    local wg_interface="$1"

    local nat_chain="vpn_dns_nat_${wg_interface}"
    local filter_chain="vpn_dns_filter_${wg_interface}"
    local isolation_chain="vpn_dns_isolate_${wg_interface}"

    # Clean up nat chain
    iptables -t nat -D PREROUTING -j $nat_chain 2>/dev/null
    iptables -t nat -F $nat_chain 2>/dev/null
    iptables -t nat -X $nat_chain 2>/dev/null

    # Clean up filter chain
    iptables -D FORWARD -j $filter_chain 2>/dev/null
    iptables -F $filter_chain 2>/dev/null
    iptables -X $filter_chain 2>/dev/null

    # Clean up isolation chain
    iptables -D FORWARD -j $isolation_chain 2>/dev/null
    iptables -F $isolation_chain 2>/dev/null
    iptables -X $isolation_chain 2>/dev/null

    logger -t wireguard "[$wg_interface] DNS rules cleaned up"
}
# Clean up ALL potential IPv6 blocking chains
ip6tables -F $BLOCK_CHAIN 2>/dev/null
ip6tables -D FORWARD -j $BLOCK_CHAIN 2>/dev/null
ip6tables -X $BLOCK_CHAIN 2>/dev/null
ip6tables -F $BLOCK_IPV4_ONLY_CHAIN 2>/dev/null
ip6tables -D FORWARD -j $BLOCK_IPV4_ONLY_CHAIN 2>/dev/null
ip6tables -X $BLOCK_IPV4_ONLY_CHAIN 2>/dev/null

# Clean up policy routing rules
while ip rule | grep -q "lookup $ROUTING_TABLE"; do
    ip rule del $(ip rule | grep "lookup $ROUTING_TABLE" | head -n1)
done
while ip -6 rule | grep -q "lookup $ROUTING_TABLE"; do
    ip -6 rule del $(ip -6 rule | grep "lookup $ROUTING_TABLE" | head -n1)
done

# Clean up firewall mark rules
logger -t wireguard "[$WG_INTERFACE] Cleaning up DNS leak-prevention firewall mark $MARK_VALUE."
ip rule del fwmark $MARK_VALUE table $ROUTING_TABLE 2>/dev/null
ip -6 rule del fwmark $MARK_VALUE table $ROUTING_TABLE 2>/dev/null

lan_ifaces=$(ip link show | grep -o 'br-lan[^:]*' | tr '\n' ' ')
[ -z "$lan_ifaces" ] && lan_ifaces="br-lan"
for lan_if in $lan_ifaces; do
    iptables -t mangle -D PREROUTING -i $lan_if -j $MARK_CHAIN 2>/dev/null
    ip6tables -t mangle -D PREROUTING -i $lan_if -j $MARK_CHAIN 2>/dev/null
done
iptables -t mangle -F $MARK_CHAIN 2>/dev/null
iptables -t mangle -X $MARK_CHAIN 2>/dev/null
ip6tables -t mangle -F $MARK_CHAIN 2>/dev/null
ip6tables -t mangle -X $MARK_CHAIN 2>/dev/null

# Clean up specific CONNMARK restore rules
iptables -t mangle -D OUTPUT -p udp --sport 53 -j CONNMARK --restore-mark 2>/dev/null
iptables -t mangle -D OUTPUT -p tcp --sport 53 -j CONNMARK --restore-mark 2>/dev/null
ip6tables -t mangle -D OUTPUT -p udp --sport 53 -j CONNMARK --restore-mark 2>/dev/null
ip6tables -t mangle -D OUTPUT -p tcp --sport 53 -j CONNMARK --restore-mark 2>/dev/null

# *** CORRECTED: Clean up DNS backstop rules ***
logger -t wireguard "[$WG_INTERFACE] Cleaning up DNS backstop rules."
iptables -t mangle -D OUTPUT -m mark --mark $MARK_VALUE -j $DNS_BLOCK_CHAIN_v4 2>/dev/null
iptables -t mangle -F $DNS_BLOCK_CHAIN_v4 2>/dev/null
iptables -t mangle -X $DNS_BLOCK_CHAIN_v4 2>/dev/null

ip6tables -t mangle -D OUTPUT -m mark --mark $MARK_VALUE -j $DNS_BLOCK_CHAIN_v6 2>/dev/null
ip6tables -t mangle -F $DNS_BLOCK_CHAIN_v6 2>/dev/null
ip6tables -t mangle -X $DNS_BLOCK_CHAIN_v6 2>/dev/null

# Activate Kill Switch
logger -t wireguard "[$WG_INTERFACE] Interface is down. Activating kill switch."
iptables -N $KS_CHAIN 2>/dev/null; ip6tables -N $KS_CHAIN 2>/dev/null
iptables -C FORWARD -j $KS_CHAIN 2>/dev/null || iptables -I FORWARD 1 -j $KS_CHAIN
ip6tables -C FORWARD -j $KS_CHAIN 2>/dev/null || ip6tables -I FORWARD 1 -j $KS_CHAIN

for item in $VPN_IPS; do
    logger -t wireguard "[$WG_INTERFACE] Blocking all traffic from $item."
    iptables -A $KS_CHAIN -s $item -j REJECT --reject-with icmp-host-prohibited
    case "$item" in
        */*) # For subnets, find all known MACs and block them for IPv6
            if [ -f /cfg/dhcp.leases ]; then
                while read -r exp mac ip host; do
                    if is_in_subnet "$ip" "$item"; then
                        ip6tables -A $KS_CHAIN -m mac --mac-source $mac -j REJECT --reject-with icmp6-adm-prohibited
                    fi
                done < /cfg/dhcp.leases
            fi
            ;;
        *) # For individual IPs, get MAC directly
            mac=$(ip neigh show "$item" | grep -o '[0-9a-f:]\{17\}' | head -1)
            if [ -n "$mac" ] && [ "$mac" != "<incomplete>" ]; then
                ip6tables -A $KS_CHAIN -m mac --mac-source $mac -j REJECT --reject-with icmp6-adm-prohibited
            fi
            ;;
    esac
done

ip route flush table $ROUTING_TABLE 2>/dev/null
ip -6 route flush table $ROUTING_TABLE 2>/dev/null
ip route flush cache; ip -6 route flush cache 2>/dev/null
logger -t wireguard "[$WG_INTERFACE] Interface down and routing cleaned up"

cleanup_vpn_dns "$WG_INTERFACE"

# ADDED: Clean up dnsmasq config and ipset
IPSET_NAME="vpn_${WG_INTERFACE}"
DNSMASQ_CONF="/tmp/dnsmasq.d/99-${WG_INTERFACE}-dns.conf"

logger -t wireguard "[$WG_INTERFACE] Cleaning up ipset and dnsmasq config."
rm -f $DNSMASQ_CONF

# *** FIX 1: Do NOT destroy the ipset. It will be flushed by the ifup script. ***
# ipset destroy $IPSET_NAME 2>/dev/null

# Reload dnsmasq to apply changes (remove config)
( /etc/init.d/dnsmasq reload >/dev/null 2>&1 ) &
EOFCLEANUP
sed -i "s|INTERFACE_NAME_PLACEHOLDER|$INTERFACE_NAME|g" /etc/hotplug.d/iface/99-${INTERFACE_NAME}-cleanup
sed -i "s|VPN_IPS_PLACEHOLDER|$VPN_IPS|g" /etc/hotplug.d/iface/99-${INTERFACE_NAME}-cleanup
sed -i "s|ROUTING_TABLE_PLACEHOLDER|$ROUTING_TABLE|g" /etc/hotplug.d/iface/99-${INTERFACE_NAME}-cleanup
sed -i "s|IPSET_NAME_PLACEHOLDER|$IPSET_NAME|g" /etc/hotplug.d/iface/99-${INTERFACE_NAME}-cleanup
chmod +x /etc/hotplug.d/iface/99-${INTERFACE_NAME}-cleanup

if [ "$SKIP_INTERFACE_CONFIG" -eq 0 ]; then
    uci commit network
    uci commit firewall
fi

# Reload dnsmasq to apply selective DNS rules
if [ "$RESTART_SERVICES" -eq 1 ]; then
    if [ -n "$VPN_DNS_SERVERS" ] && [ -f "$DNSMASQ_CONF" ]; then
       echo "Reloading dnsmasq to apply selective DNS rules..."
       /etc/init.d/dnsmasq reload
    fi
fi

echo "Configuration applied successfully!"

if [ "$RESTART_SERVICES" -eq 1 ]; then
    if [ "$SKIP_INTERFACE_CONFIG" -eq 0 ]; then
        echo "Restarting network and firewall services..."
        /etc/init.d/network restart
        /etc/init.d/firewall restart
    else
        echo "Applying routing configuration..."
        # Reload dnsmasq to apply selective DNS rules
        if [ "$RESTART_SERVICES" -eq 1 ]; then
            if [ -n "$VPN_DNS_SERVERS" ] && [ -f "$DNSMASQ_CONF" ]; then
                echo "Reloading dnsmasq to apply selective DNS rules..."
                /etc/init.d/dnsmasq reload
            fi
        fi
        ifdown $INTERFACE_NAME 2>/dev/null || true
        sleep 1
        ifup $INTERFACE_NAME
    fi
else
    echo "Skipping service restarts (--no-restart set). Please restart services manually."
    echo "To apply changes, run:"
    echo "/etc/init.d/network restart && /etc/init.d/firewall restart && /etc/init.d/dnsmasq reload"
    echo "Or, if interface already exists:"
    echo "ifdown $INTERFACE_NAME && ifup $INTERFACE_NAME"
fi

echo ""
echo "✅ WireGuard client setup complete!"
echo "Interface: $INTERFACE_NAME"
echo "Policy routing: Dynamically managing clients in '$VPN_IPS'"
echo ""

Reserved. Placeholder for future updates.

Version Date Posted
v1.0.0 2025-11-02

Very nice :ok_hand: