summaryrefslogtreecommitdiffstats
path: root/roles
diff options
context:
space:
mode:
authorAhmed Abdelhalim <[email protected]>2026-07-08 14:28:33 +0200
committerAhmed Abdelhalim <[email protected]>2026-07-08 16:55:30 +0200
commitb8f25e9d80c96f7170d51eb5abcd662a66d55317 (patch)
tree664374075de9f34ee23ab8f59d50371b4f3522e3 /roles
parent663bce95c096ed43dd24674debf4c8df2be1b604 (diff)
Route IPv6 through WireGuard VPN via radvd and static gateway
Add radvd to gateway role to advertise Pi as high-preference IPv6 default router using the stable ULA prefix (fd1e:.../64). With FritzBox also sending RAs, devices end up with ECMP between Pi and FritzBox. To solve this, add network_ipv6_gateway (Pi's link-local) as a static route with metric 100 to all managed hosts — beats RA metric 425, ensuring all IPv6 default traffic goes through Pi. Fix IPv6 MASQUERADE in gateway-apply-rules: - Direct mode: add MASQUERADE on end0 (LAN devices use ULA source addresses not known to FritzBox, so Pi must NAT them) - FORWARD rules: restrict to RELATED,ESTABLISHED only — previously the broad ACCEPT rule passed un-NAT'd packets alongside masqueraded copies, causing duplicate SYNs, conntrack corruption, and RSTs - MASQUERADE/clear rules: match by interface not by source subnet (devices may use any source address, not just the ULA prefix) - VPN mode return traffic: explicitly restrict to wg+→end0 direction Add network_ipv6_gateway var (optional) to network role NM templates (ethernet, wifi, bridge) — injects a static IPv6 default route at metric 100 when set. Add rpi5 static route to FritzBox link-local so Pi keeps IPv6 after FritzBox RA is disabled. Force SSH to IPv4 for *.local hosts (AddressFamily inet) — prevents Ansible from hanging on mDNS returning multiple IPv6 addresses. Update gateway and pihole READMEs with two-step IPv6 setup process. Co-Authored-By: Claude.ai
Diffstat (limited to 'roles')
-rw-r--r--roles/gateway/README.md26
-rw-r--r--roles/gateway/templates/gateway-apply-rules.sh.j211
-rw-r--r--roles/network/meta/argument_specs.yml4
-rw-r--r--roles/network/templates/bridge.nmconnection.j25
-rw-r--r--roles/network/templates/eth0-connection.nmconnection.j23
-rw-r--r--roles/network/templates/wifi-connection.nmconnection.j23
-rw-r--r--roles/pihole/README.md27
7 files changed, 55 insertions, 24 deletions
diff --git a/roles/gateway/README.md b/roles/gateway/README.md
index cfbe49d3..6c46cc9b 100644
--- a/roles/gateway/README.md
+++ b/roles/gateway/README.md
@@ -19,20 +19,22 @@ gateway_router_interface: "end0"
### IPv6 Support (Optional)
-IPv6 gateway is enabled by setting both `network_ipv6_address` and `gateway_local_ipv6_subnet`:
+IPv6 gateway is enabled by setting `gateway_local_ipv6_subnet` to the ULA prefix advertised by radvd:
```yaml
-network_ipv6_address: "2a02:xxxx:xxxx:xxxx::254/64" # Pi's IPv6 address
-gateway_local_ipv6_subnet: "2a02:xxxx:xxxx:xxxx::/64" # LAN IPv6 subnet
+gateway_local_ipv6_subnet: "fd00:xxxx:xxxx:xxxx::/64" # ULA prefix (stable, router-independent)
```
-To disable IPv6 entirely, remove both variables from `host_vars`. The role will:
-- Omit `accept_ra` sysctl (no IPv6 interface key written)
-- Skip IPv6 iptables masquerade rules (`gateway_local_ipv6_subnet` defaults to `""`)
-- Leave `net.ipv6.conf.all.forwarding=0`
+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
-**Note:** `accept_ra` is set per-interface (`gateway_router_interface`) only when IPv6 is enabled.
-This prevents rogue RA acceptance on WireGuard and LAN interfaces.
+**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.
+
+To disable IPv6, leave `gateway_local_ipv6_subnet: ""` (default). The role skips all ip6tables rules and disables radvd.
## Network Architecture
@@ -63,9 +65,9 @@ Client Devices (192.168.1.0/24)
**Direct Mode Characteristics:**
-- All traffic NAT'd through router interface (`end0`)
-- Direct IPv4/IPv6 routing to ISP
-- Standard internet routing
+- IPv4 traffic NAT'd through router interface (`end0`)
+- IPv6 forwarded without NAT (globally routable addresses, native routing)
+- Standard internet routing via ISP
## Requirements
diff --git a/roles/gateway/templates/gateway-apply-rules.sh.j2 b/roles/gateway/templates/gateway-apply-rules.sh.j2
index 0ce9f740..daddb180 100644
--- a/roles/gateway/templates/gateway-apply-rules.sh.j2
+++ b/roles/gateway/templates/gateway-apply-rules.sh.j2
@@ -27,8 +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 -t nat -D POSTROUTING -s "$GATEWAY_SUBNET_V6" -o wg+ -j MASQUERADE 2>/dev/null || true
- ip6tables -t nat -D POSTROUTING -s "$GATEWAY_SUBNET_V6" -o "$GATEWAY_INTERFACE" -j MASQUERADE 2>/dev/null || true
+ ip6tables -t nat -D POSTROUTING -o wg+ -j MASQUERADE 2>/dev/null || true
+ ip6tables -t nat -D POSTROUTING -o "$GATEWAY_INTERFACE" -j MASQUERADE 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
@@ -45,7 +45,7 @@ apply_vpn_mode() {
ip6tables -t nat -A POSTROUTING -o wg+ -j MASQUERADE
ip6tables -A OUTPUT -o wg+ -j ACCEPT
ip6tables -A FORWARD -i "$GATEWAY_INTERFACE" -o wg+ -j ACCEPT
- ip6tables -A FORWARD -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
+ ip6tables -A FORWARD -i wg+ -o "$GATEWAY_INTERFACE" -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
fi
log_message "VPN mode rules applied - traffic routed through VPN"
}
@@ -57,10 +57,9 @@ apply_direct_rules() {
iptables -A FORWARD -s "$GATEWAY_SUBNET_V4" -o "$GATEWAY_INTERFACE" -j ACCEPT
iptables -A FORWARD -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
if [ -n "$GATEWAY_SUBNET_V6" ]; then
- # No MASQUERADE for IPv6: LAN devices have public SLAAC addresses, native routing applies
+ ip6tables -t nat -A POSTROUTING -o "$GATEWAY_INTERFACE" -j MASQUERADE
ip6tables -A OUTPUT -o "$GATEWAY_INTERFACE" -j ACCEPT
- ip6tables -A FORWARD -i "$GATEWAY_INTERFACE" -o "$GATEWAY_INTERFACE" -j ACCEPT
- ip6tables -A FORWARD -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
+ ip6tables -A FORWARD -i "$GATEWAY_INTERFACE" -o "$GATEWAY_INTERFACE" -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
fi
log_message "Direct mode rules applied - normal routing active"
}
diff --git a/roles/network/meta/argument_specs.yml b/roles/network/meta/argument_specs.yml
index 5f74b45c..c501d8bb 100644
--- a/roles/network/meta/argument_specs.yml
+++ b/roles/network/meta/argument_specs.yml
@@ -21,6 +21,10 @@ argument_specs:
network_ipv4_dns:
type: "str"
description: "The network IP(4) gateway to configure when provided"
+ network_ipv6_gateway:
+ type: "str"
+ description: "Static IPv6 default route via router link-local (e.g. fe80::1/end0) — set when router RA is disabled"
+ required: false
network_bridge_interface:
type: "str"
description: "Physical NIC to enslave as vmbr0 bridge port"
diff --git a/roles/network/templates/bridge.nmconnection.j2 b/roles/network/templates/bridge.nmconnection.j2
index 0088a1a1..47f723d8 100644
--- a/roles/network/templates/bridge.nmconnection.j2
+++ b/roles/network/templates/bridge.nmconnection.j2
@@ -17,4 +17,7 @@ dns={{ network_ipv4_dns }}
[ipv6]
addr-gen-mode=default
-method=disabled
+method=auto
+{% if network_ipv6_gateway is defined %}
+routes=::/0,{{ network_ipv6_gateway }},100
+{% endif %}
diff --git a/roles/network/templates/eth0-connection.nmconnection.j2 b/roles/network/templates/eth0-connection.nmconnection.j2
index b1bc87a8..d6d2ea86 100644
--- a/roles/network/templates/eth0-connection.nmconnection.j2
+++ b/roles/network/templates/eth0-connection.nmconnection.j2
@@ -32,6 +32,9 @@ addr-gen-mode=default
method=disabled
{% else %}
method=auto
+{% if network_ipv6_gateway is defined %}
+routes=::/0,{{ network_ipv6_gateway }},100
+{% endif %}
{% endif %}
[proxy]
diff --git a/roles/network/templates/wifi-connection.nmconnection.j2 b/roles/network/templates/wifi-connection.nmconnection.j2
index 3d7667c8..5b4ab185 100644
--- a/roles/network/templates/wifi-connection.nmconnection.j2
+++ b/roles/network/templates/wifi-connection.nmconnection.j2
@@ -28,5 +28,8 @@ method=auto
[ipv6]
addr-gen-mode=default
method=auto
+{% if network_ipv6_gateway is defined %}
+routes=::/0,{{ network_ipv6_gateway }},100
+{% endif %}
[proxy]
diff --git a/roles/pihole/README.md b/roles/pihole/README.md
index c690dffb..e7e0bb20 100644
--- a/roles/pihole/README.md
+++ b/roles/pihole/README.md
@@ -47,11 +47,10 @@ This role configure the [pihole](https://github.com/pi-hole/pi-hole) DNS Sinkhol
#### With IPv6 Support
-> **Note:** IPv6 support is currently disabled due to ISP compatibility issues. The steps below are kept
-> for future reference. IPv6 on the network template defaults to `method=disabled`.
-> To enable: set `method=auto` in `roles/network/templates/eth0-connection.nmconnection.j2` and follow
-> the steps below to obtain a stable ULA address via SLAAC.
-> Use the `mngtmpaddr` address from `ip address | grep "inet6 fd"` as `{{ pihole_ipv6 }}` — not the `temporary` one.
+Setup is a two-step process: first enable FritzBox RA to get the Pi's stable ULA address,
+then disable FritzBox RA so Pi becomes the sole IPv6 default router.
+
+**Step 1 — Enable FritzBox RA temporarily to get Pi's ULA address:**
- Internet > Account Information >
- IPv6 > IPv6 Support > ✅
@@ -70,4 +69,22 @@ This role configure the [pihole](https://github.com/pi-hole/pi-hole) DNS Sinkhol
- DHCPv6 Server in the home network >
- Disable DHCPv6 server in the FRITZ!Box for the home network > ✅
- There are no other DHCPv6 servers in the home network. > ✅
+
- Internet > Account Information > Internet DNS Server > DNSv6 Server > Use Other DNSv6 Servers > {{ pihole_ipv6 }}
+
+On the Pi, get the ULA address and the FritzBox link-local default route:
+```bash
+ip address | grep "inet6 fd" # use the mngtmpaddr address as pihole_ipv6
+ip -6 route show default | grep ra # use the nexthop as network_ipv6_gateway in rpi5 host_vars
+```
+
+Set `network_ipv6_gateway` in `host_vars/rpi5.local.yml` and run the playbook to deploy the static IPv6 route on Pi.
+
+**Step 2 — Disable FritzBox RA so Pi becomes sole IPv6 default router:**
+
+- Home Network > Network > Network Settings > Change Advanced Network Settings > IPv6 >
+ - Router advertisement enable in the LAN > ❌
+
+NOTE: Pi's radvd (deployed by the `gateway` role) takes over sending RAs with `AdvDefaultPreference high`.
+All LAN devices use Pi as their IPv6 default router → traffic routes VPN or direct mode.
+Pi keeps IPv6 connectivity via the static route to FritzBox set in Step 1.