How To make an OpenVPN Gateway in Qubes (4.2, 4.3)

Original forum link
https://forum.qubes-os.org/t/38632
Original poster
longTimeQubesUser
Created at
2026-01-14 18:50:40
Posts count
4
Likes count
8
Tags
configuration, networking, openvpn, version-r42, version-r43

Step-by-step Guide on setting up an OpenVPN Gateway with multiple anti-leak features that make the connection fail closed should it be interrupted.

It has been tested with Debian 13 minimal on Qubes versions 4.2 and 4.3.

Security and privacy features included: - 🔒 anti-leak nftables rules applied to the VPN Gateway VM from inside the VM - 🔒 anti-leak dom0 rules applied to the VPN Gateway VM from outside - :male_detective:optional random OpenVPN connection selection (requires multiple .ovpn files) - :male_detective:optional no-logs OpenVPN client configuration


Before proceeding, you will need to download a copy of your VPN provider's configuration file(s) and have your VPN login information handy.

Prepare the OpenVPN template

  1. In dom0 terminal, install debian-13-minimal template qvm-template install debian-13-minimal

  2. Clone the template to a dedicated OpenVPN template qvm-clone debian-13-minimal debian-13-minimal-ovpn

  3. Start XTerm terminal in the OpenVPN template qvm-run -u root debian-13-minimal-ovpn xterm

  4. In debian-13-minimal-ovpn terminal, edit XTerm terminal settings to allow copy-pasting nano /etc/X11/app-defaults/XTerm

  5. scroll to the end, paste the following line *selectToClipboard: true

  6. Save the file (ctrl+x, y, and Enter)

  7. Close the terminal, then open it again (repeat step #3)

    <small> â„šī¸ </small>Copy-Paste inside XTerm Terminal: to copy, select the text and press Ctrl+Shift+C, to paste press the mousewheel button

  8. Update and install OpenVPN and notification packages
    apt update && apt install -y --no-install-recommends qubes-core-agent-networking qubes-notification-agent libnotify-bin openvpn

<small>Note: libnotify-bin package is present in debian-13 template, but missing in the minimal version. It is required for VPN status notifications.</small>

  1. Disable the auto-starting services that come with OpenVPN, then close the terminal.

systemctl stop openvpn.service  
systemctl stop openvpn-client@client  
systemctl stop openvpn-server@server  
systemctl disable openvpn.service  
systemctl disable openvpn-client@client  
systemctl disable openvpn-server@server  
<small>Ignore warnings about locale settings</small>

  1. Shutdown the template. In dom0 qvm-shutdown debian-13-minimal-ovpn

Set up the OpenVPN Gateway VM

  1. Create a new AppVM
  2. name it sys-vpn
  3. set the template to debian-13-minimal-ovpn
  4. set Network to Custom ➝ sys-firewall — <small> âš ī¸ </small> not Default ➝ sys-firewall
  5. set Provides network access to other qubes checked in the Advanced Options section

CreateNewQube_Image|496x500

  1. Set up and test the OpenVPN client

Make sure the VPN template debian-13-minimal-ovpn is not running, then start the VPN VM and launch XTerm terminal. From dom0 run qvm-run -u root sys-vpn xterm

Set up and test the VPN client. Create a new `/rw/config/vpn` folder

mkdir /rw/config/vpn

Copy your VPN configuration files to `/rw/config/vpn`. Your OpenVPN config file should be named `openvpn-client.ovpn` so you can use the scripts below as is without modification. Otherwise you would have to replace the file name. Files accompanying the main config such as `*.crt` and `*.pem` should also be placed in the `/rw/config/vpn` folder.

Check or modify configuration file contents

nano /rw/config/vpn/openvpn-client.ovpn

Files referenced in `openvpn-client.ovpn` should not use absolute paths such as `/etc/...`

The config should route all traffic through your VPN's interface after a connection is created; For OpenVPN the directive for this is `redirect-gateway def1`

Make sure it already includes or add:
`redirect-gateway def1`

> **Note:** If your VPN provider did not supply credentials to use, you may skip the following `auth-user-pass` directive and `pass.txt` file creation

The VPN client may not be able to prompt you for credentials when connecting to the server, so we'll add a reference to a file containing the VPN username and password, add or modify `auth-user-pass` like so:

`auth-user-pass pass.txt`

Save the `/rw/config/vpn/openvpn-client.ovpn` file.



`nano /rw/config/vpn/pass.txt`

Add:

```text
username
password
```

Replace `username` and `password` with your actual username and password.



**Test your client configuration:** Run the client from the AppVM XTerm terminal in the 'vpn'

openvpn --cd /rw/config/vpn --config openvpn-client.ovpn

Watch for status messages that indicate whether the connection is successful (e.g. `Initialization Sequence Completed`) and test from another VPN VM terminal window with ping.

ping 1.1.1.1

`ping` can be aborted by pressing the two key combination `Ctrl+C`. DNS may be tested at this point by replacing addresses in `/etc/resolv.conf` with ones appropriate for your VPN (although this file will not be used when setup is complete, therefore revert to your original qubes nameservers after testing the connection). Diagnose any connection problems using resources such as client documentation and help from your VPN service provider. Proceed to the next step when you're sure the basic VPN connection is working.
  1. Create the DNS-handling script

nano /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 "LINK IS UP." --icon=network-idle' user
else
    su - -c 'notify-send "LINK UP, NO DNS!" --icon=dialog-error' user
fi

;;
down)
su - -c 'notify-send "LINK IS DOWN !" --icon=dialog-error' user

# Restart the VPN automatically
sleep 5s
sudo /rw/config/rc.local
;;
esac

Save the script (ctrl+x, y, and Enter). Make it executable. chmod +x /rw/config/vpn/qubes-vpn-handler.sh

  1. Configure client to use the DNS handling script nano /rw/config/vpn/openvpn-client.ovpn

Add the following under the redirect-gateway def1 line:

script-security 2
up 'qubes-vpn-handler.sh up'
down 'qubes-vpn-handler.sh down'

Remove other instances of lines starting with script-security, up or down should there be any others.

Save the script (ctrl+x, y, and Enter)

  1. Create the nftables anti-leak rules script

nano /rw/config/vpn/qubes-vpn-firewall.sh

Add the following:

#!/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 (ctrl+x, y, and Enter). Make it executable. chmod +x /rw/config/vpn/qubes-vpn-firewall.sh

  1. Set up the VPN's autostart nano /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'

# Uncomment to randomly select a .ovpn config from /rw/config/vpn/
#VPN_OPTIONS='--cd /rw/config/vpn/ --config '$(find /rw/config/vpn/*.ovpn|shuf -n1|rev|cut -d/ -f1|rev)' --daemon'

groupadd -rf qvpn ; sleep 2s
sg qvpn -c "$VPN_CLIENT $VPN_OPTIONS"
su - --whitelist-environment=VPN_CLIENT -c 'notify-send "Starting $VPN_CLIENT..." --icon=network-idle' user

<small> â„šī¸ </small> Optional: If you have multiple .ovpn configuration files inside /rw/config/vpn/ and want to start a random VPN connection each time the VPN Gateway boots up, comment the 3rd line and uncomment the 6th line

Save the script (ctrl+x, y, and Enter) and close the terminal.

  1. Shutdown the VPN VM. In dom0 qvm-shutdown sys-vpn

  2. Create a systemd service in VPN template

Start the OpenVPN template and spawn XTerm terminal qvm-run -u root debian-13-minimal-ovpn xterm

Create the systemd service nano /lib/systemd/system/qubes-vpn-firewall.service

Add the following:

[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
Save the script (ctrl+x, y, and Enter)

> <small> **Note:** This system service ensures the nftables rules from `/rw/config/vpn/qubes-vpn-firewall.sh` are in place *after* the `qubes-firewall.service` is active and *before* the `qubes-network.service` has started. </small>

Enable the service systemctl enable qubes-vpn-firewall.service

Close the terminal.

  1. Shutdown the VPN template. In dom0 qvm-shutdown debian-13-minimal-ovpn

  2. Start your VPN Gateway VM. You should see VPN Notifications on the top right corner of your monitor.

vpn-notifications|428x400

You can now use your VPN Gateway! :partying_face: Set sys-vpn as the Net Qube for your AppVMs.

🔒 Anti-leak dom0 firewall rules

A highly recommended extra layer of anti-leak firewall rules can be set in dom0 for the VPN Gateway VM. These rules are outside the scope of the VPN VM and would require dom0 compromise in order to be tampered with.

<small> âš ī¸ </small> Make sure the VPN Gateway (sys-vpn) is turned off before proceeding.

In dom0 terminal run the following commands — replace <> placeholders with the connection information of your OpenVPN configuration

qvm-firewall <VPN-Gateway-VM-Name> reset
qvm-firewall <VPN-Gateway-VM-Name> add accept <OpenVPN-server-IP>/32 <protocol> <port>
qvm-firewall <VPN-Gateway-VM-Name> add drop
qvm-firewall <VPN-Gateway-VM-Name> del --rule-no 0   # Delete default 'accept' rule
Example:
qvm-firewall sys-vpn reset
qvm-firewall sys-vpn add accept 123.45.67.89/32 udp 1194
qvm-firewall sys-vpn add drop
qvm-firewall sys-vpn del --rule-no 0

Check the firewall rules with qvm-firewall sys-vpn list

It should look like

user@dom0:~$ qvm-firewall sys-vpn list
NO  ACTION  HOST             PROTOCOL  PORT(S)  SPECIAL TARGET  ICMP TYPE  EXPIRE  COMMENT
0   accept  123.45.67.89/32  udp       1194     -               -          -       -
1   drop    -                -         -        -               -          -       -

:male_detective: No-Logs configuration

To prevent recording OpenVPN (client) logs in the Gateway VM, add the following to your .ovpn configuration file:

log /dev/null
status /dev/null
verb 0

🔍 Debug

To debug your OpenVPN connection, add the following to your .ovpn config:

verb 3
log /home/user/log

This will save the verbose connection log to the /home/user/log file.


Sources: