When you add an IPv6 prefix to a site-to-site tunnel, the packet does not travel the way the diagram suggests. The local gateway takes the whole IPv6 packet, encrypts it, and puts an outer IP header in front whose address family is decided by the endpoint addresses, not by what is inside. The far gateway strips that outer header and forwards the original packet onto its LAN, where it is an ordinary IPv6 packet again. Outer tunnel and inner traffic are two separate conversations that share a key, an endpoint pair, and nothing else.

Two headers, two address families

In IPsec tunnel mode the inner IPv6 packet becomes the payload behind an ESP header. The outer header carrying that ESP packet can be IPv4 or IPv6, and it follows local_addrs and remote_addrs. WireGuard does the same with less ceremony: the inner packet is the payload of a UDP datagram to the peer's endpoint. Both leave the inner header untouched, which is why the far side can route it after decapsulation.

The path between the sites sees only UDP 4500 when IPsec uses NAT traversal, or UDP 51820 for a default WireGuard listener. Every router out there could be IPv4-only and the IPv6 traffic still crosses, because it never appears as an IPv6 packet on that path. That gives a clean first split: tcpdump -ni eth0 udp port 51820 shows the encrypted path, tcpdump -ni wg0 ip6 shows the inner traffic. A busy outer capture with a silent inner one means the kernel is not putting IPv6 into the tunnel, which is routing or policy rather than link state.

The gateway also decrements the inner Hop Limit as it forwards, so the endpoints count as routers on the IPv6 path. Outer hop count and inner Hop Limit move independently, and a packet that dies at the far gateway died in forwarding, not encryption.

Selectors and AllowedIPs are the permission list

IKEv2 negotiates which inner prefixes a child SA may carry. On strongSwan 5.9 and later, spelling them out in swanctl.conf looks like this:

children {
  net6 {
    local_ts  = 2001:db8:aa::/48
    remote_ts = 2001:db8:bb::/48
    mode = tunnel
    esp_proposals = aes256gcm16-prfsha384-ecp384
    start_action = trap
  }
}

Omit local_ts and remote_ts and strongSwan derives them from the subnets of the interface it selects. On a dual-stack host that shortcut often yields an IPv4-only pair: the SA comes up and IPv6 is refused before it reaches the ESP code. Writing both prefixes out makes the SA eligible to carry them, and start_action = trap brings it up on the first matching packet.

WireGuard has one list doing two jobs. In the peer block, AllowedIPs = 2001:db8:bb::/48, fd00:bb::/48 is both the route installed on send and the filter applied on receive: a decrypted packet whose inner source falls outside that peer's list is dropped silently. wg-quick adds a route per entry, so the peer block below needs no separate ip -6 route command.

[Interface]
Address = 2001:db8:aa:ff::1/64, fd00:aa:ff::1/64
ListenPort = 51820

[Peer]
PublicKey = <site-b-public-key>
Endpoint = 203.0.113.20:51820
AllowedIPs = 2001:db8:bb::/48, fd00:bb::/48

Both lists have to be mirrored: site B accepts site A's prefix as a remote selector and routes it back, and the tunnel only works when the two directions agree. An asymmetric list gives the symptom that wastes the most time — a healthy handshake, counters moving on one side, and a ping from A to B that succeeds while nothing from B reaches A.

Forwarding, routes, and the return prefix

Linux will not forward IPv6 until you say so, and the setting is per namespace rather than per interface:

sysctl -w net.ipv6.conf.all.forwarding=1
ip -6 route add 2001:db8:bb::/48 dev wg0
ip -6 route get 2001:db8:bb::10 from 2001:db8:aa::10

The second line is the one people forget on route-based designs. Policy-based IPsec installs an XFRM policy matching the prefixes, but a route-based setup with vti6 or WireGuard needs an actual IPv6 route pointing at that device. Persist the sysctl under /etc/sysctl.d/; a reboot silently restores forwarding to off otherwise.

Forwarding has a side effect worth knowing: the kernel then ignores router advertisements unless net.ipv6.conf.<wan>.accept_ra is 2, so a gateway whose WAN address arrives by SLAAC can lose its default route the moment you enable forwarding. Set accept_ra = 2 on that interface in the same file and the route survives. Site B's core must also learn 2001:db8:aa::/48 from the tunnel gateway, by static route or a routing protocol over the tunnel, or the reply dies inside B's own network.

A four-step check that localises the break

Work in this order and stop at the first step that fails, because each later step depends on the earlier ones.

  1. Selectors: swanctl --list-sas should show the IPv6 prefix pair on the child SA, or wg show wg0 allowed-ips the remote prefix with a recent latest-handshakes.
  2. Kernel route: ip -6 route get 2001:db8:bb::10 must answer with the tunnel device, not the WAN interface.
  3. Inner capture: tcpdump -ni wg0 ip6 while pinging between real LAN hosts. Packets leaving but none returning point at B's route or firewall; no packets at all point back at step 1 or 2.
  4. Destination: on the B host, ss -6 -ltn confirms the service listens on IPv6, and the local firewall counters show whether the packet was accepted or dropped.

MTU belongs in the same pass. IPv6 routers do not fragment, so an oversized packet is dropped and Packet Too Big comes back to the sender. If handshakes and small pings work but a file transfer stalls, shrink the payload until it passes: ping -6 -s 1400 -c 3 2001:db8:bb::10. WireGuard interfaces default to 1420 bytes of MTU and IPsec over IPv4 subtracts more, so setting the inner MTU beats hoping for path MTU discovery through a firewall that drops ICMPv6.

Failure modes that look like firewall problems

The most common one is not a firewall at all: the service binds to 0.0.0.0 instead of ::, the AAAA record exists, and the client gets a fast refusal over IPv6 that a dual-stack browser hides by falling back to IPv4. If only some applications fail, check the listening family before touching a rule set.

Second is ICMPv6 filtering. Neighbor Discovery and Packet Too Big are part of normal operation, not diagnostics, and a policy that drops all ICMPv6 produces a tunnel that connects, authenticates, then stalls on anything larger than a small packet. Permit the types the path depends on and leave counters on so a drop is visible.

Third is a prefix added to the tunnel but never to the address plan of the site that owns it, so no router advertises it and no host can answer from it. Start with one prefix and one host pair, prove both directions with the four steps, then add the rest; a broken prefix stays one line in a config instead of a redesign. The one official reference worth keeping open is RFC 8200, which defines the header the tunnel carries.

Akụkọ ndị metụtara ya