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.