I recently ventured into IPv6 territory with one goal: Get rid of the router my ISP gave me.

I am a fan of UniFi gear and own an UniFi Dream Machine (upgraded from a UniFi Secure Gateway last year) which is more than capable of handling IPv6, so how hard can it be?

I went into this not knowing a thing about IPv6 and just assumed it was identical to IPv4, just bigger, but boy was I wrong. This is my writeup of what I learned and what my endresult is (spoiler: I got it working)

IPv6 misconceptions

As I explained above, I just assumed that IPv6 was IPv4, but longer. So you have some gateway/subnet-mask combo, your router gets an IP assigned from the ISP and all devices inside the network use that IP outwards facing when interacting with things (= “NAT”). Internally, the router assigns IPv4 addresses based on the gateway/subnet-mask, with each device receiving a unique IPv4 address from the router through DHCP. So far so good.

IPv6 on the other hand does not work at all like this. In IPv6-land, each device receives unique (actually multiple) IPv6 addresses. There is no NAT, so when my iPhone that’s connected to my WiFi communicates with a website, that website will see the unique IPv6 address my iPhone has assigned, not my router’s IP. There are also (unless specifically setup) no local network addresses - each IPv6 address is global. And how can the router assign IPv6 addresses? Enter prefix delegation or IPv6-PD.

The router, after authenticating with the ISP when PD is enabled, receives an entire address space (like /64 or /48) delegated to it. In simple words, the ISP tells the router: “Here’s a chunk of addresses that you can use for your devices, handle it.”

Not all IPv6 addresses are created equal

Let’s take a look at this ifconfig output of my Mac:

en0: flags=8863<UP,BROADCAST,SMART,RUNNING,SIMPLEX,MULTICAST> mtu 1500
        options=50b<RXCSUM,TXCSUM,VLAN_HWTAGGING,AV,CHANNEL_IO>
        ether 4c:20:b8:e7:3b:be
        inet6 fe80::1449:6fa5:65a9:a313%en0 prefixlen 64 secured scopeid 0x6
        inet6 fd74:d39f:a55f::8c prefixlen 64 dynamic
        inet 192.168.1.2 netmask 0xffffff00 broadcast 192.168.1.255
        inet6 240f:78:<snip> prefixlen 64 dynamic
        inet6 240f:78:<snip> prefixlen 64 autoconf secured
        inet6 240f:78:<snip> prefixlen 64 autoconf temporary

It doesn’t just have 1 IPv6 address, no that would be far too simple. Instead, it has 5!! Why? Because apparently, we have so many IPv6 addresses now that we don’t need to be sparse with them /s.

There are different kinds of IPv6 addresses assigned to my mac:

inet6 240f:78:<snip> prefixlen 64 dynamic
inet6 240f:78:<snip> prefixlen 64 autoconf secured
inet6 240f:78:<snip> prefixlen 64 autoconf temporary

The secured address is my permanent IPv6 address. This address rarely ever changes and will stick around even after reboots.

The temporary address is, you guessed it, a temporary address.

Lastly we have 2 more:

inet6 fe80::1449:6fa5:65a9:a313%en0 prefixlen 64 secured scopeid 0x6
inet6 fd74:d39f:a55f::8c prefixlen 64 dynamic

fe80::, while not obvious whatsoever, is a prefix for localhost or local link. This is an address that’s only usable locally.

fd74::, more specifically the fd:: prefix is designated for the local network. Didn’t I just earlier say that there are no local network addresses in IPv6? By default, yes, but I specifically setup my router to hand out local network addresses. Keyword here is “Unique local address” (ULA).

IPv6 implementations - Native, 6to4, IPv6oE

Are you still with me?? Okay, because there is more. Not all IPv6 implementations are equal either. Very common implementations are either IPv6 “native” or “IPv6-in-IPv4 tunnel”. There are more exotic implementations like SoftBanks “IPv6高速ハイブリッド IPv6 IPoE + IPv4” that’s utilizing IPoE for authentication and assigning addresses. (Note here: I used SoftBank in the past and was not able to get IPv6 without their router working due to the proprietary way their router authenticates)

I’m not going into detail here and don’t fully understand everything either, but a 6to4 tunnel is like the name implies an earlier implementation that tries to build the IPv6 stack on top of a IPv4 network stack. Read more about it here.

“Native” IPv6 is the one that’s nice to work with, but also the one not every ISP supports. I made sure to pick an ISP that has a native IPv6 stack and doesn’t use IPv6-over-IPv4

Getting started - figuring out what my ISP router is doing

