netVM/proxyVM openvpn gateway with kicksecure-18 template does not work

I used this tutorial to create a NetVM using the Kicksecure-18 template, following the installations and configurations exactly as described in the tutorial:

I followed the tutorial, but instead of using the Debian Minimal template, I used debian-13-xfce. I cloned it and carried out all the configurations and installations described in the tutorial.

It worked perfectly—OpenVPN worked flawlessly as a NetVM!

However, I did the same thing with Kicksecure by cloning kicksecure-18 and following exactly the same steps, but it does not work.

The AppVM that uses this NetVM configuration was tested with the user, sysmaint, and unrestricted users, but it still did not work.

Can someone help me and explain what is happening?

Does kicksecure-18 have any hardening that blocks forwarding?

I configured sys-firewall using the Kicksecure-18 template for testing, and it worked normally. The same happened with sys-net. Both sys-net and sys-firewall work when using the Kicksecure-18 template!

I would really appreciate any help.

I think the ideal solution would be to use Kicksecure for everything because its hardening is exceptionally strong and much better than the default Fedora and Debian templates, which do not provide this level of hardening.

Use a sysmaint kernel in your Kicksecure-based ProxyVM and run these diagnostic commands:

sudo dmesg | grep -i "apparmor.*denied"
sudo cat /var/log/audit/audit.log 2>/dev/null | grep DENIED
sudo openvpn --mktun --dev tun0
sudo nft add table inet user-vpn-test
sudo nft list tables
getent group qvpn
id root
id user
id sysmaint

Paste the output in enclosed Markdown triple backtick pairs.

this is the output:

dd[root ~]# sudo dmesg | grep -i "apparmor.*denied"
sudo cat /var/log/audit/audit.log 2>/dev/null | grep DENIED
sudo openvpn --mktun --dev tun0
sudo nft add table inet user-vpn-test
sudo nft list tables
getent group qvpn
id root
id user
id sysmaint; echo; echo
zsh: done       sudo dmesg | 
zsh: exit 1     grep --color=auto -i "apparmor.*denied"
zsh: exit 1     sudo cat /var/log/audit/audit.log 2> /dev/null | grep --color=auto DENIED
2026-08-27 12:02:59 Note: --mktun does not support DCO. Creating TUN interface.
2026-08-27 12:02:59 TUN/TAP device tun0 opened
2026-08-27 12:02:59 Persist state set to: ON
table ip qubes
table ip6 qubes
table ip qubes-firewall
table ip6 qubes-firewall
table inet user-vpn
table inet qubes-nat-accel
table inet user-vpn-test
qvpn:x:986:
uid=0(root) gid=0(root) groups=0(root),27(sudo),105(console)
uid=1000(user) gid=1000(user) groups=1000(user),991(qubes)
uid=1001(sysmaint) gid=1001(sysmaint) groups=1001(sysmaint),27(sudo),100(users),991(qubes),105(console),108(debian-tor),117(ssh)


[root ~]# 

I tried using mullvad-browser connect inside this netvm and do not work. Appvm that uses this netvm as network do not work to…
´see the nft list ruleset output´

