The hook works the first time, and after three reconnects the chain holds three copies of the same rule. Nothing failed loudly; the script simply did what it was told each time it ran. A VPN hook is a configuration operation that can run more than once, so it needs an owner, a desired state and a serialized change. Walk it in that order and the duplicates stop.

Give the rules an owner before writing any

Decide which chain or table this integration owns before you write a single rule. A dedicated chain that the hook creates and flushes is the clearest form of ownership, because cleanup can then remove that chain without touching anyone else's policy. If you must add rules to a shared chain, tag them with a comment so a later search can find exactly yours. Ownership is what makes teardown safe, and teardown is the part that fails quietly.

iptables -N VPN_HOOK 2>/dev/null || true
iptables -C INPUT -j VPN_HOOK 2>/dev/null || iptables -I INPUT -j VPN_HOOK
iptables -S VPN_HOOK

A comment is not decoration here. It is the difference between removing your rule and removing a rule that happens to look similar, and it survives in the saved ruleset. Write the comment once, in a variable, and use the same string in every command that adds, tests or deletes.

Test for the desired state instead of adding again

Write down what should exist while the tunnel is up: which interfaces, which source and destination, which protocol and which port. Then express each rule as a test followed by an insertion, so a second run finds the rule and does nothing. The check command exits zero when the rule exists, which is exactly the signal the script needs. The iptables reference documents the check, append and insert forms used here.

iptables -C VPN_HOOK -i tun0 -p tcp --dport 443 -j ACCEPT ||
  iptables -A VPN_HOOK -i tun0 -p tcp --dport 443 -j ACCEPT

Be careful about position. Checking for a rule and inserting at the head can move an allow ahead of a deliberate denial that used to come first, so the second run changes behavior even though it adds nothing. Append inside your own chain, and control ordering by where the jump to that chain sits. If ordering matters inside the chain, rebuild the chain from a known list rather than patching it rule by rule.

Stop if the rule you need cannot be expressed as a stable test. That usually means the desired state is still vague.

Serialize the change instead of hoping it holds

Two hook invocations can overlap, and both can observe an absent rule before either adds it. The xtables wait option makes a command wait for the lock, but it does not make a check-then-add sequence atomic across two commands. Serialize the whole operation with your own lock, or use a manager that applies a declared batch as one transaction. nftables can do the latter, which is one reason to prefer it on hosts where it is available.

flock /run/vpn-hook.lock /usr/local/sbin/vpn-firewall-apply
nft -f /etc/nftables/vpn-hook.nft

Whichever mechanism you choose, keep the critical section short and make it reentrant: a hook that fails halfway should be safe to run again. Log the outcome so a repeated run is visible in the journal instead of being inferred from a rule count. If two different supervisors can invoke the hook, fix that first; no lock repairs two owners.

Tear down only what you own

Teardown has to tell a replacement connection apart from the one it retired, and it has to survive an event that never gets a graceful cleanup. Delete by the same identifiers you used to add, and never flush a shared chain to make cleanup simple. After a hard stop, run a reconcile pass at the next start rather than assuming the down hook ran.

iptables -D VPN_HOOK -i tun0 -p tcp --dport 443 -j ACCEPT
iptables -F VPN_HOOK
ip -brief link show type tun

Interface lifetime belongs in the design too. A reconnect can create a device with a different name, so a rule that hardcodes the second tunnel's name becomes stale while still looking valid. Prefer the identity the connection itself exposes, and re-read it on each run. Test the three events separately: a repeated connect, a replacement connect and a shutdown. The allowed traffic should survive all three without duplicates, and unrelated services should be untouched.

The walkthrough ends when the hook has an owner, a test for every rule it adds, one serialized path, and a teardown that removes only its own state. A hook that is safe to run twice is worth more than a hook that is clever once.

Mikanda mikatakata