The first step to getting IPv6 working was to actually figure out what my ISP router is doing when I plug it in. Specifically: How does it actually get the IPv6 prefix assigned, how does it authenticate, and what does the handhshake look like.

Now how do we actually do this? Wireshark and a couple of USB-C network dongles to the rescue!

dongles

Mac has a lot of nice networking features, one of them is the ability to bridge 2 network interfaces, effectively creating a tunnel between them. This can be done through the CLI, but also through the network preferences pane after plugging multiple network dongles into the Mac.

network-preference-pane

Specifically, we want to make it so the MacBook is in between the ISP Router and the network out and acting as a transparent bridge. So Internet <-> MacBook <-> ISP Router

Once the virtual bridge is created, we can use Wireshark to monitor the new device for any traffic that happens between the ISP router and the mothership. Now start Wireshark, plug the ISP router in, and….

au-hikari-startup

… it works!

Lot’s of new information here for a non-network engineer like me, but also some familiar words: DHCPv6, ICMPv6, UDP, SSL. The *v6 protocols are IPv6 variants of the same things we have in IPv4, but beefed up and more important in IPv6 land.

Specifically, ICMPv6 is much more important than the IPv4 version that we use for pinging stuff and is used extensively for the Neighbor Discovery Protocol, that’s those “Router Solicitation” and “Router Advertisement” messages you see in the screenshot above.

So from this startup dump we can see:

  • My ISP router inquires with ISP uplink through Router Solicitation where the uplink router is
  • Uplink responds with Router Advertisement
  • ISP router advertises itself through DHCPv6, uses a DUID (“DHCP unique identifier”) to identify itself, this DUID is generated off the mac address
  • ISP responds to DHCPv6 request and assigns IPv6 prefix
  • Neighbor Discovery Protocol continues with Neighbor Solicitation and Neighbor Advertisement

(the XID stuff you see in there is a transaction id that’s being calculated on each request)

So to summarize: This ISP is using fully native IPv6, the IPv6 prefix space is assigned through DHCPv6, and authentication happens through a DUID identifier that’s being used in all DHCPv6 requests.

If we are able to replicate this handshake on the UDM, we should have IPv6 working. Now how do we actually do that…

Teaching the UDM how to DHCPv6

From the wireshark dump above, we know that the ISP router is using DHCPv6 to authenticate and get its prefix assigned. We also know which DUID the ISP router uses for identifying itself, and we know the prefix size that we get assigned from the ISP (/48).

Let’s enable IPv6 through DHCPv6 through the UniFi admin on WAN:

ipv6-settings

Then grep for dhcp to see what’s happening on the UDM:

# ps aux | grep dhcp
 3537 root     /sbin/udhcpc --foreground --interface eth4 --script /usr/share/ubios-udapi-server/ubios-udhcpc-script -r 124.214.62.19
 3895 root     /usr/sbin/odhcp6c -R -f -e -v -s /usr/share/ubios-udapi-server/ubios-odhcp6c-script -P 48 eth4
 7715 root     grep dhcp

The UDM is spinning up udhcpc and odhcp6c with the closed-source binaries ubios-odhcp6c-script, both on the eth4 interface (my WAN port).

We can also print our own duid by running cat /config/dnsmasq.lease, and since the DUID being used is not the one that our ISP router uses, we have to do something about it.

odhcp6c that is being used here is is a DHCPv6 client by openwrt and it being open-source and widely used means we can dig deeper into what it’s doing with all those parameters:

Usage: odhcp6c [options] <interface>

Feature options:
        -S <time>       Wait at least <time> sec for a DHCP-server (0)
        -N <mode>       Mode for requesting addresses [try|force|none]
        -P <length>     Request IPv6-Prefix (0 = auto)
        -F              Force IPv6-Prefix
        -V <class>      Set vendor-class option (base-16 encoded)
        -u <user-class> Set user-class option string
        -c <clientid>   Override client-ID (base-16 encoded 16-bit type + value)
        -i <iface-id>   Use a custom interface identifier for RA handling
        -r <options>    Options to be requested (comma-separated)
        -R              Do not request any options except those specified with -r
        -s <script>     Status update script (/usr/sbin/odhcp6c-update)
        -a              Don't send Accept Reconfigure option
        -f              Don't send Client FQDN option
        -k              Don't send a RELEASE when stopping
        -t <seconds>    Maximum timeout for DHCPv6-SOLICIT (120)
        -m <seconds>    Minimum time between accepting updates (30)

The flag we’re after is -c <clientid> Override client-ID (base-16 encoded 16-bit type + value) . So theoretically, if we are able to pass -c into the startup of the odhcp6c command, we should be able to receive a valid IPv6 address from the ISP. Let’s give it a try!