table ip qubes {
	set downstream {
		type ipv4_addr
		elements = { 10.138.23.127 }
	}

	set allowed {
		type ifname . ipv4_addr
		elements = { "vif63.0" . 10.138.23.127 }
	}

	chain prerouting {
		type filter hook prerouting priority raw; policy accept;
		iifgroup 2 goto antispoof
		ip saddr @downstream counter packets 0 bytes 0 drop
	}

	chain antispoof {
		iifname . ip saddr @allowed accept
		counter packets 0 bytes 0 drop
	}

	chain postrouting {
		type nat hook postrouting priority srcnat; policy accept;
		oifgroup 2 accept
		oif "lo" accept
		masquerade
	}

	chain input {
		type filter hook input priority filter; policy drop;
		jump custom-input
		ct state invalid counter packets 0 bytes 0 drop
		iifgroup 2 udp dport 68 counter packets 0 bytes 0 drop
		ct state established,related accept
		iifgroup 2 meta l4proto icmp accept
		iif "lo" accept
		iifgroup 2 counter packets 0 bytes 0 reject with icmp host-prohibited
		counter packets 0 bytes 0
	}

	chain forward {
		type filter hook forward priority filter; policy accept;
		jump custom-forward
		ct state invalid counter packets 0 bytes 0 drop
		ct state established,related accept
		oifgroup 2 counter packets 0 bytes 0 drop
	}

	chain custom-input {
	}

	chain custom-forward {
	}

	chain dnat-dns {
		type nat hook prerouting priority dstnat; policy accept;
		ip daddr 10.139.1.1 udp dport 53 dnat to 10.139.1.1
		ip daddr 10.139.1.1 tcp dport 53 dnat to 10.139.1.1
		ip daddr 10.139.1.2 udp dport 53 dnat to 10.139.1.2
		ip daddr 10.139.1.2 tcp dport 53 dnat to 10.139.1.2
	}
}
table ip6 qubes {
	set downstream {
		type ipv6_addr
	}

	set allowed {
		type ifname . ipv6_addr
	}

	chain antispoof {
		iifname . ip6 saddr @allowed accept
		counter packets 13 bytes 844 drop
	}

	chain prerouting {
		type filter hook prerouting priority raw; policy accept;
		iifgroup 2 goto antispoof
		ip6 saddr @downstream counter packets 0 bytes 0 drop
	}

	chain postrouting {
		type nat hook postrouting priority srcnat; policy accept;
		oifgroup 2 accept
		oif "lo" accept
		masquerade
	}

	chain _icmpv6 {
		meta l4proto != ipv6-icmp counter packets 0 bytes 0 reject with icmpv6 admin-prohibited
		icmpv6 type { nd-router-advert, nd-redirect } counter packets 0 bytes 0 drop
		accept
	}

	chain input {
		type filter hook input priority filter; policy drop;
		jump custom-input
		ct state invalid counter packets 0 bytes 0 drop
		ct state established,related accept
		iifgroup 2 goto _icmpv6
		iif "lo" accept
		ip6 saddr fe80::/64 ip6 daddr fe80::/64 udp dport 546 accept
		meta l4proto ipv6-icmp accept
		counter packets 0 bytes 0
	}

	chain forward {
		type filter hook forward priority filter; policy accept;
		jump custom-forward
		ct state invalid counter packets 0 bytes 0 drop
		ct state established,related accept
		oifgroup 2 counter packets 0 bytes 0 drop
	}

	chain custom-input {
	}

	chain custom-forward {
	}
}
table ip qubes-firewall {
	set dns-addr {
		type ipv4_addr
		elements = { 10.139.1.1, 10.139.1.2 }
	}

	chain qubes-forward {
		ip saddr 10.138.23.127 jump qbs-10-138-23-127
	}

	chain forward {
		type filter hook forward priority filter; policy drop;
		ct state established,related accept
		iifname != "vif*" accept
		jump qubes-forward
	}

	chain prerouting {
		type filter hook prerouting priority raw; policy accept;
		iifname != "vif*" ip saddr 10.138.23.127 drop
	}

	chain postrouting {
		type filter hook postrouting priority raw; policy accept;
		oifname != "vif*" ip daddr 10.138.23.127 drop
	}

	chain qbs-10-138-23-127 {
		accept
		reject with icmp admin-prohibited
	}
}
table ip6 qubes-firewall {
	set dns-addr {
		type ipv6_addr
	}

	chain qubes-forward {
	}

	chain forward {
		type filter hook forward priority filter; policy drop;
		ct state established,related accept
		iifname != "vif*" accept
		jump qubes-forward
	}

	chain prerouting {
		type filter hook prerouting priority raw; policy accept;
	}

	chain postrouting {
		type filter hook postrouting priority raw; policy accept;
	}
}
table inet user-vpn {
	chain forward-filter {
		type filter hook forward priority filter - 1; policy accept;
		iifgroup 1 counter packets 0 bytes 0 drop
		oifgroup 1 counter packets 436 bytes 35685 drop
		meta mark set 0x00000bb8 counter packets 0 bytes 0
		jump forward-filter-dns
		iifgroup 2 udp dport 53 counter packets 0 bytes 0 drop
		iifgroup 2 tcp dport 53 counter packets 0 bytes 0 drop
	}

	chain forward-filter-dns {
		iifgroup 2 tcp dport 53 ip daddr 10.98.0.1 counter packets 0 bytes 0 accept
		iifgroup 2 udp dport 53 ip daddr 10.98.0.1 counter packets 0 bytes 0 accept
	}

	chain postrouting-filter {
		type filter hook postrouting priority filter - 1; policy accept;
		meta mark 0x00000bb8 oifgroup 1 counter packets 0 bytes 0 drop
	}

	chain output-filter {
		type filter hook output priority filter - 1; policy drop;
		oifgroup 1 meta skgid 986 counter packets 15 bytes 3608 accept
		oifgroup 2 counter packets 5 bytes 376 accept
		oifname "lo" counter packets 110 bytes 5176 accept
		counter packets 379 bytes 32648
	}

	chain prerouting-nat {
		type nat hook prerouting priority dstnat - 1; policy accept;
		iifgroup 2 udp dport 53 counter packets 294 bytes 19886 dnat ip to 10.98.0.1
		iifgroup 2 tcp dport 53 counter packets 0 bytes 0 dnat ip to 10.98.0.1
	}
}
table inet qubes-nat-accel {
	flowtable qubes-accel {
		hook ingress priority filter
		devices = { eth0, lo, vif63.0 }
	}

	chain qubes-accel {
		type filter hook forward priority filter + 5; policy accept;
		meta l4proto { tcp, udp } iifgroup 2 oifgroup 1 flow add @qubes-accel
		counter packets 0 bytes 0
	}
}
table inet user-vpn-test {
}

