Picture a media box on a home uplink that is supposed to reach the internet only through a tunnel. When the tunnel drops, the application should stall rather than quietly continue over the physical interface. Getting that behaviour is a firewall problem rather than a routing problem, and the distinction matters because a route can be replaced by a daemon restart while a filter stays where you put it. What follows is how the packet path actually decides, and then how to bound the exceptions that keep the tunnel reconnecting.

Local output is a different hook from forwarding

A packet leaving an application on the box traverses the output hook; a packet the box routes for another host traverses forward. A policy that mentions only forward constrains the second and leaves the first wide open, which is why so many egress locks look correct and do nothing. Interface matching belongs in the same rule: oifname "tun0" accept passes traffic that has already been routed out the tunnel. Routing and filtering also stay separate decisions, so an uplink route can exist while the filter refuses the packet. That separation is the mechanism you are relying on.

A gateway that forwards for other devices needs both hooks. Locking only output protects the box itself and leaves every downstream client free to leak.

Write the exception list from the endpoint outward

Start with the tunnel endpoint: one destination address or a small approved set, the transport protocol, and the destination port, for example udp dport 1194 ip daddr 203.0.113.7 accept. If the client resolves a hostname, the bootstrap DNS exchange needs its own rule scoped to the resolver and to port 53, not a blanket permit to that resolver's address. Every exception should name the reason it exists. An allowance for all UDP traffic to the VPN port covers far more than the single authenticated service you had in mind.

Bootstrap resolution is where locks usually rot. An address set that refreshes on a timer needs defined behaviour when the refresh fails, and that behaviour cannot be to open the uplink until it succeeds. Refresh over the tunnel while the tunnel is up, and fall back to the last known good endpoint otherwise.

Local administration needs the same treatment. A management subnet is not trustworthy because its addresses are private, and a rule that trusts it invites a second path around the lock.

Interface matching is not proof of a healthy tunnel

Rules that accept anything leaving tun0 assume a packet on that interface came from the tunnel. Interface names get recreated, and a name is not a cryptographic identity. The peer can also be gone while the interface stays up, which makes the rule a very convincing no-op.

Connection tracking adds a related trap. A broad established-connection rule replays the permission a flow obtained earlier, so a session that started before the tunnel dropped keeps working afterwards. Restrict stateful acceptance to the interfaces and addresses you actually mean, or you have built a lock that opens itself. Input and output rules answer different questions too, and hardening one while ignoring the other leaves the box reachable in the direction you did not consider.

What survives a daemon crash

A filter installed by a script that runs after the tunnel comes up disappears with the tunnel, at the precise moment it was needed. Load the lock at boot and let the daemon manage only its exceptions.

Deleting the default route is not a substitute either. The route is state the daemon owns and rewrites, and removing it can take away the path needed to reconnect. IPv6 needs its own plan, because neighbour discovery and router advertisements are link services that must keep working while ordinary traffic is blocked. DHCP belongs in the same category. Write these down as exceptions with a purpose, rather than discovering them later as outages.

Recovery path before deployment

Test the lock the way it will fail: boot with no tunnel, kill the daemon, change the endpoint, and reconnect. Each case should stall the applications and leave the recovery channel working. Do it from a console, since a remote shell is the session most likely to be locked out by your own rule.

Then check what an ordinary application can still reach, using a harmless destination and a request you can verify. The result you want is a refusal on the uplink and success through the tunnel, not an ambiguous timeout that could mean either. Keep the rule set under the same owner as the rest of the firewall instead of replacing the whole table from a shell script. A policy where every allowance can justify itself is worth more than a long ruleset nobody can explain.

Makomi ya ndenge moko