Configuring a ProxyVM VPN Gateway

Issues with original guide:
Leaks packets outside tunnel in edge cases.
Leaks packets to AppVM DNS in edge cases.
Leaks packets to AppVM DNS if OpenVPN server pushes IPv6 DNS servers.
Leaks packets to AppVM DNS if Qubes install a higher priority DNAT rule.
Uses qubes-firewall-user-script which can silently fail meaning no kill switch is active.
Uses interface names instead of interface groups. Qubes have said groups are more stable for this.
Allows output traffic from within the VPN VM which adds additional attack surface. It doesn’t do DNAT for output DNS so it’s only partially working in the first place.
Notification displays “Starting …” because environment is not passed to the new login shell.
Modifies nftables chains that are managed by Qubes instead of only using custom chains or a separate table.

Issues unable to be addressed by this guide:
Qubes can update the templates and silently introduce leaks.
If the VPN firewall fails then forwarding is disabled but the network is still active so packets from the VPN VM itself can leak.
The network can be up and configured prior to the VPN firewall rules being added, however forwarding is only enabled after the rules have been added.
If the AppVM uses DoH or DoT then DNS will leak.

Steps to change:

  1. Use this command to find services to disable:

    dpkg -L openvpn | grep systemd
    
  2. Create the DNS-handling script.

    sudo gedit /rw/config/vpn/qubes-vpn-handler.sh
    

    Add the following:

    #!/bin/bash
    
    set -e
    export PATH="$PATH:/usr/sbin:/sbin"
      
    case "$1" in
      
    up)
    # To override DHCP DNS, assign DNS addresses to 'vpn_dns' env variable before calling this script;
    # Format is 'X.X.X.X  Y.Y.Y.Y [...]'
    if [[ -z "$vpn_dns" ]] ; then
        # Parses DHCP foreign_option_* vars to automatically set DNS address translation:
        for optionname in ${!foreign_option_*} ; do
            option="${!optionname}"
            unset fops; fops=($option)
            if [ ${fops[1]} == "DNS" ] ; then vpn_dns="$vpn_dns ${fops[2]}" ; fi
        done
    fi
      
    nft flush chain inet user-vpn prerouting-nat
    nft flush chain inet user-vpn forward-filter-dns
    if [[ -n "$vpn_dns" ]] ; then
        # Set DNS address translation in firewall:
        for addr in $vpn_dns; do
    		nft add rule inet user-vpn prerouting-nat iifgroup 2 udp dport 53 counter dnat ip to $addr
    		nft insert rule inet user-vpn forward-filter-dns iifgroup 2 udp dport 53 ip daddr $addr counter accept
    
    		nft add rule inet user-vpn prerouting-nat iifgroup 2 tcp dport 53 counter dnat ip to $addr
    		nft insert rule inet user-vpn forward-filter-dns iifgroup 2 tcp dport 53 ip daddr $addr counter accept
        done
        su - -c 'notify-send "$(hostname): LINK IS UP." --icon=network-idle' user
    else
        su - -c 'notify-send "$(hostname): LINK UP, NO DNS!" --icon=dialog-error' user
    fi
      
    ;;
    down)
    su - -c 'notify-send "$(hostname): LINK IS DOWN !" --icon=dialog-error' user
    
    # Restart the VPN automatically
    sleep 5s
    sudo /rw/config/rc.local
    ;;
    esac
    

    Save the script. Make it executable.

    sudo chmod +x /rw/config/vpn/qubes-vpn-handler.sh
    
  3. Set up iptables anti-leak rules. Edit the firewall script.

    sudo gedit /rw/config/vpn/qubes-vpn-firewall.sh
    
     Clear out the existing lines and add:
    
    #!/bin/bash
    
    set -e
    
    # Add the `qvpn` group to system. The sync and sleep were in the original script but might not be needed.
    groupadd -rf qvpn; sync; sleep 2s
    
    nft add table inet user-vpn
    
    nft 'add chain inet user-vpn forward-filter { type filter hook forward priority filter - 1; policy accept; }'
    
    # Block forwarding of connections through upstream network device (in case the vpn tunnel breaks or packets are routed
    # directly between upstream and tunnel interface).
    nft add rule inet user-vpn forward-filter meta iifgroup 1 counter drop
    nft add rule inet user-vpn forward-filter meta oifgroup 1 counter drop
    nft add rule inet user-vpn forward-filter meta mark set 3000 counter
    
    nft add chain inet user-vpn forward-filter-dns
    nft add rule inet user-vpn forward-filter jump forward-filter-dns
    
    # The up script adds a DNAT rule to prerouting-nat and a corresponding accept rule to forward-filter-dns. If the accept
    # rule is not hit then the DNAT was not applied and the packet should be dropped else it will go through the tunnel with
    # the AppVM DNS (DNS leak). This could happen if the tunnel is created after a packet passes prerouting-nat but before
    # the routing decision is made. If Qubes adds a higher priority DNAT DNS rule then DNS will stop working but not leak.
    nft add rule inet user-vpn forward-filter iifgroup 2 udp dport 53 counter drop
    nft add rule inet user-vpn forward-filter iifgroup 2 tcp dport 53 counter drop
    
    nft 'add chain inet user-vpn postrouting-filter { type filter hook postrouting priority filter - 1; policy accept; }'
    # Tunnel broke after forward-filter and the reroute check redirected packet to oifgroup 1.
    nft add rule inet user-vpn postrouting-filter meta mark 3000 oifgroup 1 counter drop
    
    # Drop all output through the tunnel interface and only allow traffic from the `qvpn` group to the uplink interface. Our
    # VPN client will run with group `qvpn`.
    nft 'add chain inet user-vpn output-filter { type filter hook output priority filter - 1; policy drop; }'
    nft add rule inet user-vpn output-filter meta oifgroup 1 skgid qvpn counter accept
    nft add rule inet user-vpn output-filter meta oifgroup 2 counter accept
    nft add rule inet user-vpn output-filter meta oifname "lo" counter accept
    nft add rule inet user-vpn output-filter counter
    
    # Chain for up script.
    nft 'add chain inet user-vpn prerouting-nat { type nat hook prerouting priority dstnat - 1; policy accept; }'
    
    # Rely on Qubes rules for masquerade.
    # Rely on Qubes rules for preventing inbound packets (they're dropped unless established,related).
    

    Save the script. Make it executable.

    sudo chmod +x /rw/config/vpn/qubes-vpn-firewall.sh
    
  4. Set up the VPN’s autostart.

    sudo gedit /rw/config/rc.local
    

    Clear out the existing lines and add:

    #!/bin/bash
    export VPN_CLIENT='openvpn'
    VPN_OPTIONS='--cd /rw/config/vpn/ --config openvpn-client.ovpn --daemon'
    
    sg qvpn -c "$VPN_CLIENT $VPN_OPTIONS"
    su - --whitelist-environment=VPN_CLIENT -c 'notify-send "$(hostname): Starting $VPN_CLIENT..." --icon=network-idle' user
    

    If you are using anything other than OpenVPN, change the VPN_CLIENT and VPN_OPTIONS variables to match your VPN software. Save the script. Make it executable.

    sudo chmod +x /rw/config/rc.local
    
  5. Create systemd service in TemplateVM

    sudo gedit /lib/systemd/system/qubes-vpn-firewall.service
    
    [Unit]
    Description=Qubes VPN firewall updater
    After=qubes-firewall.service
    Before=qubes-network.service
    
    [Service]
    type=oneshot
    ExecStart=/rw/config/vpn/qubes-vpn-firewall.sh
    
    [Install]
    RequiredBy=qubes-network.service
    
    sudo systemctl enable qubes-vpn-firewall.service
    

The numbers don’t match the original guide because I can’t get the formatting correct.

@unman @demi @marmarek

2 Likes