That is it!

Okay, I have a few theories, but we will start with the first one, OpenVPN may not be assigned in the right group and/or user, so run these diagnostic commands:

ps aux | grep openvpn
cat /proc/$(pgrep -x openvpn)/status | grep -E 'Uid|Gid|Groups'
ip route show
grep -E '^\s*(user|group)\s' /rw/config/vpn/openvpn-client.ovpn

In Settings, under the Advanced tab, selecting Boot Mode → Persistent Mode: Unrestricted Session WORKED!

After I switched to the user session, it started working!

Before that, I completed the entire configuration, and the first connection was made using the user session, but it didn’t work.
When I switched to the unrestricted session, it worked. Then I switched back to the user session, and it continued to work!
Perhaps the permissions for the user are only applied after using unrestricted mode first!

Output of the commands with the Unrestricted Session! Works perfectly! But the hardening of kicksecure that works without root using user session is not here!

[user ~]% ip a 
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
    inet 127.0.0.1/8 scope host lo
       valid_lft forever preferred_lft forever
    inet6 ::1/128 scope host noprefixroute 
       valid_lft forever preferred_lft forever
2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc mq state UP group 1 qlen 1000
    link/ether 00:16:3e:5e:6c:00 brd ff:ff:ff:ff:ff:ff
    inet 10.138.8.172/32 scope global eth0
       valid_lft forever preferred_lft forever
    inet6 fe80::216:3eff:fe5e:6c00/64 scope link proto kernel_ll 
       valid_lft forever preferred_lft forever
3: tun0: <POINTOPOINT,MULTICAST,NOARP,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UNKNOWN group default qlen 500
    link/none 
    inet 10.98.0.27/16 scope global tun0
       valid_lft forever preferred_lft forever
    inet6 fe80::bf5b:e2dd:7d6:cf88/64 scope link stable-privacy proto kernel_ll 
       valid_lft forever preferred_lft forever
[user ~]% ip route
0.0.0.0/1 via 10.98.0.1 dev tun0 
default via 10.138.14.4 dev eth0 onlink 
10.98.0.0/16 dev tun0 proto kernel scope link src 10.98.0.27 
10.138.14.4 dev eth0 scope link 
128.0.0.0/1 via 10.98.0.1 dev tun0 
149.88.27.232 via 10.138.14.4 dev eth0 
[user ~]% mullvad-browser
[user ~]% echo "tested with user unrestricted"
tested with user unrestricted
[user ~]%                                     
[user ~]% 
[user ~]% 
[user ~]% ps aux | grep openvpn
cat /proc/$(pgrep -x openvpn)/status | grep -E 'Uid|Gid|Groups'
ip route show
grep -E '^\s*(user|group)\s' /rw/config/vpn/openvpn-client.ovpn
root        3743  0.0  0.3  15548  9056 ?        Ss   16:31   0:00 openvpn --cd /rw/config/vpn/ --config ca-free-106.protonvpn.tcp.ovpn --daemon
user       19119  0.0  0.1   6528  2280 pts/0    S+   16:37   0:00 grep --color=auto openvpn
Uid:    0       0       0       0
Gid:    986     986     986     986
Groups:  
0.0.0.0/1 via 10.98.0.1 dev tun0 
default via 10.138.14.4 dev eth0 onlink 
10.98.0.0/16 dev tun0 proto kernel scope link src 10.98.0.27 
10.138.2.1 dev vif92.0 scope link metric 32660 
10.138.14.4 dev eth0 scope link 
128.0.0.0/1 via 10.98.0.1 dev tun0 
149.88.27.232 via 10.138.14.4 dev eth0 
grep: /rw/config/vpn/openvpn-client.ovpn: No such file or directory
zsh: exit 2     grep --color=auto -E '^\s*(user|group)\s' /rw/config/vpn/openvpn-client.ovpn
[user ~]% cat /rw/config/rc.local
#!/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

