- Summary With a policy-based IPSec tunnel over an in-kernel wireguard tunnel (if_wg.ko), it seems like the outer ip-ip header is not stripped at the correct time for state tracking to work. This results in TCP sessions (UDP and ICMP are unaffected) dropping after an initial window of approximately 64k. - Reproducing Create a wireguard tunnel using the kernel module. Create an IPSec tunnel in policy mode, with the outer layer routed over the wireguard tunnel. Test TCP connectivity over the IPSec tunnel with some traffic (I've been using iperf, but any TCP-based protocol shows similar results) Observe that the session drops after a small amount of traffic. - Troubleshooting steps taken A few different hints I've found that made me conclude this is likely a kernel issue: 1. Creating firewall rules for the enc0 interface with state tracking disabled fixes the issue. 2. Using the (old) go implementation of wireguard fixes the issue. 3. The following dtrace program: dtrace -x nolibs -qn ' struct pf_addr { uint8_t addr8[16]; }; struct pf_state_key_cmp { struct pf_addr addr[2]; uint16_t port[2]; uint8_t af; uint8_t proto; uint8_t pad[2]; }; pf:ip:state:lookup /arg2 == 1 && stringof((char *)arg0) == "enc0"/ { this->k = (struct pf_state_key_cmp *)arg1; printf("enc0 IN proto=%d %d.%d.%d.%d <-> %d.%d.%d.%d ports=%d,%d %s\n", this->k->proto, this->k->addr[0].addr8[0], this->k->addr[0].addr8[1], this->k->addr[0].addr8[2], this->k->addr[0].addr8[3], this->k->addr[1].addr8[0], this->k->addr[1].addr8[1], this->k->addr[1].addr8[2], this->k->addr[1].addr8[3], ((this->k->port[0]&0xff)<<8)|((this->k->port[0]>>8)&0xff), ((this->k->port[1]&0xff)<<8)|((this->k->port[1]>>8)&0xff), arg4 ? "HIT" : "MISS"); }' produces the following log line when run with if_wg: enc0 IN proto=4 <wg peer IP> <-> <local wg IP> ports=0,0 HIT and the following when run with the old wireguard-go: enc0 IN proto=6 192.168.1.10 <-> 192.168.20.60 ports=5201,49970 HIT 4. While an iperf test is running, pf logs the following lines: <13><...> pf: loose state match: TCP out wire: 192.168.1.10:5201 192.168.20.60:42200 stack: - [lo=4067707952 high=4067644560 win=502 modulator=0] [lo=0 high=502 win=1 modulator=0] 2:0 A seq=4067707952 (4067707952) ack=0 len=1348 ackskew=0 pkts=50:0 dir=out,fwd <13><...> pf: loose state match: TCP out wire: 192.168.1.10:5201 192.168.20.60:42200 stack: - [lo=4067709300 high=4067644560 win=502 modulator=0] [lo=0 high=502 win=1 modulator=0] 2:0 A seq=4067709300 (4067709300) ack=0 len=1348 ackskew=0 pkts=51:0 dir=out,fwd <13><...> pf: BAD state: TCP out wire: 192.168.1.10:5201 192.168.20.60:42200 stack: - [lo=4067710648 high=4067644560 win=502 modulator=0] [lo=0 high=502 win=1 modulator=0] 2:0 A seq=4067710648 (4067710648) ack=0 len=1348 ackskew=0 pkts=52:0 dir=out,fwd <13><...> pf: State failure on: 1 | 5 5. No change in hardware, system version, firewall settings (except those that disable stateful filtering on enc0), tunnel settings, etc. has made any detectable difference to the issue. - Environment This issue has surfaced on an OPNSense installation, an issue with OPNSense unfortunately could not be solved (https://github.com/opnsense/core/issues/8680). I have reproduced this bug on OPNSense, Version 23.7.12 (FreeBSD OPNsense.localdomain 13.2-RELEASE-p7 FreeBSD 13.2-RELEASE-p7 stable/23.7-n254871-d5ec322cffc SMP amd64) since that is the last version that also supported the wireguard-go implementation, which lets me swap between implementations quickly. I have also reproduced the issue on OPSense Version 26.1 (FreeBSD OPNSense.localdomain 14.3-RELEASE-p15 FreeBSD 14.3-RELEASE-p15 stable/26.1-n272132-0101c21cac77 SMP amd64) with no change in behaviour at all. I can try to reproduce the issue on vanilla FreeBSD 15.1 for example, though I don't expect OPNSense or the FreeBSD version to be the issue here, given the symptoms. If you'd like me to try and produce any further debug logs or try a modified setup, let me know. I know the setup for reproduction is a bit special, and I have a working setup I can modify easily.
Can you share the PF ruleset? Are the states per interface or global?
Created attachment 272602 [details] pfctl -sa
The states seems to be bound to the enc0/vtnet1 interface during the tests. For context: vtnet1 is LAN, 192.168.20.0/24 in the test setup. root@OPNsense:~ # pfctl -vvss No ALTQ support in kernel ALTQ related functions disabled <...> all tcp 192.168.1.10:5201 <- 192.168.20.60:38242 ESTABLISHED:ESTABLISHED [4052195990 + 4294639872] wscale 7 [268115414 + 4294902016] wscale 7 age 00:00:03, expires in 23:59:57, 7:8 pkts, 537:428 bytes, rule 72 id: ca2d4e6a00000000 creatorid: 6ec3bd86 gateway: 0.0.0.0 origif: vtnet1 all tcp 192.168.20.60:38242 -> 192.168.1.10:5201 SYN_SENT:CLOSED [268115414 + 1543438336] [0 + 4127260672] age 00:00:03, expires in 00:00:27, 7:0 pkts, 537:0 bytes, rule 68 id: cb2d4e6a00000000 creatorid: 6ec3bd86 gateway: 0.0.0.0 origif: enc0 all esp <local wg IP> -> <wg peer IP> MULTIPLE:MULTIPLE age 00:00:03, expires in 00:00:57, 59:59 pkts, 73364:7316 bytes, rule 80 id: cc2d4e6a00000000 creatorid: 6ec3bd86 gateway: 0.0.0.0 origif: wg1 all ipencap <local wg IP> <- <wg peer IP> NO_TRAFFIC:SINGLE age 00:00:03, expires in 00:00:27, 59:0 pkts, 4268:0 bytes, rule 67 id: cd2d4e6a00000000 creatorid: 6ec3bd86 gateway: 0.0.0.0 origif: enc0 all tcp 192.168.1.10:5201 <- 192.168.20.60:38256 ESTABLISHED:ESTABLISHED [124433020 + 4294639872] wscale 7 [353817828 + 950009856] wscale 7 age 00:00:03, expires in 24:00:00, 74:51 pkts, 99601:2660 bytes, rule 72 id: ce2d4e6a00000000 creatorid: 6ec3bd86 gateway: 0.0.0.0 origif: vtnet1 all tcp 192.168.20.60:38256 -> 192.168.1.10:5201 SYN_SENT:CLOSED [1303892964 + 3623747328] [0 + 4127260672] age 00:00:03, expires in 00:00:27, 52:0 pkts, 68801:0 bytes, rule 68 id: cf2d4e6a00000000 creatorid: 6ec3bd86 gateway: 0.0.0.0 origif: enc0
Non of your rules have the state-policy set of if-bound. I wonder if the states once created match on the wrong interface.
As far as I understand it, the issue seems to be that the responses (inbound on enc0) are missing the state which the outbound connection created. I would assume a floating state would be more likely to hit here than an if-bound one. Or can the packet match a single state only once? Either way, the dtrace output would lead me to believe that the kernel is trying to look up the outer (IP-IP encapsulated) packet on enc0 for state matching, and not the inner packet which pf misses entirely on enc0. To be clear, "enc0 IN proto=4 <wg peer IP> <-> <local wg IP> ports=0,0 HIT" is the _only_ line that program prints, apart from an initial "MISS", which creates this state in pfctl -vvss : all ipencap <local wg IP> <- <wg peer IP> NO_TRAFFIC:SINGLE age 00:00:03, expires in 00:00:27, 59:0 pkts, 4268:0 bytes, rule 67 id: cd2d4e6a00000000 creatorid: 6ec3bd86 gateway: 0.0.0.0 origif: enc0