Clients connect and authenticate, then their traffic leaves by the wrong uplink, because the only default route on the box belongs to the gateway itself. Replacing that default would break the machine's own updates and management access, so the selection has to be narrower than "all traffic". The workable shape is a policy rule keyed on the client pool's inner source address, pointing at a second routing table that owns the alternate WAN. From there the checklist is about completeness and failure behaviour, not about the rule itself.

Confirm the source address survives to the routing decision

A policy rule that matches a source range only fires when the packet still carries that source at lookup time. Forwarded client traffic does, as long as nothing upstream rewrote it before routing. Traffic generated by the gateway does not, which is exactly why the gateway's own default can stay where it is.

This is where local tests mislead. A curl run on the gateway is a locally generated flow with the gateway's address, so it takes the main table and proves nothing about the client path. Test from a connected client, or generate traffic with a source that actually belongs to the pool.

Start by writing down the real values: the pool prefix, the tunnel interface that carries it, the second WAN's address and next hop, and the table number you intend to use. Clear the old rules from any previous attempt before adding new ones, because duplicated rules at different priorities produce failures that look random.

Build a selected table that is complete

A table is not a default route with a friend. It needs enough routes that every lookup resolves inside it, including the connected route for the WAN subnet so the next hop is reachable. For a pool of 10.77.0.0/24 on tun1, a WAN address of 203.0.113.20/24 with next hop 203.0.113.1, and table 600:

ip -4 route replace 203.0.113.0/24 dev usb0 src 203.0.113.20 table 600
ip -4 route replace 10.77.0.0/24 dev tun1 table 600
ip -4 route replace default via 203.0.113.1 dev usb0 table 600
ip -4 rule add priority 1500 from 10.77.0.0/24 lookup 600

The rule sits at a priority below any existing catch-all rule, so matching clients hit table 600 first and everything else falls through to the main table. The ip-rule reference describes the ordered lookup model and the selector syntax in full.

Then think about what the table does not contain. A destination that only has a route in main, such as a management subnet reachable through eth0, will not be found in table 600 and will not fall back to main once a default exists there. If those paths matter, add them to the selected table explicitly, which is usually the right answer for a VPN egress table.

Keep the listener's own traffic out of the policy

The OpenVPN listener has an outer connection of its own, carried over whichever uplink the client reached it on. That traffic must keep the return path it arrived with, and a broad rule covering the server's public address or the whole tunnel interface can drag it into the alternate table. Scope the rule to the inner pool and nothing else.

Reverse-path filtering deserves a look for the same reason. When replies to clients leave by usb0 while the request arrived over a different link, a strict rp_filter setting can drop packets that the routing policy considers valid. Check the effective sysctl values on both interfaces before concluding that the rule failed.

Ordering also matters relative to anything else on the box. Docker bridges, a second VPN, and any pre-existing rules from a provider image all add priorities, so read the full rule list rather than assuming priority 100 is alone.

Make forwarding and translation agree with the route

Routing selects an uplink; it does not grant permission to forward. The kernel has to have IPv4 forwarding enabled, and the firewall has to accept the client-to-WAN direction and the return traffic. A correct table with a FORWARD chain that drops the flow produces the same symptom as a missing route, which is why both get checked in the same pass.

If the second WAN will not route a private pool back to you, translate the source on that egress. For a dynamically addressed interface, a narrow masquerade rule scoped to the pool and the interface is the usual choice:

iptables -t nat -A POSTROUTING -s 10.77.0.0/24 -o usb0 -j MASQUERADE

Scope matters more than the target here. A masquerade rule that matches any outbound interface will also rewrite traffic that correctly leaves by eth0, which hides routing mistakes instead of fixing them. Translation runs after the routing decision, so it can never repair a packet sent to the wrong uplink.

Tunnel overhead also needs attention once paths differ. If clients negotiate a large TCP MSS and the alternate WAN has a lower MTU or a tunnel inside it, large segments stall while small ones work. Clamping MSS on the forwarding path turns that into a predictable behaviour rather than an intermittent hang.

Decide what happens when usb0 disappears

An empty table is not a fail-closed state. If the matching route disappears, lookup continues to the next rule, reaches the main table and takes the eth0 default. Traffic that was supposed to leave by the second WAN quietly leaves by the first, which for a policy-routed deployment is usually the outcome you least want.

Where that fallback is unacceptable, add an explicit terminal route in table 600, such as a blackhole default, or drop the pool in the firewall while the interface is down. Either choice is a decision you can write down; silence is not.

Persistence is the last item. Whoever owns both links, whether that is the network manager, a dispatcher script or the connection lifecycle, has to recreate the routes and the rule on every reconnect without appending duplicates. Verify with a request from a real client and a separate request from the gateway, confirm the observed egress on both, then pull the second WAN and watch which of the two outcomes you designed actually happens. IPv6 needs its own table, rule and translation; the IPv4 rule above says nothing about it.

Qillqakuna t'inkisqa