Output of the commands with the user session!
It started working after following the procedure of connecting to the unrestricted session first and then connecting to the user session!
Do you know why it?

[user ~]% ip a
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
    inet 127.0.0.1/8 scope host lo
       valid_lft forever preferred_lft forever
    inet6 ::1/128 scope host noprefixroute 
       valid_lft forever preferred_lft forever
2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc mq state UP group 1 qlen 1000
    link/ether 00:16:3e:5e:6c:00 brd ff:ff:ff:ff:ff:ff
    inet 10.138.8.172/32 scope global eth0
       valid_lft forever preferred_lft forever
    inet6 fe80::216:3eff:fe5e:6c00/64 scope link proto kernel_ll 
       valid_lft forever preferred_lft forever
3: tun0: <POINTOPOINT,MULTICAST,NOARP,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UNKNOWN group default qlen 500
    link/none 
    inet 10.98.0.12/16 scope global tun0
       valid_lft forever preferred_lft forever
    inet6 fe80::c4b6:4c69:cfe9:a094/64 scope link stable-privacy proto kernel_ll 
       valid_lft forever preferred_lft forever
[user ~]% ip route
0.0.0.0/1 via 10.98.0.1 dev tun0 
default via 10.138.14.4 dev eth0 onlink 
10.98.0.0/16 dev tun0 proto kernel scope link src 10.98.0.12 
10.138.14.4 dev eth0 scope link 
128.0.0.0/1 via 10.98.0.1 dev tun0 
149.88.27.232 via 10.138.14.4 dev eth0 
[user ~]% 
[user ~]% ps aux | grep openvpn
cat /proc/$(pgrep -x openvpn)/status | grep -E 'Uid|Gid|Groups'
ip route show
grep -E '^\s*(user|group)\s' /rw/config/vpn/openvpn-client.ovpn
root        1399  0.0  0.3  15548  9148 ?        Ss   16:42   0:00 openvpn --cd /rw/config/vpn/ --config ca-free-106.protonvpn.tcp.ovpn --daemon
user       17404  0.0  0.0   6528  2372 pts/0    S+   16:48   0:00 grep --color=auto openvpn
Uid:    0       0       0       0
Gid:    986     986     986     986
Groups:  
0.0.0.0/1 via 10.98.0.1 dev tun0 
default via 10.138.14.4 dev eth0 onlink 
10.98.0.0/16 dev tun0 proto kernel scope link src 10.98.0.12 
10.138.14.4 dev eth0 scope link 
128.0.0.0/1 via 10.98.0.1 dev tun0 
149.88.27.232 via 10.138.14.4 dev eth0 
grep: /rw/config/vpn/openvpn-client.ovpn: No such file or directory
zsh: exit 2     grep --color=auto -E '^\s*(user|group)\s' /rw/config/vpn/openvpn-client.ovpn
[user ~]% cat /rw/config/rc.local
#!/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

Yes, basically the entire guide assumes sudo is already available like any other Debian distribution except Kicksecure/Whonix, so any commands requiring elevated privileges breaks when you start as the user account first. If you want to verify this hypothesis, run these commands as user after VM startup:

systemctl status qubes-vpn-firewall.service
systemctl status rc-local 2>/dev/null || systemctl status qubes-early-vm-config
pgrep -a openvpn || echo "openvpn NOT running"
journalctl -b | grep -iE 'openvpn|rc.local|qvpn'

If the output echoes that OpenVPN is not running as user, then you have verified the hypothesis.