Pinning one program to a VPN on Linux is mostly a routing problem, and the firewall rule only decides what happens after the route is chosen. If you match the program's UID in OUTPUT before the tunnel owns the default route, the rule drops the very traffic you meant to protect. The same rule also cannot see the resolver process that answers the program's DNS queries. Get the order right and a single unprivileged account becomes a reliable split-tunnel selector.

Give the Account a Route Before a Verdict

The owner match answers one question: did a local socket belonging to this UID build the packet? It does not choose an interface, and it cannot move traffic that the main table already sends out of the physical NIC. That is why a UID rule with no matching route blocks the program instead of redirecting it.

Send the account down its own table first, then let the firewall confirm the result. This works on a host whose iproute2 understands uidrange, which ip rule help reports in one line. Table 100 holds a default route through the tunnel, and the rule sends only UID 1000 to that table. Everything else stays on the main table and keeps using the physical link.

ip rule add uidrange 1000-1000 table 100
ip route add default dev tun0 table 100

A second account that needs a different tunnel gets table 101 and its own rule, because two profiles cannot both own the default route in the same table. NetworkManager profiles are system-wide, so this part stays manual, and the rules vanish on reboot unless something restores them. Write the tables into a boot script next to the tunnel definition so a reconnect cannot leave one account on the wrong path.

Why the Loopback Exception Kills the Tunnel

Most copied versions of the rule drop every packet that does not leave through loopback. The tunnel is not loopback, so that form drops the tunnel traffic too, and the result looks like a dead VPN rather than a firewall mistake.

By the time OUTPUT runs, the kernel has already picked an egress interface, so -o compares against the route's answer rather than the packet header. The owner match is consulted only in OUTPUT and POSTROUTING, and forwarded packets carry no socket for it to read. That combination means the exception must name the tunnel interface and negate that match, rather than negating loopback.

Loopback still deserves its own ACCEPT above the DROP, because the first DNS packet from the program goes to the local stub over lo. Put the two rules in this order and the account reaches the stub, then the tunnel, and nothing else.

iptables -I OUTPUT 1 -o lo -m owner --uid-owner vpnapp -j ACCEPT
iptables -A OUTPUT -m owner --uid-owner vpnapp ! -o tun0 -j DROP

IPv6 is a separate ruleset, and a v4-only pair leaves ip6tables untouched. A program that prefers AAAA records would then leave through the physical link without ever reaching the DROP. Mirror both rules in ip6tables, or disable IPv6 on the host when the tunnel carries no v6 address. Hand-added rules also disappear on reboot, so save them with netfilter-persistent save or rebuild the same logic as an nftables table loaded by the boot unit.

DNS Answers Come From Another Process

The program's query to 127.0.0.53 passes the loopback ACCEPT, and that is where the UID match stops. The stub resolver forwards upstream from its own service account, so a rule keyed to vpnapp never sees the second query. If the resolver picks a LAN server for the link, answers come back over the physical NIC while the program's own packets stay in the tunnel. The visible result is a working tunnel with broken names, or names answered by a server you did not choose.

Fix name resolution on the tunnel link instead of on the application. Give the tunnel profile a DNS server and a routing domain, then confirm with resolvectl query and resolvectl status tun0 that the resolver uses it for the names that matter. Inside a network namespace the problem disappears, because the namespace resolver knows only the tunnel.

Check the Rule From the Account Itself

Ask the kernel which route a UID gets, then make a request as that account and compare the exit address with your own account's. If both report the same address, the rule is matching something other than the traffic you tested. The first command below shows the route lookup without sending a packet, which separates a routing mistake from a firewall mistake.

ip route get 1.1.1.1 uid 1000
sudo -u vpnapp curl -s https://ifconfig.co

Counters show whether a rule ever fired. Run iptables -vnL OUTPUT, read the packet count beside each rule, and treat a zero next to the DROP after real traffic as a match error.

Forwarded traffic sits outside this design. A container or VM behind a bridge has no local socket owned by vpnapp, so the owner match never fires and those packets follow the FORWARD policy instead. If that workload must be covered, test it separately and write a policy keyed to the bridge or the container address. The design holds when the table, the interface exception, the resolver and the v6 rules all point the same way; leave any one of them out and the account either leaks or loses connectivity.

ተዛማጅ ጽሑፎች