Wireguard VPN w/ namespace killswitch

If someone want to improve this "all-in-one" script using namespace, here is the PROTOTYPE.

Features

  • Usage: sys-net ↔ sys-firewall ↔ sys-vpn ↔ appvms
  • Network isolation via namespaces
  • Killswitch-like protection when VPN is down
  • VPN-only connectivity for all traffic
  • DNS leak protection through NAT rules
  • AppVM DNS NAT compatibility
  • IPv6 disabled

wireguard-namespace-qube.sh

#!/bin/bash

################################################################################
# File Name    : wireguard-namespace-qube.sh
# Description  : Advanced WireGuard setup with network namespace as killswitch
#                for Qubes OS. This setup uses network namespace isolation to
#                prevent leaks, NFTables killswitch that blocks all non-VPN
#                traffic, IPv6 disabled and automatic connection verification
#                at the end.
# Usage        : • Transfer this script from appvm to dom0 with:
#                [user@dom0 ~]$ qvm-run --pass-io appvm 'cat ~/wireguard-namespace-qube.sh' > ~/wireguard-namespace-qube.sh
#                • Make the script executable with:
#                [user@dom0 ~]$ chmod +x ~/wireguard-namespace-qube.sh
#                • Comment Address, DNS and MTU, then Place your WireGuard config
#                file named wg0.conf to wireguard folder in dom0:
#                [user@dom0 ~]$ qvm-run --pass-io appvm 'cat ~/wg0.conf' > ~/wireguard/wg0.conf
#                • Run the script with:
#                [user@dom0 ~]$ bash ~/wireguard-namespace-qube.sh
# Author       : Me and the bois
# License      : Free of charge, no warranty
# Last edited  : 2025-10-03
################################################################################

# Configurationz
BASE_TEMPLATE="debian-12-minimal"
CUSTOM_TEMPLATE="debian-vpn-template"
VPN_SERVICE="sys-vpn"
WG_CONFIG_SOURCE="/home/user/wireguard" # Comment Address, DNS and MTU of wg0.conf

# Function to get VM IP address
get_vm_ip() {
    local vm="$1"
    qvm-ls -n | grep "^$vm" | awk '{print $3}' | grep -E '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$' | head -n1
}

# Strategy to create script content in a temporary file and transfer it
create_script_in_vm() {
    local script_name="$1"
    local script_content="$2"
    local temp_file="/tmp/$(basename "$script_name")"

    echo -e "$script_content" > "$temp_file"
    qvm-copy-to-vm "$VPN_SERVICE" "$temp_file"
    qvm-run -p -u root "$VPN_SERVICE" "mv '/home/user/QubesIncoming/dom0/$(basename "$temp_file")' '$script_name' && chown root:root '$script_name' && chmod 755 '$script_name'"
    rm -f "$temp_file"
}

# Step 1: Verify and create base template
create_custom_template() {
    echo -e "\nStep 1: Verifying and creating base template: $BASE_TEMPLATE..."

    if ! qvm-check "$BASE_TEMPLATE"; then
        echo -e "\nInstalling $BASE_TEMPLATE..."
        sudo qubes-dom0-update "qubes-template-$BASE_TEMPLATE"
    fi

    qvm-shutdown --wait "$BASE_TEMPLATE"

    if qvm-check "$BASE_TEMPLATE"; then
        echo -e "\nUpdating $BASE_TEMPLATE..."
        sudo qubesctl --show-output --skip-dom0 --targets="$BASE_TEMPLATE" state.sls update.qubes-vm
    fi

    echo -e "\nBase template setup complete"
}

