Inhaltsverzeichnis

Blueprint: IPv6 WireGuard Endpoint with Dynamic ISP Prefix (/56) + OVH DynDNS (AAAA)

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)

0. Placeholders used in this document

Replace these with real values:


1. Architecture overview

Your 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:

  1. detect the current GUA on the WireGuard server
  2. update an AAAA record via OVH API
  3. clients always connect via DNS name

No NAT66, no hacks, no manual updates.


2. DNS design (OVH)

Single AAAA record:

vpn.example.org.  AAAA  <current-GUA>

Recommended TTL: 300 seconds

This allows fast updates and stable WireGuard endpoints.


3. Required OVH API permissions

Create a dedicated OVH API token.

Minimal permissions for zone example.org:

Nothing else is required.


4. Packages on the WireGuard host

apt update
apt install -y wireguard ufw python3 python3-venv ca-certificates

5. UFW firewall rules

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

6. Script: fetch current IPv6 GUA

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

7. OVH DynDNS updater (Python)

7.1 Directory structure

/opt/ovh-dyndns-v6/
 ├── venv/
 └── update_aaaa.py
/etc/ovh-dyndns-v6.conf
/usr/local/sbin/ovh-dyndns-v6.sh

7.2 Python virtual environment

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")'

7.3 Configuration file

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

7.4 Python updater

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}")

7.5 Bash wrapper

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"

8. systemd service and timer

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

9. Validation

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.


11. Summary

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.

12. Extension

want to create a reverse v6/v4 fallback endpoint? The following article might be of interest for you.