Disable DHCPv6 again through the admin UI, wait until the process fully stopped, then manually run the command above, but with -c flag:

/usr/sbin/odhcp6c -R -f -c <DUID from our wireshark dump>  -e -v -s /usr/share/ubios-udapi-server/ubios-odhcp6c-script -P 48 eth4

Wait a bit, then ip -6

# ip -6 addr show scope global
10: br0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 state UP qlen 1000
    inet6 fd74:d39f:a55f::1/64 scope global
       valid_lft forever preferred_lft forever
    inet6 240f:78:<snip>/64 scope global dynamic
       valid_lft 108367sec preferred_lft 108367sec

Hello there, IPv6 :)

Back on the UDM admin, let’s enable IPv6 on the LAN network. We want to do prefix delegation and also advertise ourselves as a router (remember Neighbor Discovery Protocol above?)

ipv6-lan-settings

Wait a bit, then let’s enable IPv6 on my mac and renew the DHCPv6 lease:

en0: flags=8863<UP,BROADCAST,SMART,RUNNING,SIMPLEX,MULTICAST> mtu 1500
        inet6 240f:78:9464::<snip> prefixlen 64 dynamic
        inet6 240f:78:9464:0:<snip> prefixlen 64 autoconf secured
        inet6 240f:78:9464:0:<snip> prefixlen 64 autoconf temporary

Nice!!

And what about https://test-ipv6.com?

ipv6-test

Persisting changes to the UDM

Now that we got an IPv6 address, let’s persist those changes so we don’t have to manually run odhcpv6 every time.

Pre-req: Extending the UDM with boot-scripts

UniFi gear is hackable which allows us to do more than just what’s possible through the admin UI. The USG had a very advanced customization flow baked in, but the UDM is a tad more annoying to work with.

Luckily there is this awesome project called udm-utilities by boostchicken, a “A collection of things to enhance the capabilities of your Unifi Dream Machine or Dream Machine Pro.

Basically what this project does is, it installs a custom package that extends the UDM with the ability to run scripts on boot that persist in between reboots. We put our shell scripts into /mnt/data/on_boot.d, and udm-utilities makes sure those are run when the router boots.

There are a lot of really cool things you can do with it, from configuring the UDM to even running podman containers like homebridge or adguard, but we’ll be focusing on the network aspects only for this writeup.

Changing DUID on boot

Now the next problem is, how do we actually start odhcp6c with our own options? The UDM launches it on boot through ubios-udapi-server, which is closed-source. We could decompile the binary with ghidra and see if we can hack our own arguments in, how hard can it be?

But there is an easier (more hacky) way of achieving just that. What if the binary at /usr/sbin/odhcp6c isn’t actually a binary, but an executable script that runs odhcp6c for us? The UDM will continue to execute odhcp6c like usual and we can inject our custom flags. Here’s what I mean:

# cat /usr/sbin/odhcp6c
#!/bin/sh
# workaround script to inject odhcp6 options
/usr/sbin/odhcp6c-real -R -f -c 0003000<snip> "$@"

The custom script that we put in /usr/sbin is calling odhcp6c transparently and adds the extra -c flag.

Using udm-utilities, we can automate this with the following boot script:

#!/bin/sh
mv /usr/sbin/odhcp6c /usr/sbin/odhcp6c-real
cp /mnt/data/odhcp6c/odhcp6c /usr/sbin/odhcp6c
chmod +x /usr/sbin/odhcp6c
killall odhcp6c
  1. We move odhcp6c to odhcp6c-real
  2. Copy the custom odhcp6c to the old location
  3. Make it executable
  4. Kill odhcp6c

Once killed, ubios-udapi-server will automatically attempt to restart it by executing /usr/sbin/odhcp6c, which is no longer the real binary but our custom script.

And does it work?

# ps aux | grep dhcp
 3891 root     {odhcp6c} /bin/sh /usr/sbin/odhcp6c -e -v -s /usr/share/ubios-udapi-server/ubios-odhcp6c-script -P 64 eth4
 3895 root     /usr/sbin/odhcp6c-real -R -f -c 0003000<snip> -e -v -s /usr/share/ubios-udapi-server/ubios-odhcp6c-script -P 64 eth4

It absolutely does!

Was it worth it?

Worth it in terms of performance or network benefit? Probably not. The world doesn’t feel different when interacting with IPv6 and speed for me is pretty much on par as it was with IPv4.

But it has been a very nice learning experience. Without this venture into IPv6-land I would have never learned about the differences between IPv6 and IPv4.

My network is now fully IPv6 compatible which it wasn’t before, and that’s an achievement in itself.