# Step 2: Verify and create custom template with packages (change as needed)
install_packages() {
    echo -e "\nStep 2: Creating and installing required packages in $CUSTOM_TEMPLATE..."

    if ! qvm-check "$CUSTOM_TEMPLATE"; then
        echo -e "\nCreating $CUSTOM_TEMPLATE by cloning from $BASE_TEMPLATE..."
        qvm-clone "$BASE_TEMPLATE" "$CUSTOM_TEMPLATE"
        qvm-prefs "$CUSTOM_TEMPLATE" label orange

        echo -e "\nStarting $CUSTOM_TEMPLATE for package installation..."
        qvm-start "$CUSTOM_TEMPLATE"

        echo -e "\n Configuring template..."
        qvm-run -p -u root "$CUSTOM_TEMPLATE" "echo 'TERM=xterm' >> /etc/environment"
        qvm-run -p -u root "$CUSTOM_TEMPLATE" "sed -i 's/^# *\(en_US.UTF-8\)/\1/' /etc/locale.gen"
        qvm-run -p -u root "$CUSTOM_TEMPLATE" "locale-gen en_US.UTF-8"

        echo -e "\nInstalling packages in $CUSTOM_TEMPLATE..."
        qvm-run -u root "$CUSTOM_TEMPLATE" "
apt update -y && apt install -y --no-install-recommends \
qubes-core-agent-passwordless-root \
qubes-core-agent-networking \
wireguard-tools \
nftables \
iproute2 \
dnsutils \
less \
psmisc \
curl \
xfce4-terminal
"

        echo -e "\nCustom template $CUSTOM_TEMPLATE created successfully"
    else
        echo -e "\nCustom template $CUSTOM_TEMPLATE already exists"
    fi

    qvm-shutdown --wait "$CUSTOM_TEMPLATE"
}

# Step 3: Create VPN qube service
create_vpn_qube() {
    echo -e "\nStep 3: Verifying and creating VPN qube service: $VPN_SERVICE..."

    if qvm-check "$VPN_SERVICE"; then
        qvm-shutdown --wait "$VPN_SERVICE"
        qvm-remove "$VPN_SERVICE"
        sleep 2
    fi

    echo -e "\nCreating $VPN_SERVICE..."
    qvm-create \
      --class AppVM \
      --template "$CUSTOM_TEMPLATE" \
      --label orange \
      --property netvm=sys-firewall \
      --property provides_network=True \
      --property vcpus=1 \
      --property memory=300 \
      --property maxmem=400 \
      --property autostart=False \
      --property include_in_backups=False \
      "$VPN_SERVICE"

    qvm-features "$VPN_SERVICE" ipv6 ''
    echo -e "\nDisabled IPv6 in $VPN_SERVICE"

    qvm-service "$VPN_SERVICE" qubes-firewall on

    echo -e "\nStarting $VPN_SERVICE to apply configuration..."
    qvm-start "$VPN_SERVICE"

    echo -e "\nVPN qube created and configured"
}

# Step 4: Copy the configuration file from dom0 to the VPN service qube
transfer_config() {
    echo -e "\nStep 4: Transferring WireGuard configuration to the VPN service qube..."

    if [[ ! -f "$WG_CONFIG_SOURCE/wg0.conf" ]]; then
        echo -e "\nError: Config file not found at $WG_CONFIG_SOURCE/wg0.conf"
        return 1
    fi

    echo -e "\nCopying..."
    qvm-copy-to-vm "$VPN_SERVICE" "$WG_CONFIG_SOURCE/wg0.conf"
    sleep 2

    qvm-run -u root "$VPN_SERVICE" "mkdir -p '/rw/config/vpn'"
    qvm-run -u root "$VPN_SERVICE" "mv '/home/user/QubesIncoming/dom0/wg0.conf' '/rw/config/vpn/'"
    qvm-run -u root "$VPN_SERVICE" "chmod 600 '/rw/config/vpn/wg0.conf'"
    echo -e "\nConfig file moved to: /rw/config/vpn/wg0.conf"
}

