This document explains how to operate a WireGuard server behind a firewall (e.g. pfSense) when the ISP assigns a dynamic IPv6 prefix (for example /56).
The solution publishes the current global IPv6 address (GUA) of the WireGuard server via the OVH DNS API as an AAAA record. Clients connect via FQDN, so prefix changes are handled automatically.
Audience: beginners + admins
Goal: stable endpoint vpn.example.org
WireGuard port: 51820/udp (standard)
Replace these with real values:
192.168.0.0/16192.168.1.0/24fdXX:YYYY:ZZZZ::/48fdXX:YYYY:ZZZZ:40::/64vpn.example.org192.168.1.10fdXX:YYYY:ZZZZ:5::10wg0ens18Your firewall (OPNSense/PfSense or whatever) performs PPPoE and receives:
The WireGuard server is placed in a DMZ or internal VLAN and has:
Because the prefix changes, we:
No NAT66, no hacks, no manual updates.
Single AAAA record:
vpn.example.org. AAAA <current-GUA>
Recommended TTL: 300 seconds
This allows fast updates and stable WireGuard endpoints.
Create a dedicated OVH API token.
Minimal permissions for zone example.org:
Nothing else is required.
apt update apt install -y wireguard ufw python3 python3-venv ca-certificates
Default policy:
ufw default deny incoming ufw default allow outgoing
Allow WireGuard (public):
ufw allow 51820/udp comment 'WireGuard Server'
Allow SSH only from internal networks:
ufw allow from 192.168.0.0/16 to any port 22 proto tcp comment 'SSH IPv4 internal' ufw allow from fdXX:YYYY:ZZZZ::/48 to any port 22 proto tcp comment 'SSH IPv6 internal' ufw allow route on wg0 from 192.168.0.0/16 to 192.168.0.0/16 comment 'Wireguard v4 forward' ufw allow route on wg0 from fdXX:YYYY:ZZZZ::/48 to fdXX:YYYY:ZZZZ::/48 comment 'Wireguard v6 forward'
Note: Adjust other rules to your environment
Enable firewall:
ufw enable
ufw status numbered
File: /usr/local/sbin/fetch-gua.sh
#!/usr/bin/env bash set -euo pipefail IFACE="${IFACE:-ens18}" ip -6 addr show dev "$IFACE" \ | awk '/inet6/ && /scope global/ && !/temporary/ {print $2}' \ | cut -d/ -f1 \ | grep -Ev '^(fc|fd)' \ | head -n1
Permissions:
chmod 0755 /usr/local/sbin/fetch-gua.sh
Test:
IFACE=ens18 /usr/local/sbin/fetch-gua.sh
/opt/ovh-dyndns-v6/ ├── venv/ └── update_aaaa.py /etc/ovh-dyndns-v6.conf /usr/local/sbin/ovh-dyndns-v6.sh
python3 -m venv /opt/ovh-dyndns-v6/venv /opt/ovh-dyndns-v6/venv/bin/pip install --upgrade pip /opt/ovh-dyndns-v6/venv/bin/pip install ovh
Test:
/opt/ovh-dyndns-v6/venv/bin/python -c 'import ovh; print("ovh ok")'
File: /etc/ovh-dyndns-v6.conf
OVH_ENDPOINT="ovh-eu" OVH_APP_KEY="REPLACE_ME" OVH_APP_SECRET="REPLACE_ME" OVH_CONSUMER_KEY="REPLACE_ME" OVH_ZONE="example.org" RECORD_FQDN="vpn.example.org" TTL="300" IFACE="ens18"
Permissions:
chmod 600 /etc/ovh-dyndns-v6.conf
File: /opt/ovh-dyndns-v6/update_aaaa.py
#!/usr/bin/env python3 import os, sys, ovh def env(k): v = os.getenv(k) if not v: sys.exit(f"missing env var {k}") return v client = ovh.Client( endpoint=env("OVH_ENDPOINT"), application_key=env("OVH_APP_KEY"), application_secret=env("OVH_APP_SECRET"), consumer_key=env("OVH_CONSUMER_KEY"), ) zone = env("OVH_ZONE") fqdn = env("RECORD_FQDN") gua = env("GUA") ttl = int(env("TTL")) sub = "" if fqdn == zone else fqdn.replace("." + zone, "") ids = client.get(f"/domain/zone/{zone}/record", fieldType="AAAA", subDomain=sub) if ids: rec = client.get(f"/domain/zone/{zone}/record/{ids[0]}") if rec["target"] == gua: print("no change") sys.exit(0) client.put(f"/domain/zone/{zone}/record/{ids[0]}", target=gua, ttl=ttl) client.post(f"/domain/zone/{zone}/refresh") print(f"updated: {gua}") else: client.post(f"/domain/zone/{zone}/record", fieldType="AAAA", subDomain=sub, target=gua, ttl=ttl) client.post(f"/domain/zone/{zone}/refresh") print(f"created: {gua}")
File: /usr/local/sbin/ovh-dyndns-v6.sh
#!/usr/bin/env bash set -euo pipefail source /etc/ovh-dyndns-v6.conf GUA="$(IFACE="$IFACE" /usr/local/sbin/fetch-gua.sh)" export OVH_ENDPOINT OVH_APP_KEY OVH_APP_SECRET OVH_CONSUMER_KEY export OVH_ZONE RECORD_FQDN TTL GUA OUT="$(/opt/ovh-dyndns-v6/venv/bin/python /opt/ovh-dyndns-v6/update_aaaa.py)" logger -t ovh-dyndns-v6 "$OUT fqdn=$RECORD_FQDN gua=$GUA ts=$(date -Is)" echo "$OUT"
Service: /etc/systemd/system/ovh-dyndns-v6.service
[Unit] Description=OVH DynDNS IPv6 updater After=network-online.target [Service] Type=oneshot ExecStart=/usr/local/sbin/ovh-dyndns-v6.sh
Timer: /etc/systemd/system/ovh-dyndns-v6.timer
[Unit] Description=Run OVH DynDNS every 5 minutes [Timer] OnBootSec=2min OnUnitActiveSec=5min Persistent=true [Install] WantedBy=timers.target
Enable:
systemctl daemon-reload systemctl enable --now ovh-dyndns-v6.timer
Logs:
journalctl -u ovh-dyndns-v6.service journalctl -t ovh-dyndns-v6
DNS:
dig +short AAAA vpn.example.org
UDP scan (expected open|filtered):
nmap -6 -sU -p51820 vpn.example.org
WireGuard proof:
wg show
Look for recent handshake.
This setup provides a stable IPv6 WireGuard endpoint with a dynamic ISP prefix. The DNS AAAA record is automatically updated via OVH API and systemd timer.
Result: IPv6 works as intended.
want to create a reverse v6/v4 fallback endpoint?
The following article might be of interest for you.