Dynamic DNS with IPv6 Prefix Delegation on OPNsense — Why the UI Isn’t Enough

Views expressed are my own and not those of my employer.


The Problem Nobody Warns You About

You set up Dynamic DNS in the OPNsense UI, point it at dynv6, fill in your hostname — and it either sends the wrong address or silently fails. The built-in UI works fine for simple IPv4 DDNS or when your WAN interface carries a global IPv6 address directly. With PPPoE and prefix delegation, it doesn’t.

With prefix delegation (DHCPv6-PD), your ISP hands you a block — typically a /56 — that gets split across your internal interfaces. The PPPoE interface itself has no global IPv6. The OPNsense DynDNS2 handler tries to read a host address from an interface; it finds nothing usable and either sends garbage or gives up. Meanwhile the prefix you actually need is sitting in the routing table, completely ignored.

This post covers how to do it correctly: read the delegated prefix from the routing table, cache it to avoid hammering the API on every cron tick, handle multi-hostname setups (IPv4-only, IPv6-only, mixed), and wire everything into OPNsense’s configd so it shows up properly in the cron UI.


Architecture

ISP (PPPoE)
    │
    ▼
pppoe0 ── IPv4 public + link-local IPv6 only
    │
    │  DHCPv6-PD → /56 delegated prefix
    ▼
OPNsense (FreeBSD)
    ├── vlan_a  /64  ← tracking interface
    ├── vlan_b  /64  ← tracking interface
    ├── vlan_c  /64  ← tracking interface
    └── vlan_d  /64  ← tracking interface

Routing table: 2001:db8:x:xx00::/56 → lo0   ← this is what we need

dynv6.com API
    ← ipv6prefix=2001:db8:x:xx00::/56
    ← ipv4=x.x.x.x

The /56 entry pointing to lo0 is OPNsense’s way of saying “I own this prefix.” That’s the one to send to dynv6, not a /64 subnet address from a downstream interface.


Why the DynDNS2 Protocol Doesn’t Cut It

The DynDNS2 protocol sends a single IP address to /nic/update. For IPv6 that means a host address — something like 2001:db8::1. That’s a host record, not a prefix.

dynv6’s prefix delegation works through a separate parameter: ipv6prefix=2001:db8:x:xx00::/56. When you register a prefix, every host in your network that builds its address via SLAAC automatically gets a matching AAAA record on dynv6 — without any client-side agent. That’s the feature worth using. DynDNS2 never sends that parameter, so the UI path dead-ends here.


Reading the Prefix on FreeBSD

OPNsense runs FreeBSD, which means two things that trip up Linux-centric guides:

ifconfig format differs. Linux ifconfig (or ip addr) shows 2001:db8::1/64. FreeBSD shows inet6 2001:db8::1 prefixlen 64 — two separate tokens. Any grep for addr/prefix format returns nothing.

The prefix isn’t on an interface. It’s in the routing table, pointing to lo0. The right tool is netstat -rn -f inet6.

