summaryrefslogtreecommitdiffstats
path: root/roles
diff options
context:
space:
mode:
authorAhmed Abdelhalim <[email protected]>2026-07-09 22:57:11 +0200
committerAhmed Abdelhalim <[email protected]>2026-07-09 22:57:11 +0200
commit1cbb9df950175e698e1edc87953e8f4bfb22b88a (patch)
tree1f303b78561e725cf65c9bd1e62a9e22273e078d /roles
parentd295dd0edcd61e094b7bd7ffe8ca3187d0a6da00 (diff)
Fix IPv6 first-request failure caused by ICMPv6 Redirects
Root cause: Pi, the ISP router (FritzBox), and LAN clients share the same L2 segment. When Pi forwards a client's IPv6 packet to an external GUA destination, the Linux kernel detects that the ISP router is a "better" next-hop on the same link and sends an ICMPv6 Redirect (type 137) to the client. The client obeys the redirect and sends its first SYN to the ISP router directly — Pi never forwards the original packet. The ISP router applies per-device content filtering to the unmasqueraded client GUA and issues a RST. This produced the symptom of the first IPv6 request failing with "Connection reset by peer" every ~30-60 seconds (matching the FritzBox RA interval, after which clients re-resolve their default gateway). Diagnosis method: tcpdump with MAC addresses (-e flag) on Pi's end0 caught the ICMPv6 Redirect being sent immediately after the first SYN arrived. Inserting a DROP rule for icmpv6-type redirect at position 1 in ip6tables OUTPUT confirmed the fix — 0 failures across extended testing. Fix: - Drop ICMPv6 Redirect (type 137) in ip6tables OUTPUT as the first rule, before the ACCEPT rule, in both direct and VPN modes - Flush ip6tables OUTPUT chain on clear_rules() instead of per-rule -D deletion, which was fragile and left stale rules accumulating across mode switches (previously caused duplicate/conflicting OUTPUT rules) - Flush ip6tables nat POSTROUTING table instead of per-rule -D deletion for the same reason Also documents the ICMPv6 Redirect issue and IPv6 masquerade rationale in roles/gateway/README.md for future reference. Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Diffstat (limited to 'roles')
-rw-r--r--roles/gateway/README.md59
-rw-r--r--roles/gateway/templates/gateway-apply-rules.sh.j25
2 files changed, 52 insertions, 12 deletions
diff --git a/roles/gateway/README.md b/roles/gateway/README.md
index 6c46cc9b..5340cc25 100644
--- a/roles/gateway/README.md
+++ b/roles/gateway/README.md
@@ -28,11 +28,15 @@ gateway_local_ipv6_subnet: "fd00:xxxx:xxxx:xxxx::/64" # ULA prefix (stable, rou
When set, this role:
- Enables `net.ipv6.conf.all.forwarding=1` and `accept_ra=2` on `gateway_router_interface`
- Deploys radvd advertising the ULA prefix with `AdvDefaultPreference high` — Pi wins as IPv6 default router
-- Adds ip6tables MASQUERADE/FORWARD rules matching by interface (not subnet) so all source addresses are handled
+- Adds ip6tables MASQUERADE/FORWARD rules so all client IPv6 traffic is forwarded through Pi
+- Drops outbound ICMPv6 Redirect messages — prevents Pi from redirecting clients to bypass the gateway
-**Prerequisite:** Disable the router's RA (FritzBox: Home Network > IPv6 > Router advertisement > ❌) after
-setting `network_ipv6_gateway` in `host_vars` so Pi keeps its static IPv6 default route to the router.
-See `roles/pihole/README.md` for the full two-step setup.
+**ICMPv6 Redirect suppression is critical.** Pi, the ISP router (FritzBox), and LAN clients share the same L2
+segment. When Pi forwards a packet to an external destination and the kernel determines the ISP router is a
+"better" next-hop on the same link, it sends an ICMPv6 Redirect to the client. The client then sends
+subsequent connections directly to the ISP router — bypassing Pi's masquerade and content filtering entirely.
+The first connection attempt fails (Pi forwarded nothing, just redirected), giving the symptom of "first
+request fails every ~30-60s." Dropping Redirect messages in ip6tables OUTPUT fixes this.
To disable IPv6, leave `gateway_local_ipv6_subnet: ""` (default). The role skips all ip6tables rules and disables radvd.
@@ -61,13 +65,40 @@ Client Devices (192.168.1.0/24)
- All traffic NAT'd through `wg+` interfaces
- Pi and client traffic both use VPN exit IP
-- Most VPN providers IPv4/IPv6 protocol routing automatically
+- Most VPN providers handle IPv4/IPv6 protocol routing automatically
**Direct Mode Characteristics:**
- IPv4 traffic NAT'd through router interface (`end0`)
-- IPv6 forwarded without NAT (globally routable addresses, native routing)
-- Standard internet routing via ISP
+- IPv6 traffic masqueraded through router interface — clients appear as Pi's GUA to the ISP router,
+ preventing ISP-router-level content filtering from applying per-device rules
+- ICMPv6 Redirects suppressed so clients always route through Pi
+
+## IPv6 Architecture Notes
+
+### Why masquerade IPv6 in direct mode?
+
+Clients have globally routable GUA addresses (assigned by ISP router SLAAC). Without masquerade, the ISP
+router sees each client's real GUA and can apply per-device content filtering. With masquerade, all client
+traffic appears to originate from Pi's GUA — bypassing per-device filtering consistently.
+
+### Why suppress ICMPv6 Redirects?
+
+When Pi, the ISP router, and clients share the same L2 segment (single broadcast domain), the Linux kernel
+sends ICMPv6 Redirect messages when it detects a "better" next-hop for a destination on the same link. For
+external GUA destinations reachable via the ISP router directly, Linux redirects clients from Pi to the ISP
+router. This causes:
+1. First SYN from client arrives at Pi — Pi sends Redirect, does NOT forward the packet
+2. Client receives Redirect, caches ISP router as next-hop for that destination
+3. Subsequent connections go directly to ISP router — masquerade and filtering bypassed
+
+Dropping `icmpv6-type redirect` in ip6tables OUTPUT prevents this entirely.
+
+### Why radvd with `AdvDefaultPreference high`?
+
+The ISP router (FritzBox) also sends RAs with default preference `medium` (or `low` if configured).
+Pi's radvd sends `AdvDefaultPreference high`, so clients prefer Pi as their IPv6 default router.
+Without this, clients split or prefer the ISP router depending on RA timing.
## Requirements
@@ -104,10 +135,18 @@ journalctl -u gateway-direct-mode.service -f
sudo iptables -L -n -v
sudo iptables -t nat -L -n -v
-# Check WireGuard interface detection
-ip link show | grep wg
+# Verify ip6tables rules (redirect DROP must be first in OUTPUT)
+sudo ip6tables -L OUTPUT -n -v --line-numbers
+sudo ip6tables -t nat -L -n -v
+
+# Confirm no ICMPv6 Redirects being sent
+sudo tcpdump -i end0 -n 'icmp6 and ip6[40] == 137'
+
+# Verify clients route through Pi (not ISP router)
+# On client: ip -6 route show default
+# On Pi: sudo tcpdump -i end0 -n -e ip6 and host <client-GUA>
# Test traffic routing
curl -4 ifconfig.co # Should show VPN IP when VPN active
-curl -6 ifconfig.co # Should show VPN IPv6 when VPN active
+curl -6 ifconfig.co # Should show Pi's GUA in direct mode, VPN IPv6 in VPN mode
```
diff --git a/roles/gateway/templates/gateway-apply-rules.sh.j2 b/roles/gateway/templates/gateway-apply-rules.sh.j2
index 0cbdd67a..59a960cf 100644
--- a/roles/gateway/templates/gateway-apply-rules.sh.j2
+++ b/roles/gateway/templates/gateway-apply-rules.sh.j2
@@ -27,9 +27,8 @@ clear_rules() {
iptables -D OUTPUT -o "$GATEWAY_INTERFACE" -j ACCEPT 2>/dev/null || true
if [ -n "$GATEWAY_SUBNET_V6" ]; then
ip6tables -F FORWARD 2>/dev/null || true
+ ip6tables -F OUTPUT 2>/dev/null || true
ip6tables -t nat -F POSTROUTING 2>/dev/null || true
- ip6tables -D OUTPUT -o wg+ -j ACCEPT 2>/dev/null || true
- ip6tables -D OUTPUT -o "$GATEWAY_INTERFACE" -j ACCEPT 2>/dev/null || true
fi
}
@@ -42,6 +41,7 @@ apply_vpn_mode() {
iptables -A FORWARD -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
if [ -n "$GATEWAY_SUBNET_V6" ]; then
ip6tables -t nat -A POSTROUTING -o wg+ -j MASQUERADE
+ ip6tables -I OUTPUT 1 -p ipv6-icmp --icmpv6-type redirect -j DROP
ip6tables -A OUTPUT -o wg+ -j ACCEPT
ip6tables -A FORWARD -i "$GATEWAY_INTERFACE" -o wg+ -j ACCEPT
ip6tables -A FORWARD -i wg+ -o "$GATEWAY_INTERFACE" -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
@@ -57,6 +57,7 @@ apply_direct_rules() {
iptables -A FORWARD -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
if [ -n "$GATEWAY_SUBNET_V6" ]; then
ip6tables -t nat -A POSTROUTING -o "$GATEWAY_INTERFACE" -j MASQUERADE
+ ip6tables -I OUTPUT 1 -p ipv6-icmp --icmpv6-type redirect -j DROP
ip6tables -A OUTPUT -o "$GATEWAY_INTERFACE" -j ACCEPT
ip6tables -A FORWARD -s "$GATEWAY_SUBNET_V6" -i "$GATEWAY_INTERFACE" -o "$GATEWAY_INTERFACE" -j ACCEPT
ip6tables -A FORWARD -i "$GATEWAY_INTERFACE" -o "$GATEWAY_INTERFACE" -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT