Some ISPs delegate a dynamic IPv6 prefix (commonly /56). After a reconnect the prefix can change. If you administer servers behind that ISP connection and you access them from home, Fail2Ban may ban your current client IPv6 address. After a prefix change you can lock yourself out again, because your „new“ IPv6 address is different but still inside the delegated prefix.
Goal:
High-level flow:
DNS AAAA (vpn.example.tld) -> derive /56 prefix -> write Fail2Ban include -> reload Fail2Ban
Create a DNS record (AAAA) such as:
vpn.example.tld AAAA 2001:db8:abcd:12ab:xxxx:xxxx:xxxx:xxxx
Important:
OR: Checkout > Blueprint: IPv6 WireGuard Endpoint with Dynamic ISP Prefix (/56) + OVH DynDNS (AAAA)
This file is written by the updater script:
/etc/fail2ban/ignore-ip.gua-dynamic.conf
Content example:
# Auto-generated – DO NOT EDIT # Source: AAAA vpn.example.tld # 2026-01-17T23:04:21+01:00 [DEFAULT] dyn_gua_prefix = 2001:db8:abcd:1200::/56
In /etc/fail2ban/jail.local:
[INCLUDES] before = paths-debian.conf after = /etc/fail2ban/ignore-ip.gua-dynamic.conf [DEFAULT] backend = systemd ignoreself = true # Static ignore ranges (examples) # RFC1918 placeholder: # 192.168.0.0/16 # ULA placeholder: # fdXX:YYYY:ZZZZ::/48 ignoreip = 127.0.0.1/8 ::1 192.168.0.0/16 fdXX:YYYY:ZZZZ::/48 %(dyn_gua_prefix)s
Reload Fail2Ban after editing:
fail2ban-client reload || systemctl restart fail2ban
Verify:
fail2ban-client get sshd ignoreip
This script generates the include file and reloads Fail2Ban only if it changed. As hook implemeted is an „unban routine“ for all hosts within the new prefix.
Store as:
/usr/local/sbin/update-f2b-ignore-gua.sh
#!/usr/bin/env bash set -euo pipefail HOST="vpn.example.tld" OUT="/etc/fail2ban/ignore-ip.gua-dynamic.conf" AAAA="$(dig +short AAAA "$HOST" | head -n1)" if [[ -z "${AAAA}" ]]; then echo "ERROR: no AAAA record for ${HOST}" >&2 exit 1 fi # Canonical /56 from whatever IPv6 string dig returns PREFIX="$(python3 -c 'import ipaddress,sys; print(ipaddress.ip_network(sys.argv[1]+"/56", strict=False))' "$AAAA")" # Read current prefix from OUT (if it exists) CURRENT_PREFIX="" if [[ -r "$OUT" ]]; then CURRENT_PREFIX="$(awk -F= ' /^[[:space:]]*dyn_gua_prefix[[:space:]]*=/ { v=$2; gsub(/^[[:space:]]+|[[:space:]]+$/,"",v); print v; exit }' "$OUT" || true)" fi # No change? Do nothing, silently. if [[ -n "${CURRENT_PREFIX}" && "${CURRENT_PREFIX}" == "${PREFIX}" ]]; then exit 0 fi unban_prefix_ips_global() { local prefix="$1" local ips_to_unban ips_to_unban="$(fail2ban-client banned 2>/dev/null | python3 - <<'PY' "$prefix" import sys, ast, ipaddress net = ipaddress.ip_network(sys.argv[1], strict=False) data = sys.stdin.read().strip() try: obj = ast.literal_eval(data) except Exception: print("") sys.exit(0) ips=set() if isinstance(obj, list): for item in obj: if isinstance(item, dict): for _jail, lst in item.items(): if isinstance(lst, list): for s in lst: try: ip = ipaddress.ip_address(s) except Exception: continue if ip.version == 6 and ip in net: ips.add(str(ip)) print(" ".join(sorted(ips))) PY )" [[ -z "${ips_to_unban}" ]] && return 0 local count=0 ip for ip in ${ips_to_unban}; do fail2ban-client unban "$ip" >/dev/null || true count=$((count+1)) done logger -t fail2ban-dynprefix "unban-hook: unbanned ${count} IPs from ${prefix}" } # Write updated config (timestamp is fine now, we no longer compare full file) tmp="$(mktemp)" cat >"$tmp" <<EOF # Auto-generated – DO NOT EDIT # Source: AAAA ${HOST} # $(date -Is) [DEFAULT] dyn_gua_prefix = ${PREFIX} EOF install -m 0644 "$tmp" "$OUT" rm -f "$tmp" # Healthcheck: is fail2ban responsive? if ! fail2ban-client ping >/dev/null 2>&1; then # Service might be down/hung - try restart once systemctl restart fail2ban fi # Reload (or restart fallback) systemctl reload fail2ban || systemctl restart fail2ban # Unban any already-banned IPs from your PD unban_prefix_ips_global "$PREFIX" logger -t fail2ban-dynprefix "updated ignoreip with ${PREFIX} (from ${AAAA})"
Permissions:
chmod 0755 /usr/local/sbin/update-f2b-ignore-gua.sh
Manual test:
/usr/local/sbin/update-f2b-ignore-gua.sh journalctl -t fail2ban-dynprefix -n 20 --no-pager
Create:
/etc/systemd/system/f2b-dynprefix.service
[Unit] Description=Fail2Ban dynamic IPv6 /56 ignoreip updater (via DNS) After=network-online.target Wants=network-online.target [Service] Type=oneshot ExecStart=/usr/local/sbin/update-f2b-ignore-gua.sh
Create:
/etc/systemd/system/f2b-dynprefix.timer
[Unit] Description=Run Fail2Ban dynprefix updater periodically [Timer] OnBootSec=2min OnUnitActiveSec=5min AccuracySec=30s Persistent=true [Install] WantedBy=timers.target
Enable:
systemctl daemon-reload systemctl enable --now f2b-dynprefix.timer systemctl list-timers --all | grep f2b-dynprefix
Logs:
journalctl -u f2b-dynprefix.service -n 50 --no-pager
Don't forget to adjust all placeholders to your environment!
Note: This prefix-match is intentionally simple. If you want it perfect, implement real IPv6 CIDR matching.
Enjoy!