# Find the delegated prefix — filter for prefix length ≤ 60
# (excludes all the /64 subnet entries)
PREFIX=$(netstat -rn -f inet6 | awk '
  /^2[0-9a-f]{3}:/ && !/fe80/ {
    split($1, a, "/")
    if (a[2]+0 <= 60) { print $1; exit }
  }
')

If your ISP delegates a /56, filter on /56. The more generic version above handles cases where the prefix length might vary.

For IPv4, the PPPoE interface carries it directly:

IPV4=$(ifconfig pppoe0 | awk '/inet / && !/inet6/ {print $2; exit}')

The Update Script

Cache-Based Change Detection

Calling the dynv6 API every 5 minutes regardless of whether anything changed is unnecessary. A simple cache file is enough — write the last-known values, compare on each run, skip if nothing changed.

#!/bin/sh
# /usr/local/bin/dynv6-update.sh

TOKEN="your-dynv6-http-token"
CACHE="/var/db/dynv6.cache"
LOG="/var/log/dynv6.log"
WAN_IFACE="pppoe0"

# --- Read current state ---
PREFIX=$(netstat -rn -f inet6 | awk '
  /^2[0-9a-f]{3}:/ && !/fe80/ {
    split($1, a, "/")
    if (a[2]+0 <= 60) { print $1; exit }
  }
')
IPV4=$(ifconfig ${WAN_IFACE} | awk '/inet / && !/inet6/ {print $2; exit}')

if [ -z "$PREFIX" ] || [ -z "$IPV4" ]; then
  echo "$(date): ERROR — prefix or IPv4 empty, aborting" >> "$LOG"
  exit 1
fi

# --- Compare with cache ---
CACHE_PREFIX=""
CACHE_IPV4=""
if [ -f "$CACHE" ]; then
  CACHE_PREFIX=$(awk -F= '/PREFIX/ {print $2}' "$CACHE")
  CACHE_IPV4=$(awk -F= '/IPV4/ {print $2}' "$CACHE")
fi

if [ "$PREFIX" = "$CACHE_PREFIX" ] && [ "$IPV4" = "$CACHE_IPV4" ]; then
  echo "$(date): no change (${IPV4} / ${PREFIX})" >> "$LOG"
  exit 0
fi

echo "$(date): change detected" >> "$LOG"
echo "$(date):   old: ${CACHE_IPV4} / ${CACHE_PREFIX}" >> "$LOG"
echo "$(date):   new: ${IPV4} / ${PREFIX}" >> "$LOG"

# --- Update function ---
update_host() {
  HOSTNAME=$1
  PARAMS=$2
  RESULT=$(curl -s \
    "https://dynv6.com/api/update?hostname=${HOSTNAME}&token=${TOKEN}&${PARAMS}")
  echo "$(date): ${HOSTNAME}: ${RESULT}" >> "$LOG"
}

# IPv6-only hostnames
update_host "hostname-v6.dynv6.net"      "ipv6prefix=${PREFIX}"
update_host "hostname.example-v6.net"    "ipv6prefix=${PREFIX}"

# IPv4-only hostname
update_host "hostname.example-v4.net"    "ipv4=${IPV4}"

# Mixed (IPv4 + IPv6)
update_host "hostname.example.net"       "ipv4=${IPV4}&ipv6prefix=${PREFIX}"

# --- Write cache only after successful update ---
printf "PREFIX=%s\nIPV4=%s\n" "$PREFIX" "$IPV4" > "$CACHE"

Cache lives in /var/db/ — survives reboots, doesn’t clutter /tmp. The cache is only written after the API calls complete; if curl fails, the next run retries.

Permissions

chmod 700 /usr/local/bin/dynv6-update.sh
chown root:wheel /usr/local/bin/dynv6-update.sh

Log Rotation

OPNsense/FreeBSD uses newsyslog, not logrotate.

# /etc/newsyslog.conf.d/dynv6.conf
# logfile                owner:group  mode  count  size  when  flags
/var/log/dynv6.log       root:wheel   640   7      100   *     J
  • 7 rotated files, 100 KB trigger, bzip2 compressed (J)
  • /etc/newsyslog.conf.d/ survives OPNsense upgrades — it’s the correct location for local additions

Test it:

newsyslog -v -f /etc/newsyslog.conf.d/dynv6.conf
newsyslog -v -f /etc/newsyslog.conf.d/dynv6.conf -F   # force rotation

Wiring Into the Cron UI

OPNsense’s cron UI shows only registered configd actions, not arbitrary scripts. Register the script as a configd action and it appears in the dropdown:

cat > /usr/local/opnsense/service/conf/actions.d/actions_dynv6.conf << 'EOF'
[update]
command:/usr/local/bin/dynv6-update.sh
parameters:
type:script
message:dynv6 DNS update
description:dynv6 Update
EOF

service configd restart

Then in System → Settings → Cron → +:

  • Command: dynv6 Update
  • Minute: */5, everything else *

This approach survives upgrades; /etc/cron.d/ entries do not.


Security Notes

This setup runs entirely on OPNsense itself — no additional LXC involved. OPNsense is already the CrowdSec LAPI and bouncer host; the script doesn’t add attack surface. The dynv6 token sits in the script file, which is chmod 700 root:wheel and not exposed anywhere.

For monitoring: UptimeRobot checks the public dynv6 hostnames for DNS resolution and reachability. HetrixTools covers blacklist monitoring for the IP ranges. Internal metrics via Netdata run on downstream hosts, not OPNsense directly.

OPNsense manages its own updates through the built-in firmware update mechanism — no unattended-upgrades here, but the principle is the same: stay current.


What’s Next

With the prefix reliably registered at dynv6, the next step is making individual hosts reachable: SLAAC addresses auto-register under the dynv6 zone, but you need firewall rules that don’t assume IPv4-only. That’s a separate post.


I run Proxmox on my external hosting infrastructure — here’s why.