In the previous post, we covered an opinionated and simplified overview of IPv6 and some practical considerations/annoyances for deploying IPv6 in a home network. Specifically, I wanted to address these annoyances when deploying IPv6 in my network:
- Lack of a stable prefix – my GUA prefix kept changing. Not frequently, but enough for it to be annoying.
- The prefix itself is annoying to remember. A reasonable argument can be, why remember it at all. But if you have some level of OCD and also prefer knowing where things are, then making it easier to remember is a reasonable approach.
- DNS updates based on SLAAC – typically you will need some form of DDNS updates to handle this if you operate out of your GUA. But as mentioned in the previous post, I prefer not to increase the footprint of services and try to use my router as authoritative DNS server for LAN addresses (pihole serves all other addresses).
- The Interface ID portion is also annoying to remember. Again, it is reasonable to ask, why remember any addresses at all. But again, some level of OCD and being able to intuitively know where things are based on long-term muscle memory are helpful to me.
Note that the rest of the details in this post are written for a Unifi gateway.
Network Setup
In my home network, I have two VLANs. These are also separate IPv4 subnets and are distinguished by their third octet – 2 and 11 are the two networks. A single Unifi Cloud Gateway acts as the router across these networks as well as to the Internet. This gateway is also the DHCP server for both networks. DNS for both networks is a pihole server that has interfaces to both networks.
IPv6 ULA – Memorable Address Strategy
To make the ULA address memorable, while also avoiding address conflicts, I picked the following strategy. Note that this is just one of many possible strategies.
- ULA Prefix – as mentioned in the previous post, I picked
fd00::/8as the prefix. - Since we have different VLANs and their third octet distinguishes them in IPv4 land, I added that third octet to the prefix to create the full network prefix part of the ULA. So in my case, I had two ULA prefixes:
fd00:2::/64andfd00:11::/64which corresponded to the two VLANs I have. - As of the latest Unifi network version, Unifi allows you to set additional ULA prefixes to each network. So set these prefixes in the Additional IP Address setting under IPv6 for each network.
- For the Interface ID portion, each service on the network that I cared about gets a static IPv4 assignment. I picked the last octet of the IPv4 assignment as the Interface ID portion for that service. So for instance, if my pihole has a static IPv4 assignment of
x.x.2.3, then it would have the memorable ULA offd00:2::3. Since my IPv4 assignments already ensure conflicts are handled, it ensures the ULA addresses are also unique.
Implementing the ULA Memorable Address Strategy
In order to implement the memorable address strategy, you need to set the static ULA in the host. Again, it is possible to attempt to use DHCPv6 but I did not bother as SLAAC is the friction-free approach for general address assignment for IPv6. This memorable address strategy is mainly targeting the services I would reach by name in my network.
As mentioned in the previous post, having multiple addresses per interface is the norm in IPv6. So for these services where we will assign static ULA addresses, we do not disable SLAAC – we let it continue doing its work. The static ULA assignment works to provide an easy to remember address as well as an easy way of keeping the DNS records in the router as seen below.
On the host side, I setup a simple shell script that runs as a service to set the ULA address on the interface appropriately. The script looks up interfaces that have an IPv4 address in the target VLANs and then gets the last octet of that address to add the corresponding IPv6 ULA on that interface.
#!/usr/bin/env bash
set -euo pipefail
# --- Tunables ---
WAIT_SLEEP=5
WAIT_TRIES=12 # total wait = WAIT_SLEEP * WAIT_TRIES
declare -A subnet_prefix=(
[2]="fd00:2"
[11]="fd00:11"
)
# Base denylist, always applied
BASE_DENY=(
lo
docker*
veth*
virbr*
vnet*
tailscale*
wg*
tun*
zt*
)
# Extra patterns from environment, e.g.:
# DENY_EXTRA="enp6s0*,br-test*"./injector.sh
# or DENY_EXTRA="enp6s0*, br-test*, foo*"
DENY_EXTRA_RAW="${DENY_EXTRA:-}"
EXTRA_DENY=()
if [[ -n "$DENY_EXTRA_RAW" ]]; then
# turn commas into spaces, then split
tmp="${DENY_EXTRA_RAW//,/ }"
read -r -a EXTRA_DENY <<< "$tmp"
fi
DENY_PATTERNS=("${BASE_DENY[@]}" "${EXTRA_DENY[@]}")
log() { logger -t ula-injector -p user.info "$*"; }
is_denied() {
local iface="$1"
local pat
for pat in "${DENY_PATTERNS[@]}"; do
case "$iface" in
$pat) return 0 ;;
esac
done
return 1
}
log "run start host=$(hostname) wait=${WAIT_TRIES}x${WAIT_SLEEP}s deny_extra=${EXTRA_DENY[*]:-none}"
for iface in $(ip -o link show up | awk -F': ' '{split($2,a,"@"); print a[1]}' | sort -u); do
if is_denied "$iface"; then
log "skip $iface: denylist"
continue
fi
ip4=""
for i in $(seq 1 "$WAIT_TRIES"); do
ip4=$(ip -4 -o addr show dev "$iface" scope global | awk '{print $4}' | cut -d/ -f1 | head -n1 || true)
if [[ -n "$ip4" ]]; then
break
fi
if (( i == 1 || i % 3 == 0 )); then
log "wait $iface: no IPv4 yet, try $i/$WAIT_TRIES"
fi
sleep "$WAIT_SLEEP"
done
if [[ -z "$ip4" ]]; then
log "skip $iface: timed out waiting for global IPv4 after $((WAIT_TRIES*WAIT_SLEEP))s"
continue
fi
third=$(echo "$ip4" | cut -d. -f3)
octet=$(echo "$ip4" | cut -d. -f4)
prefix=${subnet_prefix[$third]:-}
if [[ -z "$prefix" ]]; then
log "skip $iface $ip4: vlan $third not in subnet_prefix map"
continue
fi
desired="${prefix}::${octet}/64"
# Clean up old static ULAs in this prefix, keep SLAAC
while read -r addr; do
[[ -z "$addr" ]] && continue
[[ "$addr" == "$desired" ]] && continue
if [[ "$addr" == ${prefix}::* ]]; then
log "delete old static $addr on $iface"
ip -6 addr del "$addr" dev "$iface" || log "warn: failed to delete $addr on $iface"
fi
done < <(ip -6 -o addr show dev "$iface" scope global permanent | awk '{print $4}')
if ip -6 addr replace "$desired" dev "$iface"; then
log "ensured $desired on $iface from $ip4"
else
log "ERROR replace failed for $desired on $iface"
fi
done
log "run complete"
IPv6 DNS Address Support In Unifi Cloud Gateway
The last piece of the puzzle is enabling DNS lookup for these static ULA. As mentioned in a different post, my DNS setup for the LAN is that my UCG is the authoritative name server for anything in the .lan domain which is the domain suffix I use for all my lan services. Given the above strategy for memorable addresses, it is easy for the router to also populate DNS address mappings for the ULAs as it already knows the static IPv4 assignments. I run the following script periodically (via crontab – every 5 minutes) on the UCG to update the DNS records – this basically reverse engineers how dnsmasq on UCG gets and serves DNS records and adds the necessary information there. Because the file is auto-generated and gets over-written any time a change is made in the UI, it needs to be periodically re-generated. One additional quirk is having an exclusion list for devices like printers where I have static IPv4 assignment but static ULA assignment is not supported or possible.
#!/bin/bash
# Configuration Paths
CONFIG_DIR="/run/dnsmasq.dns.conf.d"
MAIN_CONF="$CONFIG_DIR/main.conf"
PERSISTENT_BLOCK="/data/custom/ula_block.conf"
# Strategy Rules
PATCH_LINE="local=/lan/"
DOMAIN_SUFFIX="lan"
MODIFIED=0
# ==============================================================================
# CONFIGURATION: Hostname Exclusion List
# Add any short hostnames here (separated by spaces) to keep them out of IPv6 ULA
# ==============================================================================
EXCLUDE_LIST="upstairsprinter downstairsprinter"
# ==============================================================================
# PART 1: Ensure local=/lan/ is present in all gateway .conf files
# ==============================================================================
for FILE in "$CONFIG_DIR"/*.conf; do
[ -e "$FILE" ] || continue
if ! grep -qxF "$PATCH_LINE" "$FILE"; then
echo "$PATCH_LINE" >> "$FILE"
MODIFIED=1
fi
done
# ==============================================================================
# PART 2: Static IPv6 ULA Mapping & Injection
# ==============================================================================
if [ -f "$MAIN_CONF" ]; then
TMP_BLOCK=$(mktemp)
TMP_FINAL=$(mktemp)
# 1. Strip out any old custom ULA blocks from main.conf first
sed '/# --- START CUSTOM ULA ---/,/# --- END CUSTOM ULA ---/d' "$MAIN_CONF" > "$TMP_FINAL"
# 2. Parse static mappings using robust string manipulation
while read -r raw_line; do
line=$(echo "$raw_line" | tr -d '\r' | xargs)
if [[ "$line" == host-record=* ]]; then
content="${line#host-record=}"
hostname="${content%%,*}"
ip="${content##*,}"
if [[ "$ip" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then
vlan="${BASH_REMATCH[3]}"
octet="${BASH_REMATCH[4]}"
prefix=""
if [ "$vlan" = "2" ]; then
prefix="fd00:2::"
elif [ "$vlan" = "11" ]; then
prefix="fd00:11::"
fi
if [ -n "$prefix" ]; then
ula_ip="${prefix}${octet}"
short_hostname="${hostname%.$DOMAIN_SUFFIX}"
# GUARD: Skip the host if it matches anything in the EXCLUDE_LIST
if [[ " $EXCLUDE_LIST " == *" $short_hostname "* ]]; then
continue
fi
echo "host-record=${short_hostname},${ula_ip}"
if [ -n "$DOMAIN_SUFFIX" ]; then
echo "host-record=${short_hostname}.${DOMAIN_SUFFIX},${ula_ip}"
fi
fi
fi
fi
done < "$TMP_FINAL" | sort -u > "$TMP_BLOCK"
# Save reference copy
mkdir -p /data/custom
cp "$TMP_BLOCK" "$PERSISTENT_BLOCK"
# 3. Append the freshly computed static ULA block (minus exclusions)
if [ -s "$TMP_BLOCK" ]; then
echo "# --- START CUSTOM ULA ---" >> "$TMP_FINAL"
cat "$TMP_BLOCK" >> "$TMP_FINAL"
echo "# --- END CUSTOM ULA ---" >> "$TMP_FINAL"
fi
# 4. Swap files if the finalized state changed
if ! cmp -s "$TMP_FINAL" "$MAIN_CONF"; then
cp "$TMP_FINAL" "$MAIN_CONF"
chmod 644 "$MAIN_CONF"
MODIFIED=1
fi
rm "$TMP_BLOCK" "$TMP_FINAL"
fi
# ==============================================================================
# PART 3: Unified Reload Guard
# ==============================================================================
if [ "$MODIFIED" -eq 1 ]; then
pkill -x dnsmasq
fi
With this script in place, now pihole.lan will resolve both IPv6 and IPv4 addresses, with IPv6 being preferred by modern clients (so SSH etc. automatically use IPv6).
Testing And Debugging
Once everything is setup, from a Linux host, you can run ping -6 pihole.lan and if everything worked, you should see it resolve to your preferred ULA address and ping it. By default, now things like ssh should also prefer using the ULA IPv6 address over the IPv4 address. ip -6 neigh show can be used to see what the state of neighbor discovery is from the client device if you have trouble connecting to a service.
Caveats And Criticisms
- The biggest one is that this approach relies on static IPv4 assignments and IPv4 subnets. I understand the irony of making IPv6 rely on IPv4. But this is also a practical and simple way of adding IPv6 to the network.
- The scripts are not very resilient to changes in network configuration – for example if a static assignment changes then DNS can be out of sync with the actual IPv6 address until the script on the host side is run manually. However, since such changes are quite infrequent in my setup, I am fine with this.