MikroTik RouterOS v7: DS-Lite AFTR auto-configuration

Automatically configure the DS-Lite (RFC 6333) IPv4-in-IPv6 tunnel endpoint on a MikroTik router by reading the AFTR name the ISP sends via DHCPv6 option 64 (OPTION_AFTR_NAME, RFC 6334), instead of hardcoding it.

Tested on RouterOS 7.23.1 (RB5009) against NetCologne / NetAachen (Germany), but it should work with any DS-Lite ISP that advertises the AFTR via DHCPv6 option 64.

DS-Lite gives you IPv6 natively plus an IPv4-in-IPv6 tunnel to a carrier-grade NAT gateway called the AFTR (Address Family Transition Router). The router learns the AFTR endpoint from DHCPv6 option 64, which carries a fully qualified domain name.

RouterOS does not configure DS-Lite automatically: its DHCPv6 client neither requests option 64 by default nor wires the result into a tunnel. The usual workaround is to capture the AFTR name once and hardcode it — which breaks silently whenever the ISP changes it.

This script closes that gap. It requests option 64, decodes the FQDN from the raw DHCPv6 reply, and updates the tunnel’s remote-address whenever the advertised name changes.

  1. A DHCPv6 client option (code 6 = the Option Request Option) tells the server we want option 64 (AFTR name) and option 23 (DNS servers).
  2. The DHCPv6 client’s script runs on each DHCPv6 event. When the reply contains option 64, the script decodes the DNS wire-format FQDN and, if it differs from the last applied value, sets it as the IPIPv6 tunnel’s remote-address.
  3. The IPIPv6 interface resolves the FQDN itself, so no DNS lookup is done in the script.

A working DS-Lite setup except for the AFTR address, i.e. you already have:

  • WAN up (e.g. PPPoE over the ISP’s internet VLAN)
  • A DHCPv6-PD client bound and delegating your IPv6 prefix
  • An ipipv6 tunnel interface created, with IPv4 routing/NAT over it
  • IPv4 currently working via a hardcoded AFTR (this script removes the need to hardcode)

Throughout, replace these names with yours:

PlaceholderMeaning
pppoe-netcologneyour WAN / DHCPv6-client interface
ipipv6-netcologneyour DS-Lite IPIPv6 tunnel interface
/ipv6 dhcp-client option
add code=6 name=oro-aftr value=0x00170040

The value is the Option Request Option payload: a list of DHCPv6 option codes as 16-bit big-endian values, concatenated.

  • 0017 = 23 = OPTION_DNS_SERVERS
  • 0040 = 64 = OPTION_AFTR_NAME
Defining code=6 replaces the default ORO
Defining a code=6 option replaces the client’s default ORO rather than appending to it. That’s why DNS (0017) is re-included — drop it and you may lose DNS. Prefix delegation is not affected: PD is driven by request=prefix / IA_PD, which is independent of the ORO.

Attach it to the DHCPv6 client:

/ipv6 dhcp-client set [find interface=pppoe-netcologne] dhcp-options=oro-aftr

Set the following as the DHCPv6 client’s script. In WebFig/WinBox, paste only the body (no surrounding braces). From the terminal, wrap it in script={ ... }.

:if ([:typeof $options] = "array") do={
    :local raw ($options->"64")
    :if ([:typeof $raw] != "nothing") do={
        :local ok "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-"
        :local fqdn ""
        :local lbl ""
        :local i 0
        :local n [:len $raw]
        :while ($i < $n) do={
            :local c [:pick $raw $i ($i + 1)]
            :if ([:find $ok $c] >= 0) do={
                :set lbl ($lbl . $c)
            } else={
                :if ([:len $lbl] > 0) do={
                    :if ([:len $fqdn] > 0) do={ :set fqdn ($fqdn . ".") }
                    :set fqdn ($fqdn . $lbl)
                    :set lbl ""
                }
            }
            :set i ($i + 1)
        }
        :if ([:len $lbl] > 0) do={
            :if ([:len $fqdn] > 0) do={ :set fqdn ($fqdn . ".") }
            :set fqdn ($fqdn . $lbl)
        }
        :if ([:len $fqdn] > 0) do={
            :global lastAftr
            :if ($fqdn != $lastAftr) do={
                /interface ipipv6 set [find name=ipipv6-netcologne] remote-address=$fqdn
                :set lastAftr $fqdn
                :log info ("DS-Lite: AFTR endpoint set to " . $fqdn)
            } else={
                :log info ("DS-Lite: AFTR endpoint confirmed (" . $fqdn . ")")
            }
        } else={
            :log warning "DS-Lite: AFTR option received but could not be parsed"
        }
    }
}

Force a fresh bind:

/ipv6 dhcp-client disable [find interface=pppoe-netcologne]
/ipv6 dhcp-client enable  [find interface=pppoe-netcologne]

Watch the log:

/log print where topics~"script"

You should see DS-Lite: AFTR endpoint set to <fqdn>. Confirm the tunnel followed:

/interface ipipv6 print detail where name=ipipv6-netcologne

remote-address is the FQDN; current-remote-address is the resolved IPv6 of the AFTR.

  • Keepalive must be absent on the tunnel. IPIPv6 tunnel keepalive only works MikroTik-to-MikroTik. Against an ISP AFTR it silently fails; the tunnel briefly shows running, then the IPv4 connected route goes inactive (DIc) and IPv4 dies while IPv6 keeps working. Setting keepalive to 0/empty is not enough — it must be removed:

    /interface ipipv6 set [find name=ipipv6-netcologne] !keepalive
  • Keep the ORO option (don’t rely on unrequested replies). RFC 6334 §4 says a server will not send the AFTR option unless the client lists it in the ORO, and §5 says a conforming client MUST request it. Some ISPs (NetCologne included) return it even without the request, but that is non-conforming server leniency. Requesting it costs nothing and keeps the script portable.

  • Raw option value is a byte string. On RouterOS, $options->"64" returns the option value where each position is one byte: length octets are non-printable, label octets are literal characters. This script avoids reading byte values numerically (which proved unreliable) and instead splits on character class — printable [A-Za-z0-9-] runs are labels, anything else is a separator. This correctly reconstructs the FQDN from the RFC 6334 / RFC 3315 §8 wire format.

  • $options is RouterOS v7 only. The DHCPv6 client exposes the $options array only on v7. On v6 it is empty and this approach cannot work.

  • The client fires the script on multiple events. Some carry no option array (e.g. a prefix-only event). The outer :if ([:typeof $options] = "array") guard makes those fall through silently — expected, not an error.

  • Change detection uses a global. lastAftr persists across script runs so the tunnel is only re-pointed when the AFTR actually changes (re-setting remote-address re-initializes the tunnel and briefly drops IPv4). The global resets on reboot, so the first DHCPv6 event after a reboot re-applies once — harmless. To survive reboots, store the last value in the interface comment instead and compare against that.