One /24 for a lab printer goes missing between two ip route show calls, and the client log says only that the network changed. Nothing was wrong with the command, the prefix or your privileges. A corporate client that owns the host's routing model re-asserts that model whenever someone else edits it, so the entry you added is removed as an intruder. Fighting it in the same table is a losing position. The fix is to stop competing for that table entirely and put the exception somewhere the monitor never looks.

The monitor explains the missing route

Demonstrate it before you redesign anything: read the table, add the prefix, wait a few seconds, read again. If the entry is absent and you typed nothing to remove it, an agent is watching. Find the process that owns that behaviour and read its journal around the moment the route vanishes, because the same service usually maintains the tunnel, the packet filters and the resolver configuration. Killing it to make your route stick is a trade you will regret at the next reconnect.

ip route show
ip route add 192.168.2.0/24 dev enp0s31f6
sleep 5; ip route show

The prefix and interface above are placeholders. Copying an interface name from a working example on another machine is the most common reason this demonstration produces nothing at all.

A second table is invisible to the monitor

The agent only rebuilds the table it owns. Give the exception a table of its own plus a rule that sends matching destinations there, and the main table never changes, so there is nothing for the agent to correct. That is the entire idea: one namespace of routes that someone else maintains, and one that only you touch.

The trade is maintenance. Two objects can drift apart, and the rule's position in the list matters as much as the route inside the table it selects.

Build the exception, then make it win

Create the table, install the route, and add a rule that selects the table for that destination. The preference decides whether your rule is consulted before or after the ones the client inserted, and a rule that sorts later is never reached, so the number is part of the design rather than a decoration. Then ask the kernel what it would actually do, which is a different question from what you configured.

ip rule add to 192.168.2.0/24 table 110 pref 30010
ip route add 192.168.2.0/24 dev enp0s31f6 table 110
ip route get 192.168.2.50

ip route get resolves the route the kernel would use, including the device and source address. It does not print the table identifier, so pair it with ip rule show when you need to confirm which table won. Together they answer the only question that matters: does traffic to that address leave where you intended?

Other shapes of the same idea

The pattern generalizes once you stop thinking in destinations. A local subnet can stay reachable while the tunnel keeps the default route. Replies from a LAN-attached SSH server can leave by the interface they arrived on instead of disappearing into the tunnel. And an interface-based rule can push everything arriving on a wireless interface into the tunnel while traffic the host generates itself stays on Ethernet:

ip rule add iif wlan0 table 10
ip route add default dev tun0 table 10

That last case matches the incoming interface rather than the destination, which is a different rule type and a different mental model. Forwarded traffic also needs net.ipv4.ip_forward enabled, and the far end must route the source range back or translate it; a rule on one machine never creates the other half of the path.

Treat the rule as disposable

Rules and table routes live in memory. After a reconnect the tunnel device can be renamed, the assigned address can move, and a rule holding the old value stops matching silently. Reapply the whole change from a single script that owns it, then re-check ip rule show, the current device name and a fresh ip route get against a real destination before trusting the exception.

Two boundaries survive any amount of care. The client still controls its own filter rules and the resolver configuration, and a routing exception overrides neither. If the client also deletes your policy rule, the supported paths are its own split-tunnel policy or a client that lets you manage routes, such as OpenConnect; patching a vendor binary to silence its monitor is a maintenance commitment with an integrity check attached.

Write the table number, the preference and the destination into a note next to the script. Otherwise the next person finds a rule that outranks the tunnel and has no way to know why.

Pennadoù liammet