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
- forum.qubes-os.org — WireGuard VPN w/ namespace killswitch
- wireguard.com — Network namespaces (netns)
- Qubes OS - Data leaks - The Role of the Firewall - Documentation
- Qubes OS - Firewall - Documentation
- qvm-firewall – Manage VM outbound firewall
- Qubes OS - Config files - Documentation
- Qubes OS - How to make any file persistent (bind-dirs) - Documentation
- Qubes OS - VPN - "Documentation"
NFTables
- nft(8) manpage - Options (Debian Trixie)
- nftables - Scripting (wiki.nftables.org)
- How to use defined variables in nftables through terminal (Server Fault)
Wireguard
DNS
- DNS in NAT and PR-QBS chain - Qubes OS Forum (post #12)
- DNS nftables - Qubes OS Forum (post #56)
- openresolv - Configuration - Roy Marples
- openresolv - RESOLVCONF(8) - System Manager's Manual
- openresolv - RESOLVCONF.CONF(5) - File Formats Manual
- systemd - systemd-resolved.service and VPNs
- systemd - Network Configuration Synchronization Points
Qubes topics
- Wireguard VPN setup - Solene - Community Guides - Qubes OS Forum
- sys-wireguard - Salt Formulas for Qubes OS - ben-grande
- Qubes VPN Support - Tasket
- Qubes Mirage Firewall - Mirage
- Qubes OS Forum - General Discussion - Qubes cli firewall and DNS
- Notify-send issue - User Support - Qubes OS Forum
- Qubes OS Forum - User Support - Install inside appvm vs inside template
- Qubes OS Forum - User Support - Curl-proxy / wget-proxy scripts in Templates so users can add GPG distro keys linked to added external repositories