Migrating my IPv6 DynDNS from dynv6 to OVH
Foreword
A few years ago I wrote a rather long article on hosting services at home on a varying IPv6 prefix. The short version is: my ISP changes the IPv6 prefix every now and then, my home server's IPv6 address therefore changes with it, the DNS needs to follow and the router's IPv6 port forwarding rules need to be rewritten to point at the new address.
A common problem for IPv4 that has been tackled with DynDNS for years, a bit less common for IPv6 though, hence the article.
That article ended with a sh script running on a FreshTomato router, talking to dynv6.com for the DynDNS side of things. Reason for using dynv6: at the time, my registrar OVHdid not support updating AAAA records over their DynHost API, so I had to push the IPv6 part of the equation to a third-party service while keeping the rest of my domain at OVH. It worked almost very well — close to 4 years of uninterrupted service, free, and the API is cool — we can send only the prefix and a set of registered devices will see their IPv6 updated - ... but
- it is not failsafe:
dynv6is not OVH: it happens unfortunately quite often that one of the DNS servers ofdynv6is not in sync with the others, which results in complicated DNS issues that I struggle to solve, - it is still a third-party, something that adds up to the number of failure points, with unfortunately an almost inexistant support when you have a problem.
The update of this article is about moving the script to OVH.
OVH finally ships IPv6 DynHost
Sometime between 2024 and 2025, OVH quietly closed ovh/manager#3919: DynHost now accepts IPv6 addresses on the same /nic/update endpoint it has always exposed for IPv4. Quoting the helpful summary one user left in the ticket:
curl -u "$LOGIN:$PASSWORD" "https://www.ovh.com/nic/update?system=dyndns&hostname=$HOSTNAME&myip=$IP"
$IP can now be an IPv6 address, and OVH will update (or create) the matching AAAA record in the DNS zone of the host. Any A or AAAA record with a TTL of 60 seconds is treated as a DynHost record, so an IPv4 and an IPv6 entry can coexist on the same name as two separate DynHost records, updated independently. No additional API token, no JSON, no OAuth dance — just HTTP basic auth and a query string. Exactly what a tiny sh script running on a (Freshtomato) router wants.
Conceptual difference vs. dynv6
The dynv6 API I was using is unusual (and quite clever): you send only the prefix (2001:9e8:97c:bcff::/64), and the service recomputes the full address of every record in the zone, deriving the interface part of each from the MAC address you registered the record with. One call updates the whole zone. This requires some configuration on the device though, see the article.
OVH's DynHost works the plain DynDNS way: one AAAA record at a time, and you send the complete IPv6 address. So the router script now has to know, for each record it manages, what the lower 64 bits of the IPv6 address are — i.e. the interface identifier of the target machine — and concatenate that with the current prefix before calling OVH:
prefix from nvram 2001:9e8:97c:bcff:
+ interface identifier dea6:32ff:feb7:cac6
= full IPv6 address 2001:9e8:97c:bcff:dea6:32ff:feb7:cac6
This is a small step backwards in terms of abstraction — but it is also the only thing the router actually has to know that dynv6 was hiding from us. In practice the interface identifier is stable anyway (we made it MAC-derived via SLAAC in the original setup), so it is just one more line of configuration per host:
HOSTS="myserver.example.org|dea6:32ff:feb7:cac6
otherhost.example.org|1234:5678:9abc:def0"
The script then iterates over that list, builds each full address from the current prefix and sends one update per host. Very simple to write in python, frustrating in sh.
What changed in the script
The general skeleton (read the prefix from nvram, compare against a cached value in /tmp, only act on change, rewrite the ipv6_portforwardnvram variable and service firewall restart) is unchanged. Three things are different:
The DynDNS call is now done in a loop, once per host:
curl -fsS --user "${OVH_LOGIN}:${OVH_PASSWORD}" \ "${OVH_UPDATE_URL}?system=dyndns&hostname=${hostname}&myip=${full_ipv6}"The DynHost endpoint returns
good <ip>ornochg <ip>on success, and a short error string (badauth,nohost,abuse, ...) otherwise. The script checks both thecurlexit code and the response string, and only commits the cache once all hosts have been updated successfully. If anything fails, the script exits non-zero and the nextcrontick retries from scratch. Actually safer than the original "best effort" version.The firewall rewrite no longer needs to know the previous prefix. Instead of looking up the cached prefix and using it as the left-hand side of the
sedsubstitution, the new regex matches the first four hexadecimal groups (the/64) of any destination address in the rule and replaces them with the current prefix:rewritten=$(echo "$previous" | sed -E "s/<<([0-9a-fA-F]{1,4}:){4}/<<${PREFIX_NO_TRAIL}/g")This works regardless of what was there before, makes the script idempotent (re-running it on already-up-to-date rules is a no-op), and removes the special case for the very first run (when there is no cached prefix yet).
nvram set/commit/service firewall restartare only executed if the rewrite actually changed the rules:if [ "$rewritten" != "$previous" ]; then nvram set ipv6_portforward="${rewritten}" nvram commit service firewall restart fi
The full script is given below and follows the same deployment model as before: drop it on a USB key plugged into the router, mount it at boot, schedule it from the FreshTomatocron every 5 minutes. Setup on the OVH side is also very small:
- open the DNS zone of the domain, go to DynHost;
- create one DynHost record per name to be updated, pointing initially at
::or the current IPv6 address (OVH creates theAAAArecord with TTL 60s); - under Manage access, create a single DynHost identifier with write access to all those records — that's the
OVH_LOGIN/OVH_PASSWORDpair the script uses.
Was it worth it?
Yes, mostly because of the problems with dynv6 mentioned at the beginning of the article: OVH is just more reliable and I had none of the previous issues since this migration. I, however, wish to send warm regards to dynv6 for their freely available services, and it is still a very good service to use if you are not on OVH.
Apart from the reliability perspective, we have now a simpler and shorter script and firewall rewriting rules, and easier inspection of the DNS entries under the same registrar.
Cherry on top: this work also closes a small loop with the original article — the very first paragraph of which complained that "OVH does not offer the ability to update an IPv6 DNS entry from an API call". Four years later, it does.
Full script
For completeness, here is the full sh script as deployed on the router. Same shape as the dynv6 version: drop it on the USB key, schedule it from the FreshTomatocron every 5 minutes.
#!/bin/sh -e
#
# OVH DynHost IPv6 updater for FreshTomato routers.
#
# Adapted from https://yayimorphology.org/ipv6-at-home.html#script
# (originally written for dynv6.com).
#
# Prerequisites on the OVH side:
# 1. in the OVH manager, DNS zone of the domain -> "DynHost"
# 2. create one DynHost record per name, initially pointing at
# "::" (OVH then creates the AAAA record, TTL 60s)
# 3. create a DynHost identifier with write access to all of them
# ("Manage access"). That is the LOGIN/PASSWORD pair below.
#
# Prerequisites on the host side:
# - each host has a stable IPv6 suffix (lower 64 bits), typically
# SLAAC derived from the MAC, or a static address.
CACHE_REMOTE_PREFIX="/tmp/ovh_remote_prefix.txt"
CURRENT_PREFIX=$(nvram get ipv6_prefix)
CURRENT_PREFIX_LEN=$(nvram get ipv6_prefix_length)
# ---------------------------------------------------------------------
# OVH configuration
# ---------------------------------------------------------------------
# DynHost identifier created in the OVH manager (typically something
# like "mysite.fr-router" or a dedicated sub-user).
OVH_LOGIN="example.net-dynhost"
OVH_PASSWORD="REPLACE_WITH_YOUR_PASSWORD"
# DynHost endpoint. Both URLs below work; the *eu.ovhapis.com* one is
# the one quoted in the OVH ticket for IPv6.
OVH_UPDATE_URL="https://www.ovh.com/nic/update"
# OVH_UPDATE_URL="https://dns.eu.ovhapis.com/nic/update"
# List of AAAA records to update.
# Format, one entry per line:
# <hostname>|<interface_suffix>
#
# The interface suffix is the lower 64 bits of the host's IPv6
# address, i.e. what comes after the ISP-provided prefix.
#
# Example:
# - prefix delegated by ISP : 2001:9e8:97c:bcff::/64
# - host's full IPv6 : 2001:9e8:97c:bcff:dea6:32ff:feb7:cac6
# - interface_suffix : dea6:32ff:feb7:cac6
#
# For the router itself, the suffix is typically something like "1"
# (the :: + 1 address) or the lower part of its LAN address.
HOSTS="myserver.example.org|dea6:32ff:feb7:cac6
otherhost.example.org|1234:5678:9abc:def0"
# ---------------------------------------------------------------------
# Sanity checks
# ---------------------------------------------------------------------
if [ -z "$CURRENT_PREFIX" ]; then
logger -p ERROR "ovh-dyndns: no IPv6 prefix available."
exit 1
fi
if [ -z "$CURRENT_PREFIX_LEN" ]; then
logger -p ERROR "ovh-dyndns: no IPv6 prefix length available."
exit 1
fi
CURRENT="${CURRENT_PREFIX}/${CURRENT_PREFIX_LEN}"
# ---------------------------------------------------------------------
# Compare with the cached value
# ---------------------------------------------------------------------
CURRENT_REMOTE_PREFIX=""
if [ -f "$CACHE_REMOTE_PREFIX" ]; then
CURRENT_REMOTE_PREFIX=$(cat "$CACHE_REMOTE_PREFIX")
fi
if [ "$CURRENT_REMOTE_PREFIX" = "$CURRENT" ]; then
echo "ovh-dyndns: IPv6 configuration unchanged (${CURRENT}), nothing to do."
exit 0
fi
echo "ovh-dyndns: updating OVH records, new prefix ${CURRENT}"
logger -p WARN "ovh-dyndns: updating OVH, new prefix ${CURRENT}"
# ---------------------------------------------------------------------
# Build the full address and push it to OVH
# ---------------------------------------------------------------------
# Strip the trailing ':' from the prefix:
# 2001:9e8:97c:bcff:: -> 2001:9e8:97c:bcff:
PREFIX_NO_TRAIL="${CURRENT_PREFIX%?}"
FAIL_FLAG="/tmp/ovh_dyndns_fail.$$"
rm -f "$FAIL_FLAG"
# printf + while read is POSIX and works with busybox's sh on FreshTomato.
printf '%s\n' "$HOSTS" | while IFS='|' read -r hostname suffix; do
[ -z "$hostname" ] && continue
[ -z "$suffix" ] && continue
full_ipv6="${PREFIX_NO_TRAIL}${suffix}"
echo "ovh-dyndns: ${hostname} -> ${full_ipv6}"
logger -p INFO "ovh-dyndns: update ${hostname} -> ${full_ipv6}"
# DynHost returns "good <ip>" or "nochg <ip>" on success, and a
# short error string (badauth, nohost, abuse, ...) otherwise.
if ! response=$(curl -fsS --user "${OVH_LOGIN}:${OVH_PASSWORD}" \
"${OVH_UPDATE_URL}?system=dyndns&hostname=${hostname}&myip=${full_ipv6}"); then
logger -p ERROR "ovh-dyndns: curl failed for ${hostname}"
touch "$FAIL_FLAG"
continue
fi
case "$response" in
good*|nochg*)
echo "ovh-dyndns: OVH response: ${response}"
;;
*)
logger -p ERROR "ovh-dyndns: OVH returned an error for ${hostname}: ${response}"
touch "$FAIL_FLAG"
;;
esac
done
if [ -f "$FAIL_FLAG" ]; then
rm -f "$FAIL_FLAG"
logger -p ERROR "ovh-dyndns: some updates failed, cache and firewall left untouched"
exit 1
fi
# ---------------------------------------------------------------------
# Commit the cache (only if all OVH updates succeeded)
# ---------------------------------------------------------------------
echo "$CURRENT" > "$CACHE_REMOTE_PREFIX"
logger -p WARN "ovh-dyndns: OVH updates OK, cached prefix ${CURRENT}"
# ---------------------------------------------------------------------
# Update the IPv6 port-forwarding rules in NVRAM
# ---------------------------------------------------------------------
# Replace the /64 (first 4 hex groups) of the destination address of
# each rule with the current prefix, regardless of what was there
# before. No need for the cache here.
# Note: PREFIX_NO_TRAIL ends with ':', so by replacing the 4 groups
# (with their 4 trailing ':') we preserve the rule format.
previous=$(nvram get ipv6_portforward)
rewritten=$(echo "$previous" | sed -E "s/<<([0-9a-fA-F]{1,4}:){4}/<<${PREFIX_NO_TRAIL}/g")
if [ "$rewritten" != "$previous" ]; then
logger -p INFO "ovh-dyndns: rewriting IPv6 rules: ${rewritten} / before: ${previous}"
nvram set ipv6_portforward="${rewritten}"
nvram commit
logger -p INFO "ovh-dyndns: restarting the firewall"
service firewall restart
else
logger -p INFO "ovh-dyndns: firewall rules already up to date, no change"
fi
References
- The original IPv6-at-home article.
- OVH ticket
ovh/manager#3919— IPv6 for Dynamic DNS. - OVH documentation: DNS DynHost.