The health check
Routing tables send traffic the right way, but something has to decide which ISP the office uses, and change it when one fails. Here it's a short shell script, run every 5 seconds by a systemd timer.
Check the right thing
The obvious test is "is the link up?" or "does the ISP's router answer?". Both miss the most common failure: the ISP's own network breaking somewhere upstream while its router, and the link to you, look perfectly healthy. So the script pings targets on the far side of the internet, from each ISP's own address (ping -I 198.51.100.2), which the source rules route through that ISP. An ISP counts as working if any target answers, so one target being down doesn't trigger a failover. In real life, use two or three well-known, reliable addresses run by different organisations.
Don't flap
- Leave ISP 1 only after 3 failed checks in a row: one lost ping isn't an outage.
- Go back only after 6 good checks in a row, so an ISP that's coming and going doesn't drag the office with it.
- Only switch to ISP 2 if ISP 2 is working.
Switch cleanly
Switching is one command, ip route replace default via … in the main table; masquerade then uses the other ISP's address for new connections by itself. But existing connections can't move: they are NATed to the old ISP's address, which the new ISP will drop. The script deletes them from conntrack (conntrack -D --reply-dst <old address>), so that the next packet of each starts a fresh, correctly NATed flow, instead of failing silently until it times out. TCP sessions still break (the server sees a new address); applications reconnect.
Each change is logged with logger, so journalctl -t dualwan is the router's failover history.
Run it with systemd
A timer (OnUnitActiveSec=5s, with AccuracySec=1s so it really runs every 5 seconds) starts a oneshot service, which runs the script once. Enable the timer, and its first run picks ISP 1.
#!/bin/sh
# /usr/local/sbin/dualwan-check: run every 5 seconds by dualwan-check.timer.
# Use ISP 1 while it works, ISP 2 while it doesn't, and go back once ISP 1 recovers.
TARGETS="192.0.2.53 192.0.2.80" # far side of the internet, not the ISPs' own routers
FAIL_AFTER=3 # failed checks in a row before leaving ISP 1
RECOVER_AFTER=6 # good checks in a row before going back
S=/run/dualwan; mkdir -p $S
reachable() { # reachable SOURCE: does any target answer from this ISP's address?
for t in $TARGETS; do ping -c 1 -W 1 -I "$1" "$t" >/dev/null 2>&1 && return 0; done
return 1
}
count() { echo $(( $(cat $S/$1 2>/dev/null || echo 0) + 1 )) > $S/$1; cat $S/$1; }
use() { # use NAME GATEWAY DEVICE OLD-ADDRESS
ip route replace default via "$2" dev "$3"
echo "$1" > $S/active
# Connections NATed to the other ISP's address can't continue: forget them now.
n=$(conntrack -D --reply-dst "$4" 2>&1 | grep -o '[0-9]* flow entries' || echo "0 flow entries")
logger -t dualwan "now using $1 (cleared $n)"
}
if reachable 198.51.100.2; then rm -f $S/fail1; ok=$(count ok1); fail=0
else rm -f $S/ok1; fail=$(count fail1); ok=0; fi
active=$(cat $S/active 2>/dev/null || echo none)
if [ "$active" != isp1 ] && { [ "$active" = none ] || [ "$ok" -ge $RECOVER_AFTER ]; } && [ "$fail" -eq 0 ]; then
use isp1 198.51.100.1 eth0 203.0.113.2
elif [ "$active" != isp2 ] && [ "$fail" -ge $FAIL_AFTER ] && reachable 203.0.113.2; then
logger -t dualwan "isp1 failed $fail checks in a row"
use isp2 203.0.113.1 eth1 198.51.100.2
fi- Line 4: Probe beyond the ISPs. Any one answering is enough.
- Lines 5–6: Leave ISP 1 after 3 failures; go back after 6 successes.
- Line 10:
-Isets the source address, so the source rules pick the ISP for each probe. - Lines 15–21: Switching: change the main default route, then forget connections NATed to the old ISP.
- Lines 27–28: Back to ISP 1 (or first run) once it's been good long enough.
- Lines 29–31: To ISP 2 after 3 failures, if ISP 2 works.
# /etc/systemd/system/dualwan-check.service
[Unit]
Description=Choose the working ISP
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/dualwan-check# /etc/systemd/system/dualwan-check.timer
[Unit]
Description=Check both ISPs every 5 seconds
[Timer]
OnActiveSec=5s
OnUnitActiveSec=5s
AccuracySec=1s
[Install]
WantedBy=timers.target- Lines 6–7: First run 5 s after start, then 5 s after each run.
- Line 8: Without this, systemd may run it up to a minute late.
root@gw:~# systemctl enable --now dualwan-check.timer && sleep 7 && journalctl -t dualwan --no-pager -o short-iso && ip route show default && systemctl list-timers dualwan-check --no-pager
Created symlink '/etc/systemd/system/timers.target.wants/dualwan-check.timer' → '/etc/systemd/system/dualwan-check.timer'.
2026-10-21T08:07:32+00:00 gw dualwan[340]: now using isp1 (cleared 0 flow entries)
default via 198.51.100.1 dev eth0
NEXT LEFT LAST PASSED UNIT ACTIVATES
Wed 2026-10-21 08:07:37 UTC 3s Wed 2026-10-21 08:07:32 UTC 1s ago dualwan-check.timer dualwan-check.service
1 timers listed.
Pass --all to see loaded but inactive timers, too.- Line 3: The first run picks ISP 1.
- Lines 5–6: The next run is a few seconds away.
Check your understanding
Why does the script ping addresses beyond the ISPs, instead of the ISPs' routers?
- Because an ISP often fails inside its network while its router and your link stay up.
- Because ISP routers don't answer pings.
- To measure the internet's speed.
- Because the link state can't be read from a script.