I lost the better part of three days to one FortiOS behaviour while connecting two sites over IPsec. It bit me four times: three different destination subnets, then once more in the return direction. Each time the firewall policy, the phase 2 selectors and the static route all looked correct, and traffic still vanished or leaked out to the internet.
This post is the explanation I wish I'd had, and the diagnostic order that finds it in minutes. Addresses and interface names are changed.
The setup
Two sites, each behind a FortiGate, joined by route-based IPsec tunnels. Site A has two internet links bundled into SD-WAN. Someone had added an SD-WAN rule to load-balance internet traffic across both:
config system sdwan
config service
edit 1
set name "wan_load_balance"
set mode load-balance
set dst "all"
set src "all"
...
next
end
end
Then I added a new remote subnet, 10.2.0.0/24, behind the tunnel to-siteB:
config router static
edit 0
set dst 10.2.0.0 255.255.255.0
set device "to-siteB"
next
end
Firewall policy allowing LAN to the tunnel: done. Phase 2 selectors covering both subnets: done. Tunnel up: yes.
From a real host on the LAN, nothing reached Site B.
Why the static route loses
FortiOS doesn't go straight to the routing table. Roughly, the forwarding decision checks, in order:
- Policy routes (
config router policy) - SD-WAN service rules
- The routing table (FIB)
SD-WAN service rules are policy routes under the hood. A rule matching src all / dst all therefore claims every forwarded packet before the routing table is consulted. However specific your static route is, it never gets a vote.
The packet goes out of an SD-WAN member link, source-NATed to that link's public IP, towards a private address on the internet. It doesn't arrive anywhere, and nothing logs a "deny", because by its own rules nothing was denied.
Note that the built-in implicit SD-WAN rule follows the routing table. The trap is an explicit catch-all rule, which many deployments add for load balancing.
Why it looked fine at first
My first test was a ping from the FortiGate itself:
execute ping-options source 10.1.0.1
execute ping 10.2.0.10
It worked. Self-originated traffic isn't forwarded through the same policy-route path as traffic arriving from a LAN host, so it takes the static route and goes into the tunnel.
Lesson one: test with real traffic from a real host. A ping from the firewall proves the tunnel works, not that forwarding works.
The fix, part one: outbound
Add a policy route ahead of the SD-WAN rules for the new destination:
config router policy
edit 0
set input-device "lan"
set src "10.1.0.0/255.255.255.0"
set dst "10.2.0.0/255.255.255.0"
set output-device "to-siteB"
next
end
Traffic from the LAN now entered the tunnel. The remote side saw the packets arrive and replied.
Replies still never reached the LAN host.
The fix, part two: the return path
This is the one that cost the most time. A reply comes out of the tunnel, gets decrypted, and then needs a second routing decision to reach the directly connected LAN. That decision goes through the same order, and the catch-all SD-WAN rule claims it too.
The symptoms looked like a hardware fault:
- Tunnel counters showed decrypted packets increasing.
- The sniffer showed nothing leaving the LAN interface.
- No session, no log entry.
diagnose debug flow finally showed what was happening:
Match policy routing id=...: to 10.1.0.25 via ifindex-<wan member>
find a route: gw-<isp gateway> via <wan member>
...
policy-0 is matched, act-drop
Denied by forward policy check (policy 0)
Policy 0 is FortiOS's implicit deny. The packet only reached it because routing had already chosen the WAN link for a reply meant for the LAN, and no firewall policy allows tunnel-to-WAN.
The fix is the mirror image:
config router policy
edit 0
set input-device "to-siteB"
set src "10.2.0.0/255.255.255.0"
set dst "10.1.0.0/255.255.255.0"
set output-device "lan"
next
end
Lesson two: every new tunnel destination needs a policy route in both directions. The return direction is not inherited.
The diagnostic order that finds it fast
- Use a real host. Generate traffic from a LAN machine, not the firewall.
- Sniff it.
diagnose sniffer packet any "host 10.2.0.10" 4 30
If you see it leaving a WAN member with a NATed source instead of the tunnel, SD-WAN has it.
- Check which policy routes are being hit.
diagnose firewall proute list
A hit_count of 0 on your new rule means it never matches. Compare it with a working sibling rule.
- Clear stale sessions before retesting. Offloaded sessions keep their old path until cleared:
diagnose sys session filter dst 10.2.0.10
diagnose sys session clear
-
Trace the flow. Still stuck?
diagnose debug flownames the exact policy route and gateway chosen. It's easiest to capture from the GUI's debug flow tool, because streaming live debug over a non-interactive SSH session is unreliable. -
Trust
getovershow. In a few edge casesshowomitted fields that were set.getinsideedit <id>gives the live values.
What didn't help
I tried these before finding the cause. None of them touch the routing decision, so none of them helped:
- Disabling ASIC/NPU offload on the firewall policy
- Disabling NPU offload on the phase 1 interface
- Widening the firewall policy to
all/all
If the sniffer shows the packet leaving the wrong interface, the problem is routing. Firewall policy changes won't fix it.
Takeaways
- An SD-WAN rule matching
dst alloutranks every static route for forwarded traffic. - Add explicit policy routes for internal and tunnel destinations, in both directions.
- Prove forwarding with traffic from a real host. A firewall-originated ping proves only the tunnel.
- When a decrypted packet "disappears", trace the second routing decision before blaming the hardware.
Better still, scope the load-balancing rule to internet destinations in the first place, for example with a negated RFC 1918 destination group. Then internal traffic never matches it.
Top comments (0)