yayi C++, python, image processing, hacking, etc

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

  1. it is not failsafe: dynv6 is not OVH: it happens unfortunately quite often that one of the DNS servers of dynv6 is not in sync with the others, which results in complicated DNS issues that I struggle to solve,
  2. 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:

  1. 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> or nochg <ip> on success, and a short error string (badauth, nohost, abuse, ...) otherwise. The script checks both the curl exit 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 next cron tick retries from scratch. Actually safer than the original "best effort" version.

  2. 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 sed substitution, 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).

  3. nvram set / commit / service firewall restart are 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:

  1. open the DNS zone of the domain, go to DynHost;
  2. create one DynHost record per name to be updated, pointing initially at :: or the current IPv6 address (OVH creates the AAAA record with TTL 60s);
  3. under Manage access, create a single DynHost identifier with write access to all those records — that's the OVH_LOGIN / OVH_PASSWORD pair 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