Persistent TUN Interface Naming in OpenVPN: Solving VPN-Specific Firewall Rule Challenges


8 views

When managing multiple OpenVPN client configurations through systemd (openvpn@foo and openvpn@bar in this case), the first established connection always claims tun0. This creates challenges for:

  • Applying VPN-specific firewall rules
  • Maintaining consistent interface naming across reboots
  • Handling dynamic IP assignments

--dev and --dev-node /h2>

The most straightforward solution is to explicitly define interface names in each configuration:

# In foo.conf
dev tun-foo
dev-node /dev/net/tun-foo

# In bar.conf  
dev tun-bar
dev-node /dev/net/tun-bar

This requires creating persistent device nodes:

# Create persistent device nodes
sudo mkdir -p /dev/net
sudo mknod /dev/net/tun-foo c 10 200
sudo mknod /dev/net/tun-bar c 10 201
sudo chmod 0666 /dev/net/tun-*

For systemd-controlled OpenVPN instances, we can use temporary files to track associations:

# In foo.conf
script-security 2
up "/bin/sh -c 'echo $dev > /run/openvpn/foo.iface'"
down "/bin/sh -c 'rm -f /run/openvpn/foo.iface'"

Then query the mapping:

#!/bin/bash
get_vpn_iface() {
    local vpn_name=$1
    cat "/run/openvpn/${vpn_name}.iface"
}

# Example usage:
FOO_IFACE=$(get_vpn_iface "foo")
iptables -A OUTPUT -o $FOO_IFACE -j ACCEPT

For more complex scenarios, consider network namespaces:

# Create namespaces
sudo ip netns add vpn-foo
sudo ip netns add vpn-bar

# Launch OpenVPN in namespaces
sudo ip netns exec vpn-foo openvpn --config foo.conf
sudo ip netns exec vpn-bar openvpn --config bar.conf

Here's how to implement rules dynamically:

#!/bin/bash
# Dynamic firewall rules based on VPN config

apply_vpn_rules() {
    local vpn_name=$1
    local iface=$(get_vpn_iface $vpn_name)
    
    case $vpn_name in
        foo)
            iptables -A OUTPUT -o $iface -d 192.168.1.0/24 -j ACCEPT
            ;;
        bar)  
            iptables -A OUTPUT -o $iface -d 10.0.0.0/8 -j ACCEPT
            ;;
    esac
}

# Apply rules when VPN comes up
apply_vpn_rules "foo"
apply_vpn_rules "bar"

For modern systems with cgroup v2:

# Create cgroups
sudo mkdir /sys/fs/cgroup/unified/vpn.foo
sudo mkdir /sys/fs/cgroup/unified/vpn.bar

# Assign PIDs
echo $OPENVPN_PID > /sys/fs/cgroup/unified/vpn.foo/cgroup.procs

# BPF-based filtering
bpftool prog load firewall.o /sys/fs/bpf/firewall_foo
bpftool cgroup attach /sys/fs/cgroup/unified/vpn.foo egress pinned /sys/fs/bpf/firewall_foo

To verify interface-configuration mapping:

#!/bin/bash
for pid in $(pgrep openvpn); do
    conf=$(ps -p $pid -o cmd= | grep -oP '(?<=--config\s)\S+')
    iface=$(ip -o link | grep -B1 "tun.*$pid" | head -1 | awk -F': ' '{print $2}')
    echo "PID $pid: $conf -> $iface"
done
  • For simple setups: Use explicit dev naming
  • For automated environments: Implement the systemd temporary files solution
  • For maximum isolation: Consider network namespaces
  • For enterprise deployments: Explore cgroup/BPF filtering

When running multiple OpenVPN clients simultaneously, you'll notice the first connected VPN always grabs tun0, while subsequent connections get assigned incrementing interface names (tun1, tun2, etc.). This becomes problematic when:

  • Creating firewall rules tied to specific VPN connections
  • Writing scripts that need consistent interface references
  • Maintaining configuration files across reboots

--dev and --dev-type Parameters /h2>

OpenVPN provides configuration options to specify exact interface names:


# In your OpenVPN config file (e.g., foo.ovpn)
dev tun-foo
dev-type tun

# In your second config (e.g., bar.ovpn)
dev tun-bar
dev-type tun

This approach ensures:

  • Consistent interface naming across reboots
  • Clear identification of which VPN uses which interface
  • No interface number conflicts

When using systemd, modify your service files to pass these parameters:


# /etc/systemd/system/openvpn-foo.service
[Unit]
Description=OpenVPN Client Foo
After=network.target

[Service]
Type=simple
ExecStart=/usr/sbin/openvpn --config /etc/openvpn/foo.conf --dev tun-foo --dev-type tun

[Install]
WantedBy=multi-user.target

With persistent interface names, you can now create accurate iptables rules:


# Allow traffic only through VPN foo
iptables -A OUTPUT -o tun-foo -j ACCEPT

# Block traffic if VPN foo disconnects
iptables -A OUTPUT ! -o tun-foo -j DROP

Check interface assignments with:


ip link show | grep tun

For debugging, add these to your OpenVPN config:


log /var/log/openvpn-foo.log
verb 4

For advanced isolation, consider network namespaces:


ip netns add vpn-foo
ip netns exec vpn-foo openvpn --config foo.ovpn

This completely separates the VPN interfaces and routing tables.

Here's a complete working example for two VPNs:


# /etc/openvpn/foo.conf
client
dev tun-foo
dev-type tun
remote vpn-foo.example.com 1194
persist-tun
persist-key
verb 3

# /etc/openvpn/bar.conf
client
dev tun-bar
dev-type tun
remote vpn-bar.example.com 1194
persist-tun
persist-key
verb 3