One moment the laptop is on office Wi-Fi, the next it is tethered to a phone, and the tunnel indicator is green again before you finish switching windows. A green light only reports that the tunnel reconnected; what the routing and blocking policy did during the gap is a different question. That policy is a set of match conditions compiled when it was last installed, naming an interface, an address and an address family the handover has just removed. The same switch therefore produces three outcomes that look identical: blocking continues, the policy blocks its own recovery, or a path opens that nothing covers.

Nothing on screen separates those three states, so ask which identifiers the rules were pinned to instead of what colour the indicator shows. On Linux a rule can name an interface or an ipset filled from one; on Android a VpnService can be told which underlying networks to follow through setUnderlyingNetworks. Each binding points at a link that is about to be replaced, and each has to be re-evaluated afterwards.

What the handover actually rewrites

When the old link disappears the kernel removes its addresses, withdraws the routes that pointed through it, and rebuilds source address selection from what remains. The default route moves to the new gateway, and the interface index the old rules referenced is gone or recycled for another device. Per-link resolvers are refreshed too, though not in the same instant as the routes. All of this happens below the VPN client, so a client watching only its own tunnel state observes none of it.

The trouble is that a rule encodes a specific identifier instead of the intent behind it. A rule that drops everything not leaving through the tunnel interface encodes an interface name; a rule permitting the endpoint encodes an address the new network may route elsewhere. When the identifier vanishes the rule does not become visibly wrong, it either stops matching anything or starts matching the wrong thing.

Rebuilding the outer tunnel without punching a hole

The outer transport was bound to the old source address, so it has to open a new socket from the new one. When the endpoint is a hostname rather than a literal address, a lookup must succeed while the block is still in force. That is the ordering trap: a policy strict enough to drop everything except the endpoint will also drop the lookup needed to find it, unless resolution happens before the block is raised or is carried through a tunnel that is still alive. Either order can work, but only if the client knows which order it is in.

Clients differ here in a way that matters when you are diagnosing. Some resolve first and install rules afterwards, so a handover costs a visible outage and then recovers by itself; others keep the block up and depend on the surviving tunnel to carry the lookup, which fails outright when the tunnel died with the link. Adding a broad direct default to restore connectivity always makes the test pass and always leaves the leak you were preventing.

A route still pointing at the old gateway gives the same stuck recovery from a different cause, so read the table before blaming the client. The commands below name the interface and source address the kernel would really use, which settles in one line whether the old path is still installed.

ip route get 1.1.1.1
ip -6 route get 2606:4700:4700::1111

When the new link brings IPv6 and the rules did not

Cellular and guest networks often hand out a global IPv6 prefix even when the office wired LAN never did. A policy written as IPv4 rules only, the default shape of most hand-rolled iptables and nftables recipes, covers none of that. The tunnel stays connected, applications with IPv6 reachability leave outside it, and the indicator cannot show the difference because the tunnel really is up. The mirror case is just as common: an IPv6-only rule set does nothing at all on a network that offers no v6.

Compare what the link offers with what the policy mentions, on both sides of the handover. These print the same facts the client is working from:

ip -4 route show default
ip -6 route show default
sudo nft list ruleset | grep -c 'ip6 '

If the second command prints a default route while the third returns zero, the new link can leave the machine over IPv6 with no rule watching it. Run all three again after the switch, because the interesting number is the difference between the two runs. If the second prints nothing, the reverse case applies and any v6-heavy policy is decoration there.

The resolver changes hands mid-session

Resolvers are attached to links on modern systems, so a new link arrives with a new resolver set and possibly a different split-DNS view. systemd-resolved keeps a scope per link and chooses among them by route, while macOS reads scoped resolvers from the dynamic store and honours interface order. A client that installs its own resolver competes with the link's, and priority rules nobody watches decide the winner. Name resolution can then break while packet filtering does exactly what it was written to do.

Read the resolver that is active rather than the one configured. On a systemd host the status subcommand shows which link each scope came from and whether a private-DNS name is in force:

resolvectl status --no-pager
resolvectl query intranet.example.com

If the query leaves through the physical link rather than the tunnel, the client lost the priority contest and no firewall tuning will fix naming. Compare the query path on both sides of the handover to see which one moved. A name that resolves to a private address the new network cannot route is a third case, and it presents as a timeout rather than an error.

Drill the handover and keep the record

Reproduce the change on purpose instead of waiting for it. Bring the tunnel up on the first link, start a long-lived transfer and a repeated lookup, then switch the link and watch which one survives. Note the interval between the old link vanishing and the tunnel reporting connected, because that window is where the policy either held or did not. Run the sequence in the other direction as well, so the result is not one-sided.

Exceptions need their own pass, since a bypass justified on the old network is not automatically justified on the new one. A printer, a NAS or a captive portal on the home LAN has no counterpart on cellular, and the exception that reached it becomes a rule with no counterpart either.

Write down the interface names, the families offered, the resolver in force and the length of the outage for each direction. A reconnect proves the transport recovered, not that the block held during the gap, and those are separate claims to put in a ticket. With that record the next handover gets compared against a known state instead of against memory.

Ngaahi tokoni fekau'aki