Deploying a sys-warp ProxyVM on Qubes OS
This post walks through setting up a ProxyVM that routes all traffic through Cloudflare WARP, with an nftables killswitch that prevents leaks if the tunnel goes down.
The setup uses wgcf (generates WireGuard profiles from Cloudflare’s WARP API) with wg-quick, backed by nftables. The killswitch blocks everything except the WireGuard tunnel itself. The catch-22 of “how do I reconnect if everything is blocked?” is resolved by letting WireGuard’s kernel fwmark bypass the killswitch.
Using wgcf instead of the official cloudflare-warp package avoids a known bug where warp-cli disconnect flushes the entire nftables ruleset.
Architecture
sys-net -> sys-firewall -> sys-warp -> domU(s)
sys-warp is an AppVM with provides_network=True. Downstream VMs connect through it and have no internet if the tunnel drops.
TemplateVM setup
Start the template that your ProxyVM uses and install wireguard-tools:
sudo apt update && sudo apt install wireguard-tools
Shut down the template.
Persist the config with bind-dirs
In Qubes, an AppVM’s file system resets on reboot – only /home, /usr/local and /rw survive. If we install or configure something in /etc or /usr, it’s gone after restart. That’s a problem because our WireGuard config lives in /etc/wireguard/.
Bind-dirs is a Qubes tool that makes specific folders persist by bind-mounting them from /rw onto the ephemeral paths. We’ll use it to keep /etc/wireguard/ across reboots.
Create /rw/config/qubes-bind-dirs.d/50_user.conf:
binds+=('/etc/wireguard')
Apply:
sudo /usr/lib/qubes/init/bind-dirs.sh
Download wgcf
sudo curl -L -o /usr/local/bin/wgcf \
https://github.com/ViRb3/wgcf/releases/latest/download/wgcf_2.2.31_linux_amd64
sudo chmod +x /usr/local/bin/wgcf
Register and generate config
sudo mkdir -p /etc/wireguard
sudo /usr/local/bin/wgcf register --accept-tos
sudo /usr/local/bin/wgcf generate
sudo mv wgcf-profile.conf /etc/wireguard/wgcf.conf
Adapt for Qubes
The generated config routes all traffic through WARP (AllowedIPs = 0.0.0.0/0, ::/0). That includes traffic to Qubes’ internal infrastructure – the DNS resolver, sys-firewall itself, and any inter-VM traffic. Those packets would get sent to Cloudflare, which can’t route them, so they’d fail. We need to fix this.
Open /etc/wireguard/wgcf.conf – the PrivateKey, Address, and PublicKey values were generated by wgcf and are specific to your device. Keep those as-is. You need to make these changes:
- Add
ListenPort = 2408 - Remove the
DNSline (Qubes handles DNS, we don’t need wg-quick to set it) - Add the PostUp/PostDown rules shown below, replacing the placeholders with your values
[Interface]
PrivateKey = <keep the generated value>
Address = <keep the generated value>
MTU = 1280
ListenPort = 2408
PostUp = ip route add <qubes-subnet> via <sys-firewall-ip> dev eth0 table 51820
PostUp = ip route add <another-subnet> via <sys-firewall-ip> dev eth0 table 51820
PostDown = ip route del <qubes-subnet> via <sys-firewall-ip> dev eth0 table 51820
PostDown = ip route del <another-subnet> via <sys-firewall-ip> dev eth0 table 51820
[Peer]
PublicKey = <keep the generated value>
AllowedIPs = 0.0.0.0/0, ::/0
Endpoint = engage.cloudflareclient.com:2408
Run ip route on your sys-warp VM. The default route shows your sys-firewall IP (e.g. 10.138.xxx.xxx). The other routes show the Qubes internal subnets – typically 10.137.0.0/16, 10.138.0.0/16, 10.139.0.0/16. Add one PostUp/PostDown pair per subnet.
Killswitch
The killswitch uses nftables with output and forward chains, both policy drop.
The output chain allows:
- Loopback
- Traffic through the
wgcfinterface - WireGuard tunnel packets (fwmark 51820) – this is the catch-22 exclusion
- DNS to Qubes internal resolvers
- DHCP
- IPv6 neighbour discovery
The forward chain allows:
- Qubes internal traffic (inter-VM, DNS, management)
- Traffic through the
wgcfinterface - Return traffic from
wgcf
When the tunnel is down, fwmark 51820 packets (WireGuard handshakes) can still reach Cloudflare. The tunnel re-establishes, and then all traffic flows through wgcf.
When a downstream VM (MTU 1500) sends TCP SYNs through the WireGuard tunnel (MTU 1280), it advertises an MSS of 1460. The server responds with data segments that size, which when WireGuard-encapsulated (1460 + 60 overhead = 1520) exceed the interface MTU. Without MSS clamping, the connection hangs – small responses like HTTP redirects fit in one packet and work, but full page loads fail.
Create /rw/config/warp-killswitch.nft:
table inet killswitch {}
delete table inet killswitch
table inet killswitch {
chain output {
type filter hook output priority filter; policy drop
oif lo accept
oifname "wgcf" accept
meta mark 51820 accept
ip daddr { <dns-server-1>, <dns-server-2> } udp dport 53 accept
ip daddr { <dns-server-1>, <dns-server-2> } tcp dport 53 accept
udp sport 68 udp dport 67 accept
icmpv6 type { nd-router-solicit, nd-router-advert, \
nd-neighbor-solicit, nd-neighbor-advert } accept
}
chain forward {
type filter hook forward priority filter - 1; policy drop
ip daddr { <qubes-subnet>, <another-subnet> } accept
oifname "wgcf" tcp flags syn tcp option maxseg size set 1200
oifname "wgcf" accept
iifname "wgcf" ct state established,related accept
}
}
# Clamp TCP MSS on forwarded SYNs to prevent MTU issues.
# Without this, downstream VMs (MTU 1500) advertise MSS 1460,
# but WireGuard overhead (60 bytes) makes the outer packet
# exceed the interface MTU, causing connections to hang.
table inet mss-clamp {
chain forward {
type filter hook forward priority 0; policy accept
tcp flags syn tcp option maxseg size set 1200
}
}
Find your DNS IPs in /etc/resolv.conf. They’re typically 10.139.1.1 and 10.139.1.2. Use the same subnet ranges here as you did in the PostUp rules. If your WireGuard MTU differs from 1280, adjust the MSS value accordingly – it should be WG_MTU - 40 (IP+TCP headers) - 60 (WireGuard overhead) = WG_MTU - 100. A value that’s too high won’t fix the issue; too low just reduces throughput slightly.
Create /rw/config/warp-killswitch.sh:
#!/bin/bash
set -e
KILLSWITCH_NFT="/rw/config/warp-killswitch.nft"
apply_killswitch() {
nft -f "$KILLSWITCH_NFT"
}
remove_killswitch() {
nft delete table inet killswitch 2>/dev/null || true
}
case "${1:-status}" in
apply|start) apply_killswitch ;;
remove|stop) remove_killswitch ;;
restart) remove_killswitch; apply_killswitch ;;
status)
if nft list table inet killswitch &>/dev/null; then
echo "Killswitch: ACTIVE"
nft list table inet killswitch
else
echo "Killswitch: INACTIVE"
fi
;;
watch)
while true; do
if ! nft list table inet killswitch &>/dev/null; then
apply_killswitch
fi
sleep 30
done
;;
esac
sudo chmod +x /rw/config/warp-killswitch.sh
Boot configuration
Create /rw/config/qubes-firewall-user-script:
#!/bin/bash
# Allow WireGuard handshake responses through Qubes INPUT firewall
nft add rule ip qubes custom-input \
ip saddr { 162.159.192.0/22 } \
udp dport 2408 ct state new,established,related accept 2>/dev/null
nft add rule ip6 qubes custom-input \
ip6 saddr 2606:4700:100::/48 \
udp dport 2408 ct state new,established,related accept 2>/dev/null
# Load killswitch
/rw/config/warp-killswitch.sh apply
# Start tunnel
wg-quick up /etc/wireguard/wgcf.conf
# Start watchdog
nohup /rw/config/warp-killswitch.sh watch >/dev/null 2>&1 &
sudo chmod +x /rw/config/qubes-firewall-user-script
This script runs at every boot. It does four things in order: opens the firewall for WireGuard responses, loads the killswitch (so no traffic leaks before the tunnel is up), starts the tunnel, and launches a watchdog that re-applies the killswitch if it ever gets removed. Since all these files live in /rw/config/ (which persists) and the script is triggered by Qubes’ boot process, everything survives reboots automatically.
If you reboot and downstream VMs can’t load websites (only small responses like HTTP redirects work), the MSS clamping isn’t applying. Check that the mss-clamp table loaded correctly with sudo nft list table inet mss-clamp.
Testing
Reboot to apply the boot script, or run the custom-input rules manually for the current session:
# Only needed before first reboot, the boot script does this automatically
sudo nft add rule ip qubes custom-input \
ip saddr { 162.159.192.0/22 } \
udp dport 2408 ct state new,established,related accept
sudo nft add rule ip6 qubes custom-input \
ip6 saddr 2606:4700:100::/48 \
udp dport 2408 ct state new,established,related accept
Bring things up:
sudo /rw/config/warp-killswitch.sh apply
sudo wg-quick up /etc/wireguard/wgcf.conf
sudo wg show wgcf
# Should show: latest handshake, transfer
curl -s https://www.cloudflare.com/cdn-cgi/trace
# Should show: warp=on
Killswitch test:
sudo wg-quick down wgcf
curl -s --max-time 5 https://1.1.1.1
# Should timeout
sudo wg-quick up /etc/wireguard/wgcf.conf
curl -s https://www.cloudflare.com/cdn-cgi/trace
# Should show: warp=on