The VPN client connects, the browser works, and the one command you actually care about still leaves through your home connection. Or the opposite happens: the tunnel grabs the default route and the local file server disappears from the desktop. I hit this with a build agent that had to reach an internal artifact mirror while the rest of the host stayed on the office LAN, and the client offered exactly two choices, all traffic or none. The request both ways is the same one, this program and not the whole machine, and most clients have no checkbox for it, because the route table has never known what a program is.

Why the route table cannot see your program

When a process sends a packet, the stack looks up the destination address in the routing table. Neither the executable name nor the port enters that lookup. Domain names fail for the same reason: a tunnel carries traffic to an address, and by the time a name resolves into one, routing is the only lever the kernel still has. That is why "VPN only for this site" ends up meaning a proxy, and "VPN only for this program" ends up meaning one of a small set of mechanisms.

MechanismWhat selectsCoversOn tunnel drop
Socket bindsource addressa client you controlconnections fail
UID rule (Linux)owning userevery process of one accountconnections fail
Firewall profile (Windows)adapter profileone programfails closed
Network namespacenamespace membershipeverything started insideconnections fail
SOCKS hopapp proxy settingone app or one browser profileconnections fail

None of these is a per-process route. Each one moves the decision to a layer that can see the program, the user, or the app's own configuration.

A single destination is still a route, not a program rule. On macOS the usual recipe is to uncheck "Send all traffic over VPN" and add a host route: sudo route add -host internal-tuner.example -interface ppp0, removed again with sudo route delete -host internal-tuner.example -interface ppp0. Read the interface name from ifconfig first, since ppp0 is only an example. Wildcards do not work in a host route, so a service that talks to several names needs one entry per name, and the rule covers every process on the machine, not the one you had in mind.

Bind the socket to the tunnel address

When you control the client, binding is the cheapest selector. A socket bound to the tunnel's local address leaves through that interface as long as a route for the destination exists there, and everything else on the machine keeps its normal path. The subtlety is that the bind chooses the source address while the route table still chooses the egress. A bind alone does not move traffic that has no route into the tunnel, which is the mistake behind most "I bound it and it still leaves the wrong way" reports.

import socket
s = socket.socket()
s.bind(("10.8.0.2", 0))
s.connect(("build.internal.example", 443))

Higher-level tools expose the same idea: curl --interface 10.8.0.2, PHP's CURLOPT_INTERFACE, and the source_address argument in Python's HTTP clients. The address has to belong to the tunnel, and a typo here fails as an ordinary connection error, not a routing complaint. Binding to a device name instead of an address is a Linux privilege and does not carry over to Windows or macOS, which is why portable scripts pass the address.

Linux: match the owner, not the program name

The kernel can match the owning user of a socket, so a dedicated account becomes the selector. Put the tunnel in its own routing table and send only that account's traffic there:

ip rule add uidrange 1001-1001 lookup 100
ip route add default dev tun0 table 100

This only works if the tunnel is not already the default route. Start the client with --route-nopull, or filter the pushed redirect with --pull-filter ignore "redirect-gateway" on OpenVPN 2.4 and later, then add the table entry yourself. Otherwise both defaults exist and the lower-priority one wins. The deliberate version of that is pointing the default route at the tunnel gateway, or doing it from a disconnect script: it keeps traffic off the internet when the tunnel drops, but that is a kill switch, not a selector, and it stays until someone removes it.

The stronger version is a network namespace. Create it, run the client inside, and start the application with ip netns exec. Nothing outside the namespace sees the tunnel interface, so the application cannot fall back to the host route by accident. The cost is real: the namespace needs a veth pair to reach the physical network and its own resolver file, because the mount namespace does not share the host's resolution settings.

Two failure modes explain most leaks. Name resolution still happens in the host's resolver, so the query leaves outside the tunnel even when the TCP connection goes inside. And IPv6 needs its own ip -6 rule, because a v4 rule does not cover it.

Windows and macOS: scope the firewall or hand over a proxy

Windows has no per-application route selector either, but its firewall rules can be scoped to a profile, and a VPN adapter can be classified as Public while the home LAN stays Private. Create outbound block rules for the program on the Private and Domain profiles, leave Public open, and the application can only reach the network through the tunnel. Set the connection's network category first, then confirm the rule matched in the firewall's monitor pane. It is a block, not a route: it fails closed when the tunnel drops, and it cannot stop an application that ignores the firewall.

macOS follows the same logic with different tools. A SOCKS listener reached over SSH is the least invasive option, since ssh -D 1080 user@host creates one local port and only applications configured to use it take that path. A pf rule can block a program by user or group, but it still cannot choose the tunnel for that program alone. Tunnelblick cannot filter by domain at all, because routes work on addresses; OpenConnect users reach for vpn-slice when the server pushes a prefix list that is too wide.

Two workarounds cover what the platform tools miss. Browser extensions that switch proxy per domain are the practical answer for "only this site", because they act at the application layer where names still exist; they cover one browser and nothing else. The blunt one is to run the vendor client inside a virtual machine so the guest owns the route change while the host keeps its path, at the cost of two network stacks to maintain.

A SOCKS hop for tools with no bind option

Most applications can be pointed at an HTTP or SOCKS proxy even when they know nothing about interfaces. An SSH client supplies one with no change on the server side:

ssh -N -D 127.0.0.1:9999 [email protected]

Anything configured to use that port exits at the bastion while the rest of the desktop keeps its normal connection. A local 3proxy instance bound to the tunnel address does the same for one port without SSH, and stunnel covers a single TLS-wrapped port. The limits are practical: a proxy only carries protocols that know how to use it, UDP needs SOCKS5 UDP association that many clients' "SOCKS5" setting does not enable, and DNS must be resolved by the proxy (socks5h in curl) or the name leaks to the local resolver.

Keep the listener on loopback unless another machine needs it.

Verify the path with a capture, not a status message

Application status windows describe the setting, not the packet. Capture on both interfaces and compare:

tcpdump -i any -n port 443
ss -tnp | grep 443

If the connection appears on the physical interface with no matching entry on the tunnel, the selector is not in effect, whatever the client reports. Check IPv6 and the resolver separately, and check for a second path such as a helper process or an inherited system proxy.

Then pick the boundary that matches the requirement: a proxy is enough for one site, a UID rule is enough for one account, a firewall profile is enough when the connection must fail closed, and a namespace is the only option that hides the tunnel from everything outside it.

متعلقہ مضامین