IPv6 inside the tunnel, delivered¶
Measured on an XGS 3300, 2026-10-12, module f9f5ba7e. The second half of
issue 294's IPv6 item: an IPv6 packet
carried in the tunnel, encrypted by the coprocessor on the way out and decrypted, terminated and
handed to the kernel's IPv6 input on the way in. Over IPv4 outside, with AES-GCM-16, sourced at the
appliance itself, at 1.8 and 2.1 Gbit/s up and 1.3 and 1.7 down.
What changed in the driver¶
The trailer's next-header byte is where the inner family lives, and the driver had only read it
to refuse 41. Now:
- the termination takes
IPPROTO_IPV6besideIPPROTO_IPV4: the inner packet's version nibble and its payload length plus forty are checked against what is left after the trailer, the decrypted packet is shown to enc0 withAF_INET6and run through the IPv6 filter head, and queued toNETISR_IPV6;rx_v6, which counted refusals, counts deliveries; - the ESP interposition, which gets the packet with the outer header the kernel built, takes an
outer protocol of
41beside4and writes it as the trailer's next header; the tagged path takes ethertype0x86DDthe same way; - a too-big IPv6 packet is not fragmented (RFC 8200 4.5): a forwarded one is answered with ICMPv6
Packet Too Big carrying the size that fits, the appliance's own gets
EMSGSIZE; - the flow candidate, whose tuple parser is IPv4, is not offered an IPv6 packet.
The three measurements¶
The lab tunnel over its IPv4 10G path, with IPv6 selectors: a ULA /64 on the appliance's LAN bridge and the peer's ULA /128 on its bridge. The traffic is the appliance's own - the machine behind it could not be given an IPv6 address from the bench - so the outbound frames go through the ESP interposition, as every packet the appliance originates does.
| the association | IPv4 outside, the table's two rows as for any other; the kernel's policies fd00:…::/64 <-> fd00:…::2/128 with esp/tunnel/10.99… at both ends |
| five pings through the tunnel | 5/5, 0.34 ms |
| one stream up / down, 10 s | 1,820.8 / 1,325.5 Mbit/s |
| four streams up / down, 12 s | 2,136.0 / 1,699.7 Mbit/s |
| inbound, the peer's frames | rx_v6 +7,395,177 delivered to IPv6 input, rx_bad 0, rx_blocked 0; FROM_WIRE_TO_IPSEC_DECR +7,395,602; rx_done +7,184,569 - the 210,000 between the two are rx_queuefail, the IPv6 netisr queue refusing frames at 1.7 Gbit/s into the host's own stack, which TCP covered |
| outbound, the appliance's frames | tx_encrypt +7,397,169 = out_taken = FROM_KN_TO_IPSEC_ENCR; the peer's ESP input +7,397,169 exactly, its authentication drops 0; out_orig 0 - every packet took the interposition |
| the largest packet | a 1,398-byte ping passes (inner 1,446, the same as IPv4 inside); 1,402 is refused and not fragmented |
| the coprocessor | CRYPTO_DROP_REPLAY_OOW +94 in 7.4 million at a window of 32, TX_DROP_QUEUE_FULL +3 |
| the host | netisr 68.6 %, the load generator itself 63.8 %, one octep0 thread 54.9 % |
The rates are higher than every figure on these pages because this is the first measurement with
the appliance as the TCP endpoint on the 10G path: no 1 Gbit/s port in the way. They are the
coprocessor's GCM through the host's own IPv6 stack, both ways, and 2.1 Gbit/s is what the host's
netisr could feed it before the queue refused frames.
Four raises for one measurement¶
Each void one is a fact about the kernel, not the driver:
- The policies were installed at both ends and nothing moved:
ping6: No route to host. This kernel'sip6_outputselects the route (in6_selectroute) before it applies the policy (IPSEC_OUTPUT), so an inner IPv6 destination needs a route even though the policy will wrap the packet. The IPv4 lab never showed it: its inner destination is on-link. - The route added (the first time, not at all: the lab tool's
2>&1in a csh command string). - With the route,
ping6: Network is down: the kernel's offload path hands the tagged inner packet to the association's port as a plain IPv6 frame for the driver to wrap, and a port without an IPv6 address isifdisabledin OPNsense, sond6_resolveanswersENETDOWN. - A ULA on that port in the peer's /64 makes the inner destination on-link and the port IPv6-capable; the first ping answered.
So an IPv6 selector through this offload needs what an IPv4 one needs and OPNsense gives for free in IPv4: a route for the inner destination out of the association's port, and that port speaking the inner family.
The forwarded path, the same afternoon¶
The bench machine given a temporary address in the appliance's LAN /64 and a route to the peer's /64 through the appliance (its owner approving the elevation), the same lab raised again:
| five pings from the machine through the tunnel | 5/5 |
| one stream up / down, 10 s | 760.0 / 802.7 Mbit/s |
| four streams up / down, 12 s | 935.3 / 946.6 Mbit/s - the machine's 1 Gbit/s port, as with IPv4 inside |
| inbound, delivered and forwarded on to the machine | rx_v6 +3,613,105, rx_bad 0, rx_blocked 0; FROM_WIRE_TO_IPSEC_DECR +3,613,476 |
| outbound, the machine's packets through the tagged path | tx_encrypt +2,084,625 = FROM_KN_TO_IPSEC_ENCR; the peer's ESP input +2,084,625 exactly, its authentication drops 0 |
| the largest ping from the machine | 1,398 bytes of data lost (3/3), where the same size sourced at the appliance passes; 1,402 lost as it should be |
So the forwarded path carries IPv6 at the port's rate both ways. The 1,398-byte ping is the one thing it does differently from the appliance's own: the kernel's offload path has its own size check before the driver's, and where it draws the line for a tagged IPv6 packet was not bisected.
What is not established¶
The forwarded path for IPv6: a machine behind the appliance sending IPv6 into the tunnel takesRun the same afternoon, above.octep_ipsec_tx_preparewith the kernel's tag, compiled and not run - the bench machine could not take an IPv6 address.The exact largest IPv6 packet a machine behind the appliance can send into the tunnel, and whether it gets Packet Too Big for a larger one: 1,398 bytes of ping data is already refused on the forwarded path, not bisected, not captured.Bisected the same hour: 1,300 bytes of ping data pass and 1,350 do not, so the line is between 1,348 and 1,398 bytes of inner packet, a hundred below what the driver takes from the appliance itself; the driver's counters do not move for a refused one and the appliance's kernel counts a Packet Too Big per attempt, so it is the kernel's own line on the tagged path. Settled, by reading and then by the byte: a forwarded IPv6 packet entering an IPsec policy passesipsec6_check_pmtu(ipsec_output.c) before the offload decision, and that check subtractsipsec_hdrsiz_internal- the policy's worst case, computed with no association in hand - from the far end's path MTU. The worst case issizeof(newesp)8 +EALG_MAX_BLOCK_LEN64 (ChaCha20's native block, "keep this updated") + 9 + 16, plus the outer IPv4 header 20: 117 bytes, where AES-GCM-16 really needs 54. So on a 1,500-byte path the kernel refuses an inner packet above 1,383 with Packet Too Big; the bisect on the bench found 1,335 bytes of ping data passing and 1,336 not, which is 1,383 to the byte. The path MTU comes from the TCP host cache and, when the cache has no entry, from the route, which the check then writes into the cache - so the gate is always there (purging the cache changed nothing, and the entry was back after one ping). The IPv4 twin,ipsec4_check_pmtu, is off unlessnet.inet.ipsec.dfbitis set, which is why a 1,446-byte IPv4 packet with DF passes the same tunnel; the appliance's own packets skip the check (if (!forwarding) return 0), which is why 1,446 bytes pass from the appliance itself. Nothing in the driver can move the line; what would is the kernel using the association's own header size (esp_hdrsiz(sav), whichipsec6_check_pmtuhas in hand) instead of the policy's worst case - 63 bytes of path MTU for every AES-GCM tunnel that forwards IPv6 - which is a change for FreeBSD, proposed on the issue. In the meantime TCP through the tunnel adapts to the 1,383 it is told, as the four-stream rates above show.- The peer's side under its own offload: the peer runs the kernel's cipher.
Lessons¶
- A kernel that is told to encrypt still asks for a route first. Two of the four raises were the kernel refusing before the policy was consulted; the driver's counters were all zero both times, which said where to look.
- Every
2>&1in a quoted ssh command to a csh root is a measurement lost. Fourth time this day; the tools now have none.
Where it sits¶
The termination, the interposition and the too-big answer are in
octep_ipsec.c (octep_ipsec_rx, octep_ipsec_xf_output,
octep_ipsec_send_inner, octep_ipsec_tx_prepare). The numbers are in
the measurements.
The discussion of this page is held in the project's GitHub Discussions. Showing it here loads giscus from giscus.app and GitHub, and writing in it needs a GitHub account - what that means.