# Step 5: Configure network namespace scripts
configure_namespace_scripts() {
    echo -e "\nStep 5: Configuring network namespace scripts in $VPN_SERVICE..."

    qvm-run -u root $VPN_SERVICE "mkdir -p /rw/config /rw/config/vpn"

    VPN_IP=$(get_vm_ip "$VPN_SERVICE")
    echo -e "\nVPN qube IP: $VPN_IP"

SETUP_SCRIPT_CONTENT='#!/bin/bash

echo -e "\nStarting WireGuard namespace setup"

sleep 5

INTERFACE="eth0"
echo -e "\nUsing interface: $INTERFACE"

# Get basic network configuration
PHYSICAL_IP=$(ip -4 addr show "$INTERFACE" | grep -oP '\''(?<=inet\s)\d+(\.\d+){3}/\d+'\'')
PHYSICAL_ROUTE=$(ip route | grep default | awk '\''{print $3}'\'')
WG_CONFIG="/rw/config/vpn/wg0.conf"

# Extract DNS from WireGuard config (handle both commented and uncommented)
DNS_LINE=$(grep -E "^(#\s*)?DNS" "$WG_CONFIG" | head -1)
if [ -n "$DNS_LINE" ]; then
    # Remove comment characters if present and extract value
    CLEAN_DNS_LINE=$(echo "$DNS_LINE" | sed -E '\''s/^#\s*//'\'')
    DNS_SERVERS=$(echo "$CLEAN_DNS_LINE" | cut -d= -f2 | sed '\''s/^[[:space:]]*//;s/[[:space:]]*$//'\'' | tr "," " ")

    # Take the first DNS server for endpoint resolution
    WG_DNS=$(echo "$DNS_SERVERS" | cut -d" " -f1)
    echo -e "\nUsing DNS from WireGuard config: $DNS_SERVERS"
else
    echo -e "\nWarning: No DNS found in WireGuard config"
    # Use fallback DNS for endpoint resolution only
    WG_DNS="9.9.9.9"
    DNS_SERVERS=""
fi

echo -e "\nPhysical IP: $PHYSICAL_IP"
echo -e "\nPhysical route: $PHYSICAL_ROUTE"
echo -e "\nWG config: $WG_CONFIG"
echo -e "\nWG DNS: $WG_DNS"

# Extract only the WireGuard IP
WG_IP=$(grep -E '\''^(#)?Address'\'' "$WG_CONFIG" | head -n1 | cut -d'\''='\'' -f2 | cut -d'\''/'\'' -f1 | tr -d '\'' '\'')
WG_IP="${WG_IP}/32"
echo -e "\nUsing WG_IP: $WG_IP"

# Resolve endpoint BEFORE moving interfaces
echo -e "\nStep 1: Resolving WireGuard endpoint..."
ENDPOINT=$(grep '\''^Endpoint'\'' "$WG_CONFIG" | cut -d'\''='\'' -f2 | tr -d '\'' '\'' | cut -d: -f1)
PORT=$(grep '\''^Endpoint'\'' "$WG_CONFIG" | cut -d'\''='\'' -f2 | tr -d '\'' '\'' | cut -d: -f2)

RESOLVED_IP=""
if [ -n "$ENDPOINT" ]; then
    echo -e "\nResolving endpoint: $ENDPOINT"

    # Use multiple DNS servers for reliability
    for dns_server in $WG_DNS 10.139.1.1 9.9.9.9; do
        echo "Trying DNS: $dns_server"
        IP=$(dig +short +time=2 +tries=2 "$ENDPOINT" @"$dns_server" 2>/dev/null | grep -E '\''^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$'\'' | head -1)
        if [ -n "$IP" ] && [ "$IP" != "127.0.0.1" ]; then
            echo -e "\nSuccessfully resolved $ENDPOINT to $IP using DNS $dns_server"
            RESOLVED_IP="$IP"
            break
        else
            echo "Failed to resolve with $dns_server"
        fi
    done
fi

if [ -z "$RESOLVED_IP" ]; then
    echo -e "\nError: Could not resolve endpoint $ENDPOINT with any DNS server"
    echo "Please check your network connection and DNS configuration"
    exit 1
fi

# Create temporary config with resolved IP
cp "$WG_CONFIG" /tmp/wg-config-temp.conf
if [ -n "$RESOLVED_IP" ]; then
    sed -i "s/$ENDPOINT:[0-9]*/$RESOLVED_IP:$PORT/g" /tmp/wg-config-temp.conf
else
    echo -e "\nWarning: Could not resolve endpoint, using original config"
fi

echo -e "\nCreating netns named physical..."
ip netns add physical
ip link set "$INTERFACE" netns physical
ip -n physical link add wg0 type wireguard

echo -e "\nMoving wg0 to default netns..."
ip -n physical link set wg0 netns 1

echo -e "\nReconfigure eth0, because of it lost its settings after moving to the physical namespace."
ip -n physical addr add "$PHYSICAL_IP" dev "$INTERFACE"
ip -n physical link set eth0 up
ip -n physical route add default via "$PHYSICAL_ROUTE" dev eth0 onlink

echo -e "\nConfiguring WireGuard..."
# Remove conflicting lines from config
sed -i '\''/^Address/d'\'' /tmp/wg-config-temp.conf
sed -i '\''/^DNS/d'\'' /tmp/wg-config-temp.conf
sed -i '\''/^MTU/d'\'' /tmp/wg-config-temp.conf

wg setconf wg0 /tmp/wg-config-temp.conf
rm -f /tmp/wg-config-temp.conf
ip addr add "$WG_IP" dev wg0
ip link set wg0 up

echo -e "\nSetting up routing..."
ip route add default dev wg0

echo -e "\nConfiguring DNS for sys-vpn itself..."

# Backup original resolv.conf for sys-vpn itself
cp /etc/resolv.conf /etc/resolv.conf.backup

# Extract DNS from WireGuard config for sys-vpn itself
DNS_LINE=$(grep -E "^(#\s*)?DNS" "$WG_CONFIG" | head -1)
if [ -n "$DNS_LINE" ]; then
    # Remove comment characters if present and extract value
    CLEAN_DNS_LINE=$(echo "$DNS_LINE" | sed -E '\''s/^#\s*//'\'')
    DNS_SERVERS=$(echo "$CLEAN_DNS_LINE" | cut -d= -f2 | sed '\''s/^[[:space:]]*//;s/[[:space:]]*$//'\'' | tr "," " ")

    # Create new resolv.conf using only the DNS from WireGuard config for sys-vpn itself
    cat > /etc/resolv.conf </dev/null 2>&1; then
    ip link set "$INTERFACE" up
    PHYSICAL_ROUTE=$(ip route | grep default | awk '\''{print $3}'\'')
    if [ -n "$PHYSICAL_ROUTE" ]; then
        ip route add default via "$PHYSICAL_ROUTE" dev "$INTERFACE" onlink
    fi
fi

echo -e "\nWireGuard namespace removal complete"'

# NFTables dynamic generation script
NFT_CONFIG_SCRIPT_CONTENT='#!/bin/bash

# Extract DNS from WireGuard config - handle commented lines
WG_CONFIG="/rw/config/vpn/wg0.conf"

# First, uncomment DNS line if it exists commented
if grep -q "^#\s*DNS" "$WG_CONFIG"; then
    echo "Uncommenting DNS line in WireGuard config"
    sudo sed -i '\''s/^#\s*DNS/DNS/'\'' "$WG_CONFIG"
fi

# Extract DNS servers
DNS_LINE=$(grep "^DNS" "$WG_CONFIG" | head -1)

if [ -n "$DNS_LINE" ]; then
    # Extract DNS servers and clean them
    DNS_SERVERS=$(echo "$DNS_LINE" | cut -d= -f2 | sed '\''s/ //g'\'' | tr "," " ")
    VPNDNS1=$(echo "$DNS_SERVERS" | awk '\''{print $1}'\'')
    VPNDNS2=$(echo "$DNS_SERVERS" | awk '\''{print $2}'\'')

    echo "Using DNS from WireGuard config: $DNS_SERVERS"

    # Create NFTables config with STATIC DNS IPs (no variables)
    if [ -n "$VPNDNS1" ] && [[ "$VPNDNS1" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
        cat > /rw/config/nftables.conf < /rw/config/nftables.conf </dev/null || true"
    qvm-run -u root "$VPN_SERVICE" "rmdir '/home/user/QubesIncoming' 2>/dev/null || true"
    qvm-run -u root "$VPN_SERVICE" "/rw/config/removal-wireguard-namespace.sh"
    sleep 2

    echo -e "\nGenerating NFTables configuration..."
    qvm-run -u root "$VPN_SERVICE" "/rw/config/generate-nftables.sh"

    echo -e "\nConfiguring NFTables rules..."
    if qvm-run -u root "$VPN_SERVICE" "nft -c -f /rw/config/nftables.conf && nft -f /rw/config/nftables.conf"; then
        echo -e "\nNFTables configured successfully"
    else
        echo -e "\nNFTables configuration failed"
        return 1
    fi
    sleep 2

    echo -e "\nTesting VPN setup"
    if qvm-run -u root "$VPN_SERVICE" "/rw/config/setup-wireguard-namespace.sh"; then
        echo -e "\nWireGuard setup successful"
        sleep 2
        qvm-run -u root "$VPN_SERVICE" "/rw/config/manage-vpn.sh status"

        echo -e "\nTesting connectivity"
        qvm-run -p -u root "$VPN_SERVICE" "ping -c 3 -W 5 9.9.9.9 && echo 'VPN IP connectivity working' || echo 'VPN IP connectivity failed'"

        echo -e "\nTesting DNS with public resolver"
        qvm-run -p -u root "$VPN_SERVICE" "nslookup duckduckgo.com 9.9.9.9 && echo 'DNS resolution working' || echo 'DNS resolution failed'"

        echo -e "\nTesting DNS with internal resolver"
        qvm-run -p -u root "$VPN_SERVICE" "nslookup duckduckgo.com 10.139.1.1 && echo 'DNS resolution working' || echo 'DNS resolution failed'"

        echo -e "\nSetup complete! $VPN_SERVICE is ready."
    else
        echo -e "\nSetup failed"
        qvm-run -p -u root "$VPN_SERVICE" "/rw/config/removal-wireguard-namespace.sh"
        return 1
    fi
}

# Main function
main() {
    echo -e "\nStarting WireGuard VPN setup..."

    create_custom_template
    install_packages
    create_vpn_qube
    transfer_config
    configure_namespace_scripts
    configure_autostart
    create_management_script
    finalize

    echo -e "\nSetup completed successfully!"
}

# Run
main


Dynamic NFTables ruleset generation

# Dynamic DNS configuration

# Flush Qubes DNS chain to avoid conflicts
flush chain qubes dnat-dns

# Table for NAT and PR-QBS chain
table ip nat {
    chain PR-QBS {
        type nat hook prerouting priority -100; policy accept;
        ip daddr 10.139.1.1 udp dport 53 dnat to $VPNDNS1
        ip daddr 10.139.1.1 tcp dport 53 dnat to $VPNDNS1
$(if [ -n "$VPNDNS2" ] && [[ "$VPNDNS2" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then echo "        ip daddr 10.139.1.1 udp dport 53 dnat to $VPNDNS2"; fi)
$(if [ -n "$VPNDNS2" ] && [[ "$VPNDNS2" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then echo "        ip daddr 10.139.1.1 tcp dport 53 dnat to $VPNDNS2"; fi)
    }

    chain postrouting {
        type nat hook postrouting priority 100; policy accept;
        oifname "wg0" masquerade
    }
}

# Filter table for forwarding
table ip filter {
    chain forward {
        type filter hook forward priority 0; policy drop;
        iifname "vif*" oifname "wg0" accept
        iifname "wg0" oifname "vif*" ct state related,established accept
    }
}

# Mangle table (for MSS clamping)
table ip mangle {
    chain forward {
        type filter hook forward priority 0; policy accept;
        tcp flags syn tcp option maxseg size set rt mtu;
    }
}


Note about the the VPN isolation:

sys-net ↔ sys-firewall ↔ sys-vpn ↔ appvms

If sys-vpn is compromised the kill-switch can be bypassed or altered.

Note about extra firewall:

sys-net ↔ sys-firewall1 ↔ sys-vpn ↔ sys-firewall2 ↔ appvms

Smaller attack surface, you can keep the firewall minimal and auditable.


References

Core References

NFTables

Wireguard

DNS

Qubes topics

  1. Wireguard VPN setup - Solene - Community Guides - Qubes OS Forum
  2. sys-wireguard - Salt Formulas for Qubes OS - ben-grande
  3. Qubes VPN Support - Tasket
  4. Qubes Mirage Firewall - Mirage
  5. Qubes OS Forum - General Discussion - Qubes cli firewall and DNS
  6. Notify-send issue - User Support - Qubes OS Forum
  7. Qubes OS Forum - User Support - Install inside appvm vs inside template
  8. Qubes OS Forum - User Support - Curl-proxy / wget-proxy scripts in Templates so users can add GPG distro keys linked to added external repositories

Other topics

Bash References

Regular expressions

Heredoc, escaping and quoting pitfalls