Configuring CenturyLink IPv6 (6rd) on a UniFi UXG-Pro

If you’re using CenturyLink Fiber with a UniFi device, you may have noticed that getting IPv6 working isn’t quite as straightforward as enabling it in the UniFi Network application.

While UniFi supports IPv6, CenturyLink’s legacy network still relies on a technology called 6rd (IPv6 Rapid Deployment) rather than native IPv6 in many areas. Unfortunately, the UXG-Pro doesn’t provide a native 6rd configuration option through its management interface.

After spending some time digging through various GitHub repositories, UniFi community posts, and the underlying Linux kernel configuration, I was able to get IPv6 working using a manually configured tunnel.

This blog post is more of a reflection of how I was able to get IPv6 working with CenturyLink, rather than a supported guide that will give you IPv6 and guarantee it will work indefinitely. I’ve tried to explain why and what is going on where applicable to capture a working solution at least short-term. Yay for access to IPv6 only resources now!

My network setup

For reference, here is the environment I’m working with at the time I’m writing this post:

ComponentConfiguration
Internet providerCenturyLink Fiber
ISP networkCENTURYLINK-US-LEGACY-QWEST
OrganizationCenturyLink Communications, LLC
Network topologyCenturyLink Fiber → ONT → UXG-Pro
GatewayUbiquiti UniFi UXG-Pro
UXG-Pro firmware5.1.26
UniFi Network applicationOfficial – 10.6.106
WAN authenticationPPPoE
WAN VLAN201
Public IPv4Static assignment via PPPoE
IPv6 technology6rd
CenturyLink 6rd prefix2602::/24
CenturyLink 6rd relay205.171.2.64

One important distinction is that I’m using a static public IPv4 address. Since CenturyLink’s 6rd implementation derives your IPv6 prefix directly from your public IPv4 address, having a static assignment makes the configuration considerably easier.

That said, most residential customers probably don’t have a static public IPv4 address. I have a script later in this guide that calculates the correct IPv6 prefix from your current WAN IPv4 address, so the tunnel configuration can work with either static or dynamic assignments, but there is one additional consideration with dynamic addresses: when your public IPv4 changes, your LAN’s IPv6 prefix also needs to change. I’ll explain that limitation later.

Before digging in, what is IPv6 6rd and how does it work?

6rd stands for IPv6 Rapid Deployment and is defined in RFC 5969.

The technology allows an ISP to deliver IPv6 connectivity across an existing IPv4 network without having to deploy native IPv6 throughout its entire infrastructure.

Instead of your router sending IPv6 packets directly over an IPv6-enabled WAN connection, the router encapsulates IPv6 packets inside IPv4 packets and sends them to a designated ISP relay.

The relay then forwards the traffic to the native IPv6 Internet.

Here’s roughly what that looks like:

LAN devices
    |
    | Native IPv6
    |
UniFi UXG-Pro
    |
    | IPv6 encapsulated in IPv4
    | (SIT tunnel / IPv4 protocol 41)
    |
CenturyLink IPv4 network
    |
    |
CenturyLink 6rd Border Relay
205.171.2.64
    |
    | Native IPv6
    |
IPv6 Internet

In my environment, CenturyLink uses the following settings:

ParameterValue
6rd IPv6 prefix2602::/24
IPv4 mask length0
Border relay IPv4205.171.2.64
Tunnel MTU1472

Note: these settings are specific to the CenturyLink legacy deployment I’m using and shouldn’t automatically be assumed for every CenturyLink or Quantum Fiber connection.

How CenturyLink calculates your IPv6 prefix

The interesting part is how CenturyLink assigns IPv6 addresses.

With a /24 6rd prefix and an IPv4 mask length of zero, the router’s entire 32-bit public IPv4 address is embedded directly into the IPv6 prefix.

That gives us:

24-bit IPv6 prefix
+ 32-bit public IPv4 address
----------------------------
56-bit IPv6 prefix

In other words, each public IPv4 address maps to its own /56 IPv6 prefix. A /56 provides 256 individual /64 subnets, which is more than enough for a typical home network.

For example, you could use one /64 for your default LAN, another for IoT devices, and additional subnets for guest or isolated networks.

It’s worth noting that 6rd isn’t NAT64 or an IPv4-to-IPv6 address translator. Your devices receive globally routable IPv6 addresses. The UXG-Pro simply transports their IPv6 packets across CenturyLink’s IPv4 infrastructure.

This also means you can host services over IPv6, provided you configure the appropriate firewall rules and the CenturyLink relay allows inbound connectivity.

Why the existing UniFi 6rd scripts didn’t work

While researching how to configure this, I came across the following GitHub repository: https://github.com/msimerson/unifi

The repository includes a script for configuring 6rd on UniFi gateways. Its README describes testing on a UXG-Max and indicates that the approach should work on the UXG-Pro and other UniFi gateways.

Unfortunately, that wasn’t the case with my UXG-Pro running firmware 5.1.26.

The repository relies on native Linux 6rd tunnel functionality, including a command similar to:

ip tunnel 6rd dev 6rd 6rd-prefix 2602::/24

When I ran the command, the router returned:

6rd: ioctl 89f9 failed: Invalid argument

To understand why, I checked the kernel configuration directly:

zcat /proc/config.gz 2>/dev/null | grep IPV6_SIT

The output was:

CONFIG_IPV6_SIT=y
# CONFIG_IPV6_SIT_6RD is not set

This tells us two important things:

  • CONFIG_IPV6_SIT=y means the Linux kernel supports IPv6-in-IPv4 tunneling using SIT interfaces.
  • CONFIG_IPV6_SIT_6RD is not set means the kernel was built without the specialized 6rd functionality.

This explains why the repository’s native 6rd command fails on my gateway, despite the README suggesting compatibility with the UXG-Pro.

The kernel supports the basic encapsulation needed to transport IPv6 inside IPv4, but it doesn’t support configuring the additional 6rd parameters with ip tunnel 6rd.

The workaround is to use a standard SIT tunnel with an explicitly configured CenturyLink relay.

Because CenturyLink provides a known border relay, we can manually create the tunnel, assign our derived IPv6 address, and configure routing without relying on the kernel’s native 6rd functionality.

Step 1: Connect to the UXG-Pro and identify your WAN interface

First, SSH into your UXG-Pro and access its debug shell using the Debug option from the UniFi Network console. Once you have shell access open, confirm that your PPPoE interface has an IPv4 address:

ip -4 addr show dev ppp0

In my environment, ppp0 is the PPPoE interface that receives my static public IPv4 address.

You should see something similar to:

inet YOUR_PUBLIC_IPV4 peer ... scope global ppp0

If you’re not using PPPoE, your IPv4 address may be assigned to a different interface.

You can list all IPv4 addresses using:

ip -4 addr show

Once you’ve identified the appropriate interface, you can extract its IPv4 address directly:

ip -4 -o addr show dev ppp0 |
    awk 'NR==1 {split($4,a,"/"); print a[1]}'

We’ll use this address when creating the tunnel.

Next, verify that the kernel supports SIT tunneling:

zcat /proc/config.gz 2>/dev/null | grep IPV6_SIT

As explained above, my UXG-Pro supports SIT but doesn’t have native 6rd functionality enabled.

Step 2: Calculate your CenturyLink IPv6 prefix

Before creating the tunnel, we need to determine which IPv6 addresses CenturyLink will route to us.

Rather than manually calculating these values every time, we can derive them from the current public IPv4 address.

For illustration, consider the reserved documentation IPv4 address:

192.0.2.1

Converting its individual octets into hexadecimal gives:

IPv4 octetHexadecimal
192C0
000
202
101

When combined with CenturyLink’s 2602::/24 prefix, this produces:

2602:00c0:0002:0100::/56

Which can be shortened to:

2602:c0:2:100::/56

From that /56, we can assign addresses to our tunnel and LAN networks.

PurposeExample
Customer 6rd prefix2602:c0:2:100::/56
Tunnel IPv6 address2602:c0:2:100::1/128
Default LAN subnet2602:c0:2:101::/64
Default LAN gateway2602:c0:2:101::1

The important thing to remember is that your IPv6 prefix isn’t arbitrary. It needs to be derived from your public IPv4 address.

Automatically calculate your IPv6 addresses

Instead of converting each octet manually, we can use Linux’s awk command.

First, obtain the current WAN IPv4 address:

WAN4=$(ip -4 -o addr show dev ppp0 |
    awk 'NR==1 {split($4,a,"/"); print a[1]}')

Next, calculate the /56 prefix:

PREFIX=$(echo "$WAN4" | awk -F. '{
    printf "2602:%x:%x:%x::/56\n",
        $1, ($2 * 256) + $3, $4 * 256
}')

echo "$PREFIX"

This produces the same /56 prefix that CenturyLink derives from your public IPv4 address.

However, because the 6rd prefix is /24, the precise bit placement matters. The first IPv4 octet occupies the low byte of the second IPv6 hextet, while the next two octets occupy the third hextet.

The final IPv4 octet occupies the high byte of the fourth hextet.

For our example, the result is:

2602:c0:2:100::/56

We can then select the first /64 for the tunnel and use additional /64 networks for our LANs and VLANs.

For example:

2602:c0:2:100::/64    Tunnel
2602:c0:2:101::/64    Default LAN
2602:c0:2:102::/64    IoT
2602:c0:2:103::/64    Guest
2602:c0:2:104::/64    Servers

We’ll automate these calculations in the restore script later in the article.

Step 3: Configure the IPv6 firewall

I strongly recommend configuring the tunnel manually before attempting to automate anything to prove the 6rd tunnel will work with your CenturyLink service. First I would start with the firewall configuration.

Unlike typical residential IPv4 networks, IPv6 doesn’t require NAT for devices to reach the Internet. Each client can receive its own globally routable IPv6 address. This means we need to ensure inbound firewall rules deny inbound traffic from the internet.

I added two rules to restrict unsolicited inbound IPv6 traffic arriving through the tunnel.

Block unsolicited inbound connections to LAN clients

ip6tables -I FORWARD 1 -i cl6rd \
    -m conntrack ! --ctstate ESTABLISHED,RELATED \
    -j DROP

Here’s what the command does:

ParameterDescription
-I FORWARD 1Inserts the rule at the beginning of the forwarding chain
-i cl6rdMatches packets arriving through the tunnel
-m conntrackEnables connection-state matching
! --ctstate ESTABLISHED,RELATEDMatches packets that aren’t part of an established or related connection
-j DROPDrops matching traffic

Essentially, devices on the LAN can initiate IPv6 connections, but unsolicited inbound connections arriving through the tunnel are dropped.

Protect the UXG-Pro itself

Next, add a similar rule for traffic destined directly to the gateway:

ip6tables -I INPUT 1 -i cl6rd \
    -m conntrack ! --ctstate ESTABLISHED,RELATED \
    -j DROP

This protects services running directly on the UXG-Pro.

You can inspect the rules using:

ip6tables -L INPUT -n -v
ip6tables -L FORWARD -n -v

These are deliberately restrictive inbound rules, not a complete IPv6 firewall policy. If you want to host publicly accessible services over IPv6, you’ll need to add specific inbound exceptions.

Note: you should also make sure your overall IPv6 firewall policy doesn’t block required ICMPv6 functionality, particularly Packet Too Big messages used for Path MTU Discovery.

Step 4: Manually configure the 6rd tunnel

These steps will let us verify that the Linux kernel UniFi is using supports the workaround, CenturyLink’s relay is reachable, and IPv6 connectivity actually works. Once everything is functional, we can move on to the script.

Create the SIT tunnel

First, capture the current public IPv4 address:

WAN4=$(ip -4 -o addr show dev ppp0 |
    awk 'NR==1 {split($4,a,"/"); print a[1]}')

Next, create the tunnel:

ip tunnel add cl6rd mode sit \
    local "$WAN4" \
    remote 205.171.2.64 \
    ttl 64

Here’s what the command does:

ParameterDescription
ip tunnel add cl6rdCreates a tunnel interface named cl6rd
mode sitEnables IPv6 encapsulation inside IPv4
local "$WAN4"Uses the current public IPv4 address
remote 205.171.2.64Sets CenturyLink’s border relay
ttl 64Sets the encapsulating IPv4 packet’s time-to-live

Unlike native 6rd configuration, we’re explicitly specifying where the IPv6-in-IPv4 packets need to go.

Enable the tunnel interface

Next, bring the tunnel online and set its MTU:

ip link set dev cl6rd mtu 1472 up

The MTU is important because the tunnel introduces additional overhead. A typical Ethernet connection supports an MTU of 1500 bytes. PPPoE adds 8 bytes of overhead, leaving 1492 bytes for the IPv4 packet. SIT encapsulation adds another 20-byte IPv4 header.

That gives us:

1500 - 8 (PPPoE) - 20 (IPv4 tunnel) = 1472

This is the appropriate tunnel MTU for my PPPoE configuration. If you’re using a connection without PPPoE, the appropriate MTU may differ.

Assign an IPv6 address to the tunnel

Next, calculate the tunnel IPv6 address from the public IPv4 address:

TUNNEL_IP=$(echo "$WAN4" | awk -F. '{
    printf "2602:%x:%x:%x::1/128\n",
        $1, ($2 * 256) + $3, $4 * 256
}')

Then assign it:

ip -6 addr add "$TUNNEL_IP" dev cl6rd

The /128 assigns a single IPv6 address to the tunnel interface.

We’re not advertising a subnet over this interface. The tunnel serves as the gateway’s connection to CenturyLink’s IPv6 infrastructure.

Configure IPv6 routing

Next, tell Linux to send globally routable IPv6 traffic through the tunnel:

ip -6 route add 2000::/3 dev cl6rd

The 2000::/3 prefix covers the currently allocated global unicast IPv6 address space.

This provides a route for public IPv6 destinations without overriding local and link-local IPv6 routes.

You can confirm the route was added by running:

ip -6 route show

You should see something similar to:

2000::/3 dev cl6rd metric 1024 pref medium

At this point, the tunnel is configured and we should have connectivity!

Step 5: Verify IPv6 connectivity from the UXG-Pro

Before configuring the LAN, let’s confirm that the gateway can reach IPv6 destinations through CenturyLink.

Start by checking the tunnel interface:

ip -d link show cl6rd
ip -6 addr show dev cl6rd

Next, ping Cloudflare’s IPv6 DNS server:

ping -6 -c 3 2606:4700:4700::1111

If everything is working, you should receive replies.

You can also use curl to make an outbound IPv6 connection:

curl -6 --max-time 10 https://api64.ipify.org

This should return the public IPv6 address used for the request.

The -6 argument forces curl to use IPv6 rather than silently falling back to IPv4.

If both commands work, we’ve confirmed that the gateway can successfully transport IPv6 traffic over CenturyLink’s IPv4 infrastructure.

If these tests fail, I would stop here and troubleshoot the tunnel before making changes to the LAN.

Step 6: Configure IPv6 on the UniFi LAN

Now that the tunnel works, the next step is to configure UniFi to advertise IPv6 addresses to devices on our LAN.

Open the UniFi Network application and navigate to:

Settings → Networks → Default

Under IPv6 configuration, enter the following settings:

SettingValue
Interface TypeStatic
IPv6 AddressYour calculated LAN gateway address
Netmask64
Gateway IPAutomatically calculated by UniFi
Link Local IPLeave unchanged
AdvancedManual
Client Address AssignmentSLAAC
Auto DNS ServerUnchecked
Router Advertisement (RA)Enabled
RA PriorityMedium

For DNS, I am using Cloudflare’s public IPv6 DNS servers. Enter both a primary and secondary server address:

DNSIPv4IPv6
Primary1.1.1.12606:4700:4700::1111
Secondary1.0.0.12606:4700:4700::1001

Using the example /56 calculated earlier, our default LAN gateway would be:

2602:c0:2:101::1

With a netmask of 64, that gives us:

2602:c0:2:101::/64

The important thing is that the LAN uses a /64 within the /56 derived from our public IPv4 address.

What is SLAAC?

SLAAC stands for Stateless Address Autoconfiguration.

When enabled, devices connected to the network can automatically configure their own IPv6 addresses based on the prefix advertised by the router.

This means we don’t necessarily need a DHCPv6 server to hand out individual IPv6 addresses.

What are Router Advertisements?

Router Advertisements, or RAs, tell clients which IPv6 prefix they should use and which router serves as their default gateway.

With Router Advertisements enabled, devices on the default LAN should begin configuring IPv6 addresses within the subnet configured in UniFi.

Depending on the operating system, you may need to disconnect and reconnect a device or wait for the next Router Advertisement.

Step 7: Configure multiple LANs and VLANs

One advantage of CenturyLink’s /56 allocation is that we aren’t limited to a single IPv6 subnet.

A /56 provides 256 individual /64 networks, allowing us to use different IPv6 subnets for our default LAN, IoT devices, guest network, servers, or any other VLANs we’ve configured.

For example, using the fictional IPv4 address from earlier:

NetworkSubnet IDIPv6 subnet
Default LAN012602:c0:2:101::/64
IoT022602:c0:2:102::/64
Guest032602:c0:2:103::/64
Servers042602:c0:2:104::/64

Each network would use an IPv6 gateway address ending in ::1.

For example, the IoT network would use:

2602:c0:2:102::1/64

And the Servers network would use:

2602:c0:2:104::1/64

You can configure each of these networks in UniFi using the same Static IPv6, SLAAC, and Router Advertisement settings described above.

UniFi continues to manage the individual LAN interfaces and VLANs.

The 6rd tunnel itself doesn’t need a separate interface or route for each VLAN. The gateway simply forwards their outbound IPv6 traffic through the same tunnel.

Later in the article, we’ll configure the restore script with a list of LANs and VLANs so it can calculate and validate the expected prefix for each network.

One important distinction: the script won’t create VLANs or change the IPv6 settings managed by UniFi. It will calculate the correct addresses and warn if the actual interface configuration doesn’t match.

Step 8: Test IPv6 from a LAN device

Now that the tunnel is configured and UniFi is advertising the IPv6 subnet, connect a device to your default LAN.

The device should receive a globally routable IPv6 address within the /64 assigned to that network.

You can check this on Windows using:

ipconfig /all

Or on Linux using:

ip -6 addr

Next, try pinging Cloudflare over IPv6:

ping -6 2606:4700:4700::1111

For a more comprehensive test, navigate to:

https://test-ipv6.com

This is one of my preferred ways of validating an IPv6 configuration because it checks whether your client can successfully communicate with IPv6 destinations and identifies common connectivity issues.

With the configuration described above, I’m receiving a perfect 10/10 score on test-ipv6.com.

This confirms that IPv6 connectivity is working properly from my LAN devices through the CenturyLink 6rd tunnel.

If your UXG-Pro can reach IPv6 destinations but your LAN devices cannot, double-check the LAN prefix, Router Advertisements, IPv6 forwarding, and firewall configuration.

A 10/10 score confirms client IPv6 connectivity, but it doesn’t independently validate unsolicited inbound traffic if you’re planning to host services.

Step 9: Create a script to automatically configure the tunnel

Now that we’ve confirmed the configuration works manually, we can combine the commands into a reusable script.

Rather than hardcoding the public IPv4 address or IPv6 tunnel address, the script will calculate everything from the IPv4 address assigned to the WAN interface.

This makes the tunnel configuration usable with both static and dynamic IPv4 assignments.

There are several things I wanted the script to handle:

  1. Wait for the WAN interface to receive an IPv4 address.
  2. Calculate the CenturyLink /56 IPv6 prefix automatically.
  3. Derive the appropriate tunnel IPv6 address.
  4. Install the IPv6 firewall rules without creating duplicates.
  5. Create the tunnel if it doesn’t exist.
  6. Recreate the tunnel if its IPv4 endpoints have changed.
  7. Restore the IPv6 route.
  8. Test IPv6 connectivity through the tunnel.
  9. Calculate the expected IPv6 addresses for multiple LANs and VLANs.
  10. Validate that the configured LAN interfaces use the correct IPv6 addresses.
  11. Display clear status messages and warnings.

For additional protection, the script also supports an optional expected IPv4 address.

If you have a static public IPv4 address, you can populate that setting to prevent unexpected tunnel changes.

If you have a dynamic assignment, leave it blank.

Identify your LAN and VLAN interface names

Before creating the script, we’ll need to identify the Linux interfaces associated with our UniFi networks.

You can list the available interfaces using:

ip -br link show

You can also view interfaces with IPv6 addresses:

ip -6 -br addr show

Depending on your UniFi configuration, the LAN interfaces may be named something like br0, br10, or another bridge or VLAN interface.

Don’t assume the interface name corresponds to the VLAN ID. Verify the actual interface name on your gateway.

For example, you might discover the following mapping:

UniFi networkLinux interfaceSubnet ID
Default LANbr01
IoTbr102
Guestbr203
Serversbr304

The interface names above are examples. You’ll need to use the interface names from your own gateway.

The subnet ID is the final eight bits of your assigned /56 and can range from 0 to 255.

I’ll reserve subnet ID 0 for the tunnel and use IDs 1 through 255 for LANs and VLANs.

Create the restore script

SSH into the UXG-Pro and run the following commands.

Before running the script, review the WAN_IF, EXPECTED_WAN4, and LAN_NETWORKS settings.

mkdir -p /mnt/data/centurylink6rd

cat > /mnt/data/centurylink6rd/restore.sh <<'EOF'
#!/bin/sh
set -eu

#######################################
# CenturyLink 6rd Configuration
#######################################

WAN_IF="ppp0"
RELAY="205.171.2.64"
TUNNEL="cl6rd"
MTU="1472"

# Optional static IPv4 protection.
# Leave empty for dynamic IPv4 assignments.
EXPECTED_WAN4=""

# LAN/VLAN configuration:
# Format: Interface Subnet_ID Network_Name
#
# Replace these examples with your actual
# Linux interface names and network IDs.
#
# Use subnet IDs 1-255.
# Subnet ID 0 is reserved for the tunnel.

LAN_NETWORKS="
br0 1 Default
br10 2 IoT
br20 3 Guest
br30 4 Servers
"

#######################################
# Wait for WAN IPv4
#######################################

n=0

while :; do
    WAN4=$(ip -4 -o addr show dev "$WAN_IF" 2>/dev/null |
        awk 'NR==1 {split($4,a,"/"); print a[1]}')

    [ -n "$WAN4" ] && break

    n=$((n + 1))

    [ "$n" -ge 60 ] && {
        echo "[ERROR] WAN IPv4 timeout" >&2
        exit 1
    }

    sleep 2
done

# Validate IPv4 octets.
echo "$WAN4" | awk -F. '
    NF != 4 { exit 1 }
    {
        for (i = 1; i <= 4; i++) {
            if ($i !~ /^[0-9]+$/ ||
                $i < 0 || $i > 255) exit 1
        }
    }
' || {
    echo "[ERROR] Invalid WAN IPv4" >&2
    exit 1
}

echo "[OK] WAN IPv4 detected"

# Optional static IPv4 check.
if [ -n "$EXPECTED_WAN4" ] &&
   [ "$WAN4" != "$EXPECTED_WAN4" ]; then

    echo "[ERROR] Unexpected WAN IPv4" >&2
    exit 1
fi

#######################################
# Calculate CenturyLink IPv6 Prefix
#######################################

# CenturyLink uses:
# 6rd prefix: 2602::/24
# IPv4 mask length: 0
#
# The 32-bit IPv4 address is embedded
# after the first 24 bits.

BASE=$(echo "$WAN4" | awk -F. '{
    printf "2602:%x:%x:%x",
        $1, ($2 * 256) + $3, $4 * 256
}')

PREFIX="${BASE}::/56"
TUNNEL_IP="${BASE}::1/128"

echo "[OK] 6rd prefix calculated"

#######################################
# Configure IPv6 Firewall
#######################################

ip6tables -C FORWARD -i "$TUNNEL" \
    -m conntrack ! --ctstate ESTABLISHED,RELATED \
    -j DROP 2>/dev/null ||
ip6tables -I FORWARD 1 -i "$TUNNEL" \
    -m conntrack ! --ctstate ESTABLISHED,RELATED \
    -j DROP

ip6tables -C INPUT -i "$TUNNEL" \
    -m conntrack ! --ctstate ESTABLISHED,RELATED \
    -j DROP 2>/dev/null ||
ip6tables -I INPUT 1 -i "$TUNNEL" \
    -m conntrack ! --ctstate ESTABLISHED,RELATED \
    -j DROP

#######################################
# Create or Restore SIT Tunnel
#######################################

# Recreate if the IPv4 endpoints changed.
if ip link show dev "$TUNNEL" \
    >/dev/null 2>&1; then

    if ! ip tunnel show "$TUNNEL" |
        grep -Fq "remote $RELAY local $WAN4"; then

        echo "[WARN] Tunnel endpoints changed"
        echo "[INFO] Recreating tunnel"

        ip -6 route del 2000::/3 \
            dev "$TUNNEL" 2>/dev/null || true

        ip tunnel del "$TUNNEL"
    fi
fi

# Create the tunnel if missing.
if ! ip link show dev "$TUNNEL" \
    >/dev/null 2>&1; then

    ip tunnel add "$TUNNEL" mode sit \
        local "$WAN4" \
        remote "$RELAY" \
        ttl 64
fi

ip link set dev "$TUNNEL" mtu "$MTU" up

# Ensure the expected tunnel address exists.
ip -6 -o addr show dev "$TUNNEL" |
    awk '{print $4}' |
    grep -Fqx "$TUNNEL_IP" ||
    ip -6 addr add "$TUNNEL_IP" dev "$TUNNEL"

echo "[OK] Tunnel configured"

#######################################
# Restore IPv6 Routing
#######################################

ip -6 route replace 2000::/3 dev "$TUNNEL"

echo "[OK] IPv6 route installed"

#######################################
# Verify IPv6 Connectivity
#######################################

if ping -6 -I "$TUNNEL" -c 3 -W 3 \
    2606:4700:4700::1111 >/dev/null 2>&1; then

    echo "[OK] IPv6 connectivity verified"
else
    echo "[WARN] IPv6 connectivity test failed"
fi

#######################################
# Validate LAN/VLAN IPv6 Addresses
#######################################

echo ""
echo "LAN/VLAN validation:"

# Extract the first three IPv4 octets'
# embedded hextets once for subnet math.

IPV6_PART=$(echo "$WAN4" | awk -F. '{
    printf "2602:%x:%x",
        $1, ($2 * 256) + $3
}')

IPV6_FOURTH_BASE=$(echo "$WAN4" |
    awk -F. '{print $4 * 256}')

printf '%s\n' "$LAN_NETWORKS" |
while read -r IFACE SUBNET NAME; do

    [ -n "${IFACE:-}" ] || continue

    # Validate the subnet ID.
    case "$SUBNET" in
        *[!0-9]*|"")
            echo "[WARN] $NAME - Invalid subnet ID"
            continue
            ;;
    esac

    if [ "$SUBNET" -lt 1 ] ||
       [ "$SUBNET" -gt 255 ]; then

        echo "[WARN] $NAME - Subnet ID must be 1-255"
        continue
    fi

    # Calculate the expected LAN /64.
    FOURTH=$((IPV6_FOURTH_BASE + SUBNET))
    FOURTH_HEX=$(printf '%x' "$FOURTH")

    LAN_BASE="${IPV6_PART}:${FOURTH_HEX}"
    LAN_PREFIX="${LAN_BASE}::/64"
    LAN_GATEWAY="${LAN_BASE}::1"

    # Check whether the interface exists.
    if ! ip link show dev "$IFACE" \
        >/dev/null 2>&1; then

        echo "[WARN] $NAME ($IFACE) - Interface not found"
        echo "       Expected: $LAN_GATEWAY/64"
        continue
    fi

    # Compare the configured gateway address.
    if ip -6 -o addr show dev "$IFACE" |
        awk '{print $4}' |
        grep -Fqx "${LAN_GATEWAY}/64"; then

        echo "[OK] $NAME ($IFACE) - prefix matches"
    else
        echo "[WARN] $NAME ($IFACE) - IPv6 prefix does not match"
        echo "       Expected: $LAN_PREFIX"
        echo "       Gateway:  $LAN_GATEWAY/64"
    fi
done

#######################################
# Summary
#######################################

echo ""
echo "CenturyLink IPv6 tunnel configured."
echo ""
echo "WAN IPv4: $WAN4"
echo "6rd Prefix: $PREFIX"
echo "Tunnel IPv6: $TUNNEL_IP"
echo ""
echo "Note: LAN prefix warnings do not"
echo "automatically modify UniFi settings."
EOF

chmod 700 /mnt/data/centurylink6rd/restore.sh

How the script works

There are a few important improvements over the individual commands used earlier.

Automatically detecting the WAN IPv4 address

Rather than hardcoding an IPv4 address, the script retrieves the current address directly from the WAN interface:

WAN4=$(ip -4 -o addr show dev "$WAN_IF" 2>/dev/null |
    awk 'NR==1 {split($4,a,"/"); print a[1]}')

This works with either static or dynamically assigned WAN IPv4 addresses, provided WAN_IF points to the correct interface.

The script also waits up to approximately two minutes for the interface to receive an address.

Automatically deriving the IPv6 prefix

The script converts the public IPv4 address into the appropriate hexadecimal values and combines them with CenturyLink’s 2602::/24 prefix.

This produces the correct /56 without requiring a manually entered IPv6 address.

It then calculates the tunnel address and the expected LAN/VLAN prefixes.

Handling static IPv4 assignments

If you have a static IPv4 address, you can optionally populate:

EXPECTED_WAN4="YOUR_STATIC_PUBLIC_IPV4"

This provides an additional safety check.

If the public IPv4 address changes unexpectedly, the script exits rather than creating the tunnel using an address you weren’t expecting.

Since I have a static IPv4 assignment, this is the setting I would use in my environment.

Handling dynamic IPv4 assignments

If your public IPv4 address is dynamically assigned, leave this variable empty:

EXPECTED_WAN4=""

Every time the script runs, it retrieves the current WAN IPv4 address and recalculates the appropriate IPv6 prefix.

If the tunnel already exists but its IPv4 endpoints no longer match, the script deletes and recreates it.

Deleting the old tunnel also removes the IPv6 address associated with that tunnel interface.

Keep in mind that this happens when the script executes. It doesn’t continuously monitor the WAN connection on its own.

Avoiding duplicate firewall rules

The script uses ip6tables -C to check whether the firewall rules already exist before adding them.

This allows the script to be executed multiple times without inserting duplicate copies of the same rules.

Restoring the route

Instead of adding a new route every time, the script uses:

ip -6 route replace 2000::/3 dev "$TUNNEL"

This makes the routing configuration repeatable.

Testing IPv6 connectivity

The script also runs a connectivity test:

ping -6 -I "$TUNNEL" -c 3 -W 3 \
    2606:4700:4700::1111

This attempts to ping Cloudflare’s IPv6 DNS server through the tunnel.

If the test succeeds, the script displays:

[OK] IPv6 connectivity verified

If it fails, it displays:

[WARN] IPv6 connectivity test failed

A failed ping doesn’t necessarily mean the tunnel is configured incorrectly, so the script reports a warning rather than deleting or recreating the tunnel automatically.

Validating multiple LANs and VLANs

The script also includes a configurable list of LAN interfaces and subnet IDs.

For example:

LAN_NETWORKS="
br0 1 Default
br10 2 IoT
br20 3 Guest
br30 4 Servers
"

Each entry tells the script which Linux interface to check and which /64 subnet should be assigned to it. For each entry, the script calculates the expected IPv6 subnet and gateway address. It then checks whether that gateway address is actually configured on the corresponding Linux interface.

For example, if the IoT network is configured correctly, you’ll see:

[OK] IoT (br10) - prefix matches

If the address doesn’t match, you’ll see something like:

[WARN] IoT (br10) - IPv6 prefix does not match
       Expected: 2602:c0:2:102::/64
       Gateway:  2602:c0:2:102::1/64

This is a read-only validation step.

The script doesn’t modify UniFi’s LAN configuration or automatically change the addresses assigned to your VLANs.

Also, this check verifies the expected gateway address on the interface. It doesn’t independently verify that clients are receiving the correct Router Advertisements.

Run the script

Once you’ve saved the script and updated the appropriate variables, execute:

/mnt/data/centurylink6rd/restore.sh

If everything is configured correctly, you’ll see output similar to:

[OK] WAN IPv4 detected
[OK] 6rd prefix calculated
[OK] Tunnel configured
[OK] IPv6 route installed
[OK] IPv6 connectivity verified

LAN/VLAN validation:
[OK] Default (br0) - prefix matches
[OK] IoT (br10) - prefix matches
[WARN] Guest (br20) - IPv6 prefix does not match
       Expected: 2602:c0:2:103::/64
       Gateway:  2602:c0:2:103::1/64
[OK] Servers (br30) - prefix matches

CenturyLink IPv6 tunnel configured.

WAN IPv4: 192.0.2.1
6rd Prefix: 2602:c0:2:100::/56
Tunnel IPv6: 2602:c0:2:100::1/128

Note: LAN prefix warnings do not
automatically modify UniFi settings.

The output uses documentation addresses for illustration. Your actual values will be derived from your public IPv4 address. The important thing is that the script distinguishes between configuring the tunnel and validating the LAN networks.

Note: a LAN mismatch won’t prevent the tunnel from being configured, but the warning will tell you which UniFi network needs attention.

Step 10: Handling dynamic IP changes and reboots

There are two separate problems to consider when automating the tunnel:

  1. Restoring the configuration after a gateway reboot.
  2. Handling changes to the public IPv4 address.

The script provides the configuration and restoration logic, but it doesn’t automatically execute when either event occurs.

Static IPv4 configuration

For my environment, this is relatively straightforward.

Since CenturyLink provides me with a static public IPv4 address, my /56 IPv6 prefix remains consistent.

Once UniFi is configured with the appropriate static IPv6 addresses on the LAN and VLAN interfaces, those addresses shouldn’t need to change during normal operation.

If the tunnel disappears after a reboot or reprovisioning event, I can restore it by executing:

/mnt/data/centurylink6rd/restore.sh

The script will recreate the tunnel, restore the firewall rules and route, and validate the LAN configurations.

Dynamic IPv4 configuration

If you have a dynamic public IPv4 address, there is an additional consideration.

Since CenturyLink derives the /56 prefix directly from the public IPv4 address, a new IPv4 assignment results in a different IPv6 prefix.

The script already handles recalculating the prefix and rebuilding the tunnel when it’s executed.

However, changing the tunnel alone isn’t enough. UniFi also needs to update the IPv6 addresses and Router Advertisements for each configured LAN or VLAN. Otherwise, devices will continue using IPv6 addresses derived from the previous public IPv4 assignment.

For now, the script deliberately leaves these UniFi-managed settings alone.

Instead, it displays a warning for each LAN interface that doesn’t match the newly calculated prefix to provide a clear indication of which networks need to be updated.

A fully unattended solution for dynamic IPv4 assignments would require an additional mechanism to detect WAN changes, execute the script, and update the managed UniFi LAN configuration. That could potentially be accomplished using the UniFi Network API, but that’s outside the scope of this script.

What about automatically restoring the tunnel after a reboot?

One limitation worth mentioning is that simply saving a script under /mnt/data doesn’t mean it will execute after a reboot.

The script provides a reusable way to configure the tunnel, but it doesn’t install a startup service or boot hook.

You would still need to integrate it with a startup mechanism verified to work on your particular UniFi firmware version.

I also wouldn’t assume arbitrary files or custom startup configurations survive every UniFi OS firmware update.

For now, the script makes it straightforward to restore the configuration manually without re-entering each command.

Troubleshooting common problems

Here are a few things I’d check if you’re having trouble getting this working.

The native 6rd command returns “Invalid argument”

If you’re following another guide and receive:

6rd: ioctl 89f9 failed: Invalid argument

Check the kernel configuration:

zcat /proc/config.gz 2>/dev/null | grep IPV6_SIT

If you see:

CONFIG_IPV6_SIT=y
# CONFIG_IPV6_SIT_6RD is not set

Your kernel supports standard SIT tunneling but not the specialized 6rd functionality.

Use the fixed-relay SIT approach described in this article rather than the native ip tunnel 6rd commands.

The tunnel exists, but IPv6 doesn’t work

Start by checking the tunnel:

ip tunnel show cl6rd

Then verify the IPv6 address and route:

ip -6 addr show dev cl6rd
ip -6 route show

Make sure the tunnel’s local IPv4 endpoint matches the current public IPv4 assigned to your WAN interface.

Also verify that the remote endpoint is CenturyLink’s relay:

205.171.2.64

You can inspect the tunnel’s packet counters:

ip -s link show dev cl6rd

If needed, use tcpdump to check whether encapsulated traffic is passing through the WAN:

tcpdump -ni ppp0 'ip proto 41'

SIT uses IPv4 protocol 41.

Seeing outbound traffic but no inbound responses may indicate a problem with upstream routing, filtering, or relay connectivity.

IPv6 works on the UXG-Pro but not LAN devices

First, verify that your LAN clients are receiving IPv6 addresses within the expected /64.

Next, confirm that Router Advertisements are enabled in UniFi Network.

You can also check whether IPv6 forwarding is enabled:

sysctl net.ipv6.conf.all.forwarding

A value of 1 indicates that forwarding is enabled.

The restore script’s LAN validation output should also help identify whether the configured IPv6 addresses match the prefixes derived from the current public IPv4 address.

If the gateway is working but clients aren’t, focus on the LAN prefix, Router Advertisements, and firewall configuration.

The script reports a LAN prefix mismatch

For example:

[WARN] IoT (br10) - IPv6 prefix does not match

This means the script didn’t find the expected IPv6 gateway address on the configured Linux interface.

First, verify that you’ve entered the correct interface name in the LAN_NETWORKS configuration.

You can list the interfaces again using:

ip -br link show

Next, inspect the IPv6 addresses assigned to that interface:

ip -6 addr show dev br10

If the interface name is correct but the address doesn’t match, check the IPv6 configuration for that network in UniFi.

Keep in mind that the script doesn’t modify the LAN address automatically. It only reports the expected configuration.

IPv6 works, but some websites time out

This may indicate an MTU or Path MTU Discovery problem.

Verify that the tunnel is configured with an MTU of 1472:

ip link show dev cl6rd

Also make sure that your overall IPv6 firewall configuration isn’t blocking ICMPv6 Packet Too Big messages.

IPv6 relies on these messages to determine the maximum usable packet size along a network path.

Can I host services over IPv6?

Yes. The IPv6 addresses assigned to your LAN clients are globally routable.

Unlike a typical IPv4 residential network using NAT, you don’t need IPv6 NAT or traditional port forwarding to make a service publicly accessible.

However, the firewall rules in this guide deliberately block unsolicited inbound connections.

If you want to host a web server, VPN server, or another service over IPv6, you’ll need to configure an explicit inbound firewall exception for that destination and port.

I’d also recommend assigning servers stable IPv6 addresses rather than relying on temporary privacy addresses.

Since the IPv6 prefix is derived from your public IPv4 address, having a static public IPv4 assignment makes maintaining DNS records and inbound firewall rules much easier.

Remove the tunnel and revert the changes

If you want to remove the tunnel configuration, first delete the IPv6 route:

ip -6 route del 2000::/3 dev cl6rd

Next, delete the tunnel:

ip tunnel del cl6rd

Remove the associated firewall rules:

ip6tables -D FORWARD -i cl6rd \
    -m conntrack ! --ctstate ESTABLISHED,RELATED \
    -j DROP
ip6tables -D INPUT -i cl6rd \
    -m conntrack ! --ctstate ESTABLISHED,RELATED \
    -j DROP

Finally, disable or revert the static IPv6 configuration under your LANs and VLANs in UniFi Network.

Final thoughts

It’s a bit surprising that in 2026 we’re still dealing with 6rd to get IPv6 connectivity through an ISP’s fiber connection.

The UXG-Pro supports IPv6, but the lack of a native 6rd configuration option combined with the missing CONFIG_IPV6_SIT_6RD kernel functionality makes this more complicated than it probably should be.

Fortunately, at least with my CenturyLink configuration, it’s possible to work around the issue using standard Linux SIT tunneling, an explicitly configured relay, and some IPv6 routing. With my static IPv4 assignment, UXG-Pro firmware 5.1.26, and UniFi Network 10.6.106, I’ve confirmed that the manually configured tunnel works and produces a 10/10 score on test-ipv6.com.

The restore script also removes the need to manually calculate IPv6 addresses, making it easier to configure the tunnel for either static or dynamically assigned IPv4 addresses. It also supports calculating and validating multiple LAN or VLAN prefixes, which is helpful if your UniFi environment has separate networks for devices, IoT, guests, or servers and you want to quickly add a new segment to your network.

For users with dynamic IPv4 assignments, there’s still additional work required to automate LAN prefix updates whenever the WAN IPv4 address changes. The script handles the tunnel configuration and provides diagnostic warnings, but it doesn’t modify UniFi-managed LAN settings in favor of making the configurations through Unifi’s supported interfaces.

Hopefully this helps anyone else trying to get CenturyLink IPv6 working on a UniFi UXG-Pro without spending hours troubleshooting the same missing kernel functionality.

Aside on IPv6 and IPv4 resources. With IPv6 becoming more widely available, your devices may begin using it automatically when connecting to websites and services. But what happens when both IPv4 and IPv6 are available? That’s where Happy Eyeballs comes in. Yes, “Happy Eyeballs” is its real name!

Happy Eyeballs is an official IETF standards-track algorithm defined in RFC 8305, designed to make IPv4 and IPv6 connectivity work together seamlessly. Happy Eyeballs allows applications to attempt connections over both IPv6 and IPv4, using timed attempts to establish a connection quickly without leaving users waiting on a broken or slow network path.

What does this mean for you? Websites that support IPv6 will increasingly use it automatically, while IPv4-only services will continue working as usual. For most users, the transition from v4 to v6 will feel seamless, with more internet traffic moving to IPv6 without any noticeable difference.

References

Revitalizing your Tesla AP1 vehicle with openpilot

There are a ton of old Teslas out there running Autopilot v1 (AP1), and they fall further behind in assisted-driving capability every year. AP1 felt ultra futuristic in its day, but it’s starting to become outclassed even by the lane assist shipping in everyday non-Tesla cars.

One of the biggest reasons I opted to seek an upgrade was the ability to remove the notorious “Tesla NAG”. While I get checking if the driver is paying attention is critical for safety, the way Tesla did it in AP1 was less than ideal by having to apply torque to the steering wheel, even if you are alert and watching the road ahead of you. In addition, the driving capabilities of AP1 on unmarked roads, aggressive curves, phantom braking, etc. make for a significantly less confident driving experience in AP1 and feel dated compared to newer vehicles today.

openpilot – make driving chill

Revive your car with the open source software openpilot, maintained by the company Comma AI! comma.ai — make driving chill

While Comma AI does not officially support old Tesla vehicles there are several forks for older Teslas to use openpilot and while Comma AI doesn’t provide an official wiring harness for older Tesla cars, they do provide a developer harness where you can make your own.

You can grab a harness for the vehicle from a developer in the openpilot community, who has helped try to maintain a fork of openpilot (which we’ll talk about later on). This makes installing the Comma 4 significantly easier by bringing peace of mind it’s correct and kick back support to the maintainer for their contributions to the fork for the older Tesla vehicles.

That being said, I went down the road of trying to DIY this myself for a few reasons:
1) I wanted to learn.
2) There have been many contributors to the Tesla AP1 effort and at time of writing this, there is only one individual making harnesses. If that individual decides to take on a new project, that makes new harnesses for Tesla AP1 vehicles impossible to source (supply chain risk). In the spirit of open source, IMO, the harness details should be available to keep the vehicle support available for future reference.
3) I wanted to use Comma supplied products as much as possible to have as close to what an official solution might be as possible.
4) I had many of the tools needed to make the harness and could do it for significantly less.

When starting the project, cost wasn’t originally my concern, but I was lucky and was able to pick up their official developer harness for $50 on sale and with sourcing the parts and ultimately was able to make it for under $100. The prebuilt harness was > $250 and my understanding at the time is it would have shipped from Europe, so it would have likely gone over $300 with tariffs with that assumption (although they have some stock in the USA I found out later on).

While a cheaper harness sounds great, one big note is if you go down the DIY route, don’t expect the Comma community to help. The folks that really understand this kind of stuff in the unsupported Tesla community really laid it on thick that I should give up and just buy the harness.

IMO, if you have the time and patience it was an interesting project and I learned a lot on the electrical side and comma side debugging the pin configuration.

Nonetheless, if you do this DIY, don’t expect much help. If you just want your car to work, buy the harness, it’s worth it. If you have some background in software development and electrical engineering, then here’s my learnings if you want to take a similar venture; but proceed at your own risk. I’m not responsible if you short out any components of your car.

Acquiring the parts for the harness if you DIY this

You can grab an official developer harness from Comma AI’s shop here: https://comma.ai/shop/car-harness?harness=Developer.

These are the parts from Digikey needed to make the harness:

Qty 2: MQS 8P PLUG HSG BLK – 17-1-1743282-2-ND

Qty 2: MQS HDR 8P ASSY(H-TYPE) BLK – 17-1-1743284-2-ND

Qty 20: MQS REC CONT – 17-5-963715-1CT-ND (You only need 16, but they are cheap and if you’ve never crimped these, grab a few extra for practice)

Qty 1: Breakable Protoboard: I used this to connect/solder the two MQS HDR 8P ASSY modules together: 1568-13268-ND

Qty 2: Hookup wire between 22AWG and 26AWG – I had some hookup wire on hand (3175 Adafruit Industries LLC | Cables, Wires – Single Conductors Kits | DigiKey), but you could buy per foot as well: 30-01780 Tensility International Corp | Single Conductor Cables (Hook-Up Wire) | DigiKey and 55A0111-26-9 TE Connectivity Raychem Cable Protection | Single Conductor Cables (Hook-Up Wire) | DigiKey

Qty 1: Electrical tape – Ideally, you could also use heat shrink tubing if you have some that’s 1.25″ wide.

Misc from Amazon

Qty 1: Wiring harness tape – not required, but can help with longevity of your cable.

Note: this guide assumes you have solder, soldering iron, and wire strippers on hand. I’d recommend an adjustable wire stripper as getting the lengths to the connectors can be tricky by hand, but if you are buying this just to make the cable, you may as well buy the prebuilt harness since you are likely getting close to breaking even: 12 40 200 Knipex Tools LP | Wire Strippers and Accessories | DigiKey

The pinout of the harness

First, familiarize yourself with the Comma AI dev harness. The pinout for it can be found here: https://github.com/commaai/hardware/blob/master/harness/v3/Developer_Harness.pdf

Next, understand the Tesla harness. Tesla has a copy of the Forward Camera (NCAP) harness here:

https://service.tesla.com/docs/ModelS/ElectricalReference2012/s-lhd-sop7/2015.1_ModelS_LHD_Release.pdf

Pulling it altogether, you get this as the secret sauce to build the harness:

This branch of your harness is what receives the connector from the car (connector originally going to the Forward Camera (NCAP) port).

Connector to car
(MQS 8P PLUG HSG)
[Connects to the Tesla cable]
SignalComma DEVELOPER harness pin
Pin 8GNDPin 1
Pin 7Radar CAN +Pin 8
Pin 6Radar CAN −Pin 7
Pin 5Camera CAN +Pin 4
Pin 4Camera CAN −Pin 6
Pin 3DefogHSD_OUTPassthrough to mirror pin 6
Pin 2DefogPWR_inPassthrough to mirror pin 7
Pin 1+12VPin 10

This branch of your harness is what plugs into the Forward Camera (NCAP) port.

Connector to mirror
(MQS 8P PLUG HSG)
[Connects to mirror port]
SignalComma DEVELOPER harness pin
Pin 1GNDPin 17
Pin 2Radar CAN +Pin 9
Pin 3Radar CAN −Pin 11
Pin 4Camera CAN +Pin 16
Pin 5Camera CAN −Pin 18
Pin 6DefogHSD_OUTPassthrough to car pin 3
Pin 7DefogPWR_inPassthrough to car pin 2
Pin 8+12VPin 12

Pin 3 and 5 are looped on the comma developer harness connector (this is standard to the dev harness as-is)

The two IGN cables (brown cables at time of writing) that come with the dev harness you can remove as they won’t be used. You can use a tiny flat head screwdriver to gently lift the flap holding the connector to the cable up while pulling on the cable to slide them out.

For Pin 6 and 7 that passthrough to other connector, those map to the defog control of the mirror and can skip connecting to the Comma developer harness. The developer harness uses 26 AWG wire if you want to match their wire size. I used White and Dark gray cables to try and differentiate from the colors used in the Comma dev harness, but the color of shielding isn’t going to make a difference electrically lol

One note about this, be very very very careful you do not mix up the 12v pins with another position when building your harness. If you send 12v down your CAN buses, that will not be good. Use a multimeter to triple check. I was ultra surprised to find that Tesla had a red wire on their harness and it didn’t carry 12v; rather the “blue” wire is what actually carried 12v. Going simply by the color codes would have given me a bad day, so DO NOT make any assumptions.

When building my harness, I terminated both the “to car” and “to camera” cables with the MQS 8P plugs. Connecting the cable from the car to the comma, I placed both MQS HDR 8P ASSY back-to-back on the protoboard and soldered the pins together. This allowed me a secure connection between the car and comma harness. Unfortunately, TE Connectivity doesn’t have the receiving connector without it being mounted to a PCB board, so double connector it was. Given vibration and force from accelerating / braking, I’d recommend locking connectors over the screw down blocks to prevent wires from coming loose.

Harness Creation

Break away 2 rows of 10 pins from your protoboard. If having a tough time snapping the protoboard apart, you can use a utility knife or exacto knife to score the area you want to snap; then use a needle nose pliers to wiggle the board back and forth until it breaks away. I used 10 pins worth, with one pin on each side unused, to give the connector a little more rigidity. Place the HDR 8P ASSY adapters back to back and solder them together.

Remove the 2 IGN pins from the comma harness since they won’t be used and add the MQS rectangular pins to each of the wires needed for the harness.

Connect the wires to each of the MQS 8P PLUG HSG adapters.

Add the fabric tape around the two branches of the harness and added electrical tape around the two HDR 8P ASSY adapters to insulate the soldered pins.

Final harness should look something like this:

Mount the Comma 4 Bracket

Before you pull apart your mirror, I would recommend mounting the Comma 4 bracket. Sit how you would normally in your seat, then hold the device up. The camera can be partially blocked by the mirror, but not fully. I saw a guide that said use 3 fingers down and you should be good. I did more like 4-5, since 3 had the entire device completely blocked by the mirror. Once your Comma 4 is mounted, remove the Comma 4 itself from the device, leaving just the mount on your windshield. One thing to make sure is to make sure the yaw (left and right balance) of the unit is as close as level as possible; you have roughly 4 degrees of skew and it’s nearly impossible to adjust once you stick the device, so have someone take a look on the outside of the windshield to gauge if it looks aligned properly.

Install the harness

To install the harness you will need to remove the front mirror assembly. Official instructions can be found on Tesla’s website here:

https://service.tesla.com/docs/ModelS/ServiceManual/en-us/GUID-090CC4A1-9427-4E59-B021-74488A5C74DE.html

If you prefer a YouTube video, this video was helpful on understanding how to remove the mirror (don’t go past moving the mirror):

Mount the Comma 4 and Connect the harness

With the mirror assembly removed, unplug the connector to the mirror by pushing in both of the black sides of the connector and adding a pushing motion away to try and leverage the connector out. I found using two small flathead screwdrivers to push in the side tabs while trying to scoot the connector out worked the best. It’s tricky and can be frustrating, but you can get it!

Remove the upper mirror headliner cover if you haven’t already. It’s held into place by two metal clips, pull straight down on the “back” side to detach it.

Pull the existing cable connected to the mirror assembly up through the headliner and route the one end of your harness back to the assembly and clip it into place (you should hear it click into place). Connect the other end of the harness to the cable that was previously connected to the mirror assembly. Then connect a USB-C cable to the developer harness box and route it down through the headliner.

Looking up, I routed the USB-C cable from the Comma developer harness box around the mirror bracket and back into the headliner.

The top bracket should snap in place, even with the cable on the right side, with a tight fit.

Snap the mirror into place first and then connect the wiring harness to it. Make sure the mirror is fully snapped into place; it can feel like it has, even though it hasn’t.

Mount your Comma 4 on the mount and connect the USB-C cable from the dev harness to the comma and it should immediately start to power up.

Install the xnor fork for the Comma 4

When the Comma 4 boots, you’ll land on a setup screen. Connect to WiFi (this is not optional as you need connectivity for the next step).

Eventually, the Comma will prompt if you want to use openpilot or Custom Software. Stock openpilot won’t do anything for you here as it doesn’t support older Tesla vehicles with AP1, so select custom software.

The fork that keeps AP1 alive is xnor, maintained by xnor-tech (Lukas Loetkolben on GitHub). Docs on how to get started can be found here: https://wiki.xnor.shop/docs/general/installing_a_fork/ On this step,

Enter this URL to install the xnor fork that has the Tesla AP1 car support: https://installer.comma.ai/xnor-tech/xnor

Unrelated, whoever at Comma was responsible for the onboard keyboard on the Comma 4 should get a gold star. The second I saw the keyboard I got anxiety that it would be miserable to type the URL in, but the keyboard honestly functions incredibly well for such a small screen. Kudos!

The install should take about 5-10 mins; it’s pretty quick.

Notes:

The `xnor` branch is the precompiled one and it covers comma four + comma 3X for Tesla Model S/3/X/Y across HW1 through HW4 — HW1 being what we care about for AP1.

There’s also `xnor-dev` at `https://installer.comma.ai/xnor-tech/xnor-dev` which builds from source on the device. It takes considerably longer to install. Unless you have a reason to be on dev, take the precompiled branch.

Setup Comma 4

At this point, I ran through the rest of setup and calibration of the Comma 4.

Ideally, you mounted the Comma 4 so you can run through the driver detection mode and pass it on first try. After driver monitoring, you’ll be prompted to run through device calibration, where you drive the car > 15 mph for a little bit of time.

Unlike me, I mounted the Comma 4 blindly after tearing the mirror apart and one thing that later got me is the yaw of the device has to be within ±3.96°. Lucky me, I was off by 0.07 degrees at −4.03° and had to remount the whole thing. At time of writing this, Comma doesn’t have a mounting mode to show you want the yaw of the device is, so you kinda have best guess whether or not it’s level on the windshield. If it’s off, you’ll see a “Unable to calibrate” messages, with no details on what went wrong.

If you unfortunately have to tear the Comma 4 mounting bracket off, I used a large wide razor blade that you use when scraping items or cleaning windows. Strongly recommend using that or some sort of plastic prying tool as trying to pull it off with your fingers is pretty much impossible

Grab the Comma Connect app

Comma Connect is the interface which allows you to pull dashcam footage, live views of the camera, and drive details. I’ve developed a mobile app to try and simplify working with the comma device on the go. You can grab a free copy of the app on the Google Play store here: Comma Connect (Unofficial) – Apps on Google Play

In closing

At this point, everything should be working. If it is, go ahead and repeat the mirror disassembly steps in reverse to reassemble it.

This is a really brief guide and slightly scattered, but thought I’d post it as I couldn’t find what the mirror pins did and was curious if the original dev harness from Comma could be used as-is (which it looks like it can be! : )

Disclaimer: At time of writing this article (and likely still not today), I am not affiliated with, sponsored by, or have any investment in comma ai or openpilot. I’m just some random person that wants to make my car feel like a car of the future again.

Disclaimer 2: You are responsible for your own car and what you do with it. Follow local, state, and federal law when it comes to your vehicle and what you do with it.

Building My Own GPS Stratum-1 Time Server on a Raspberry Pi

Building My Own GPS Stratum-1 Time Server on a Raspberry Pi

Time silently hums away in the background, affecting everything we do today. Wake up, go to work, go to an appointment; all happen at specific times. Computers are no different, constantly logging time in logs, showing time in applications, and used for scheduling. In general, no one really cares about time because it’s typically “accurate enough” for most tasks. Most computers just automatically have the correct time and are accurate down to a second or so, with most people perfectly content that it’s technically approximately right. That being said, I’m neurotic and want nuclear-science-lab accuracy, knowing precisely what time it is.

While pool.ntp.org is a fantastic source of time, why settle when you can have sub-microsecond precision? No one is going to have a good time when you’re technically having a bad time.

For this project, I wanted to see how precise time can be in my house, owning it end to end — no leaning on someone else’s server out on the internet. In the industry, precision time is provided by what are called stratum-1 time servers and can sometimes be thousands of dollars. Given I’m cheap and don’t have a money tree, I decided to build my own stratum-1 time server in a relatively affordable way: a Raspberry Pi that pulls time straight off the GPS satellites overhead and serves it to everything on my LAN, no internet required.

Along the way the project grew a second half, which is to visualize the satellite tracking and time sync. Once I had gpsd and chrony working, I wrote a small daemon that enables a live view of the calculated time, full gpsd + chrony stats, a satellite sky view, and an interactive 3D globe showing the GNSS constellation in space.

This build stands on the shoulders of the classic jacobdeane.com GPS time server guide, with two deliberate modernizations:

  • chrony instead of ntpd. chrony has native PPS support and a clean socket backend, so you skip the from-source NTP compile entirely.
  • Current Raspberry Pi OS paths. On Bookworm (and Trixie) the boot config lives at /boot/firmware/config.txt, not the old /boot/config.txt (which is now just a placeholder).

How it all fits together

Before wiring anything up, here’s a quick overview :

   GPS/RTC HAT (u-blox + PPS)           Raspberry Pi 3 / 4 (host)
 ┌───────────────────────────┐    ┌──────────────────────────────────────────────┐
 │  receiver                 │    │                                              │
 │   • NMEA over UART  ──────┼──▶│  /dev/serial0 ─▶ gpsd ─┐                      │
 │   • PPS on GPIO18   ──────┼──▶│  /dev/pps0 ────────────┼─▶ chrony (stratum 1) │
 └───────────────────────────┘   │                        │        │  serves NTP │
                                 │   gpsd JSON :2947 ─┐    │        ▼   to LAN    │
                                 │   chronyc -c ──────┼────┴─▶ Rust daemon        │
                                 │   (optional) NAVCEN ───▶  :8080 (configurable) │
                                 │                    ├─▶ /data     (SSE telemetry)│
                                 │                    ├─▶ /satinfo  (opt. sat info)│
                                 │                    └─▶ /         (dashboard)   │
                                 └───────────────────────────────┬───────────────┘
                                                                │
                                                   browser ◀────┘  dashboard
  1. The timekeeping path — gpsd reads NMEA + PPS and feeds chrony; chrony disciplines the kernel clock and serves NTP to my network. This is what makes it a stratum-1 server. If you stop reading after Part 6, you’ve got a fully working time server.
  2. The telemetry path — a small daemon written in Rust that reads the same gpsd data plus chrony’s stats and streams them to the dashboard over Server-Sent Events. It’s strictly read-only and has zero effect on timekeeping accuracy.

What you’ll need

Here’s the parts list I used:

PartNotes
Raspberry Pi 3 or 4Both work identically here. The Pi 4 has dedicated Gigabit Ethernet; on the Pi 3, Ethernet shares the USB bus, so keep other USB devices off it. The Pi 5 needs different UART steps and isn’t covered.
GPS HAT with PPSI used the Uputronics Raspberry Pi GPS/RTC Expansion Board, and it’s what this guide is written against. PPS is mandatory for stratum-1; NMEA alone is only good to a few hundred milliseconds.
GPS antenna (active, timing-grade)Amphenol PCTEL 3978D-HR-DH-W — the exact antenna I used: a weatherproof (IP67) GPS L1 dome antenna with a built-in LNA + filter (~40 dB gain) and a TNC-female connector. A real step up from a puck. Needs a clear-ish view of the sky.
Antenna mounting bracketAmphenol PCTEL MMK1925 — the stainless-steel L-bracket for the WS397x-series dome (fits the 3978D). This and the antenna above are what I actually mounted.
Antenna coaxA run with the right ends for your antenna and HAT (see the note below). I didn’t spring for timing-grade coax — I used a cheap SMA extension cable like this one and it’s been perfectly fine.

All in all, the project cost ~$240 (inc. shipping, tax, etc). I didn’t include the cost of a raspberry pi since I had one unused from a prior project, but assuming you bought a new Pi; you are ~$360.

Notes before we get started

Check your HAT’s PPS pin. This guide assumes GPIO18 (the Uputronics default). Some HATs (e.g. Adafruit) use GPIO4. Adjust the gpiopin= value accordingly. Guess wrong and PPS will simply never show up.

Antenna connector & bias (if you’re using the dome antenna above). The 3978D outputs TNC female, while most GPS HATs (Uputronics included) use SMA — so plan a coax run with the right ends (TNC-male at the antenna, SMA-male at the HAT) or grab an adapter. It’s an active antenna (built-in LNA), so the HAT has to supply DC bias voltage on the coax; the Uputronics HATs do this, but confirm yours does. Being IP67 and bracket-mounted, it’s meant to live outdoors on a mast with a clear sky view.

You don’t have to mount it outdoors. The 3978D is a weatherproof, IP67, mast-mount dome. It’s built to bolt onto a roof and shrug off the weather. I didn’t put it on a roof. I bolted it to its MMK1925 bracket, set the whole thing up in my attic under asphalt shingles and 5/8″ OSB, and ran it back to the HAT with a cheap SMA extension cable instead of proper timing-grade coax. On paper that’s leaving signal on the table three different ways. In practice it’s been tracking ~16-20 satellites and holding a sub-microsecond lock the entire time. So: buy the better antenna over fancy cable if you are on a limited budget and don’t let “needs a clear view of the sky” scare you into a rooftop install (especially if you are HOA bound and limited to what you can mount outside). An attic on the sky-facing side of the house is likely going to do it for you.

Part 1 — The base OS

Flash Raspberry Pi OS Lite (64-bit) with Raspberry Pi Imager. In the Imager’s advanced settings (the gear icon), enable SSH and set the hostname (I’ll use timeserver for this guide) and locale before writing. Note: this Pi runs headless, which means you don’t have a monitor connected to it, so you want to be able to reach it on first boot.

A word on Bookworm vs Trixie. As of mid-2026 there are active reports of a Trixie serial regression where the GPIO UART stops receiving GPS data on hardware that worked fine on Bookworm (a suspected kernel/PL011 driver issue). For a device whose entire job is reliable time, Bookworm might be the safer choice today; but so far things have worked fine for me. The config paths and overlays are identical — but if NMEA never appears despite correct wiring, jump to the Trixie diagnostic in Troubleshooting. There’s also a Debian 12 vs 13 appendix at the bottom.

Boot, SSH to the Pi, and update first:

sudo apt update && sudo apt full-upgrade -y
sudo reboot

Part 2 — Freeing up the hardware UART

Here’s the first “gotcha” the Pi throws at you. The Pi 3/4 wire the good UART (PL011) to the onboard Bluetooth by default. We need that UART for the GPS, and we also need to stop Linux from using the serial port as a login console.

2a. Disable the serial login shell (but keep the hardware port)

sudo raspi-config

Under Interface Options → Serial Port:

  • login shell accessible over serial? → No
  • serial port hardware enabled? → Yes

Choose Finish but don’t reboot yet, we’ve got more config to add, and I’d rather reboot once.

2b. Disable Bluetooth to reclaim the UART

sudo vi /boot/firmware/config.txt

New to vi? Here’s the reader’s digest: press i to start typing (insert mode), make your edits, then hit Esc and type :wq followed by Enter to write and quit. If you fat-finger something and want to bail without saving, Esc then :q! throws your changes away. That’s genuinely 90% of what you need to survive vi.

Add this at the end (we’ll drop the PPS line in here too in Part 3, so it’s a single reboot):

# Reclaim the PL011 UART for GPS by disabling onboard Bluetooth
enable_uart=1
dtoverlay=disable-bt

Older guides use dtoverlay=pi3-disable-bt; the current canonical name is just disable-bt. raspi-config may have already added an enable_uart=1 line — a duplicate is harmless, but you can keep just one if it bugs you.

Then stop the Bluetooth-UART service:

sudo systemctl disable hciuart

On a Lite image (and commonly on Trixie) this may report Unit hciuart.service does not exist — and that’s totally fine. It ships with the pi-bluetooth package, which isn’t always installed, and disable-bt already turns Bluetooth off at the device-tree level. The overlay is what actually matters here; this step is just belt-and-suspenders.

Part 3 — Enabling PPS

PPS (pulse-per-second) is the secret sauce. NMEA sentences tell you what second it is; the PPS pulse tells you exactly when that second begins, down to the nanosecond. No PPS, no real stratum-1.

3a. Load the PPS-over-GPIO overlay

Still in /boot/firmware/config.txt, add (adjust gpiopin for your HAT):

# Pulse-Per-Second input on GPIO18
dtoverlay=pps-gpio,gpiopin=18

Then load the kernel module at boot and reboot so both the UART and PPS changes take effect:

echo 'pps-gpio' | sudo tee -a /etc/modules
sudo reboot

3b. Verify raw GPS and PPS

First confirm the UART is on the GPIO header, then read raw NMEA:

ls -l /dev/serial0        # expect: /dev/serial0 -> ttyAMA0
sudo cat /dev/serial0

Always use /dev/serial0 — it’s a symlink that always points to whichever UART is on the GPIO header, so you don’t have to care whether it’s ttyAMA0 or ttyS0 today. Ctrl-C to stop.

If you see garbled characters instead of clean $GNRMC/$GNGGA lines, don’t panic, that’s a baud-rate mismatch, not a fault. Bytes are flowing; the port’s just reading them at the wrong speed. Set the rate explicitly and re-read until it’s clean:

sudo stty -F /dev/serial0 9600 raw   && sudo cat /dev/serial0   # common u-blox default
sudo stty -F /dev/serial0 38400 raw  && sudo cat /dev/serial0
sudo stty -F /dev/serial0 115200 raw && sudo cat /dev/serial0   # mine with the Uputronics unit

Write down the rate that produces readable NMEA, you’ll need it for gpsd in Part 4. It’s a property of your GPS module, not the OS. Many u-blox modules use 9600, but others (including some Uputronics units) run at 115200. Mine was 115200.

Now confirm the pulse itself. This needs a satellite fix; most HATs blink a timepulse LED once they’ve locked:

sudo apt install -y pps-tools
sudo ppstest /dev/pps0

You want a new assert line every second, timestamped right on the whole second:

source 0 - assert 1699999590.000001416, sequence: 197334 - clear ...
source 0 - assert 1699999591.000000698, sequence: 197335 - clear ...

No output usually just means no fix yet, wait a couple mins or try moving the antenna to a different location. If you are running the antenna in a faraday cage, expect no results since it won’t find a satellite.

Part 4 — gpsd

sudo apt install -y gpsd gpsd-clients
sudo vi /etc/default/gpsd

Point it at your HAT (using the baud rate you found in Part 3b):

START_DAEMON="true"
USBAUTO="false"
DEVICES="/dev/serial0 /dev/pps0"
GPSD_OPTIONS="-n -s 115200"

-n polls the GPS immediately at boot; -s 115200 pins the serial speed so gpsd doesn’t autobaud its way into garbage. Enable and check:

sudo systemctl enable --now gpsd
sudo systemctl restart gpsd      # ensure it picks up the new config
gpsmon        # or: cgps -s

gpsmon shows the raw feed (fast-scrolling UBX packets are normal and healthy); cgps -s gives a calmer table. You want a 3D fix, satellites with good SNR, low DOP values, and a PPS line with a near-zero offset. That PPS line is the confirmation I was chasing.

Tip: gpsd takes exclusive control of the port. If you want to cat /dev/serial0 again for debugging, stop gpsd first: sudo systemctl stop gpsd gpsd.socket.

Part 5 — chrony with the socket backend

sudo apt install -y chrony

Here’s the key design point, and the reason chrony is such an upgrade over the old ntpd dance: gpsd hands the precise PPS timestamp to chrony through a Unix socket (chrony’s SOCK refclock). gpsd automatically writes to /run/chrony.<device>.sock once chrony has created it — neatly sidestepping the classic headache where chrony drops privileges and can no longer read /dev/pps0 directly.

sudo vi /etc/chrony/chrony.conf

Add this at the end:

# --- GPS + PPS reference clocks -------------------------------------------

# Coarse absolute time from gpsd (NMEA) via shared memory.
# 'noselect' = never sets the clock, only gives the PPS pulse a whole-second
# label. Tune 'offset' after calibration (Part 7d) if PPS won't lock.
refclock SHM 0 refid GPS precision 1e-1 offset 0.0 delay 0.2 noselect

# Precise PPS via gpsd's socket. gpsd writes here automatically.
# 'lock GPS' anchors each pulse to the GPS second above.
refclock SOCK /run/chrony.pps0.sock refid PPS lock GPS prefer

# Serve time to your LAN (adjust to your subnet) and keep serving during
# brief GPS dropouts.
allow 192.168.0.0/24
local stratum 1

# Optional internet fallback if the antenna fails. Remove for air-gapped use.
pool 2.debian.pool.ntp.org iburst

Restart both, in order — chrony first, so its socket exists by the time gpsd starts:

sudo systemctl restart chrony
sudo systemctl restart gpsd

Confirm the socket exists (it should be owned by root and actually be a socket — note the leading s in the permissions):

ls -l /run/chrony.*.sock      # expect /run/chrony.pps0.sock

Part 6 — Confirming you’ve actually got a stratum-1 server

Give it 5 minutes to settle, then watch the sources roll in:

watch -n 2 chronyc sources -v

You’re looking for #* next to PPS. The asterisk means it’s the selected source disciplining the clock. It starts at #? (not enough samples yet), climbs its reachability register (0 → 1 → 3 → 7 → 17 → 37 → 77 → 377), then finally promotes to #*. The internet servers drop to ^- (background sanity checks) once PPS takes over. Watching that register tick up is weirdly satisfying:

MS Name/IP address   Stratum Poll Reach LastRx Last sample
=========================================================================
#? GPS                     0    4  377     14   +42ms[  +42ms] +/- 200ms
#* PPS                     0    4  377     13   -242ns[ -324ns] +/- 1095ns
^- 2.debian.pool.ntp.org   2    6   77     45   -437us[ -436us] +/-  13ms

Then confirm the server itself:

chronyc tracking

Look for Stratum : 1, Reference ID … (PPS), and a System time offset down in the sub-microsecond range. That, right there, is a working stratum-1 time server. I may have taken a screenshot.

Reading the numbers: System time / Last offset are your current accuracy (nanoseconds-to-microseconds once locked). RMS offset looks alarming at first (hundreds of µs) because it’s a rolling average still dominated by the noisy warm-up; it falls steadily the longer it runs, so don’t judge it too early. Root delay near zero and Reference ID = PPS confirm a direct hardware lock.

The falseticker trap. chrony will reject PPS if the GPS (NMEA) label is more than ±200 ms off the true second. If PPS stays stuck at #?, that’s almost always the culprit, calibrate the offset (Part 7d).

At this point the time server itself is done. Below is the fun part of visualizing the setup. If you just wanted accurate time, congratulations, you can stop here.

Part 7 — The telemetry daemon

Now to create a live dashboard to visualize everything. The daemon does four things:

  1. Connects to gpsd’s JSON socket on 127.0.0.1:2947 — the same protocol libgps speaks, so I get identical data without any C FFI.
  2. Polls chrony once a second with chronyc -c tracking (the -c flag hands back machine-readable CSV from chrony’s command socket).
  3. Merges both, streams them to the browser over SSE at /data, and serves the dashboard.
  4. (Optional, off by default) fetches and caches the NAVCEN GPS almanac and serves it at /satinfo for satellite-health enrichment (more in Part 8).

Get the code, the daemon and dashboard live on GitHub: JackStromberg/gps-timeserver-daemon. Clone it directly onto the Pi:

sudo apt install -y git
git clone https://github.com/JackStromberg/gps-timeserver-daemon.git
cd gps-timeserver-daemon
ls -al

You should end up with:

gps-timeserver-daemon/
├── Cargo.toml
├── src/main.rs
└── static/index.html      # already set to live mode (USE_SIMULATION = false)

7a. Install Rust and build

curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh   # press Enter for defaults
source "$HOME/.cargo/env"
cargo --version                                                  # should print a version
sudo apt install -y build-essential                              # C linker (Lite images lack it)
cd gps-timeserver-daemon
cargo build --release

cargo: command not found right after installing? The installer can’t modify an already-running shell’s PATH. Run source "$HOME/.cargo/env" (or just open a new terminal). If ls ~/.cargo/bin/cargo doesn’t exist, the install didn’t finish — re-run the curl … | sh line.

error: linker 'cc' not found? Install build-essential (above) and rebuild.

Fair warning: the first build on a Pi is slow. It compiles tokio, axum, reqwest, and the whole rustls TLS stack from scratch (several minutes), and it may look completely frozen at the final link step. That’s normal. Subsequent builds are incremental and quick.

7b. Run it

./target/release/gps-timeserver-daemon

It prints its listen address and stays running (it’s serving, so it won’t hand you back a prompt). Open http://<pi-ip>:8080 from a browser on your LAN and you should see the dashboard light up.

The port is configurable — see Part 8. If 8080 is already taken (a Docker service, say), you’ll get AddrInUse; set a different PORT rather than editing code.

7c. The /data contract

The daemon emits one JSON object per second in this shape (the dashboard reads exactly these field names):

{
  "timestamp": "2026-07-07T12:34:56Z",
  "time":   { "gps_time": "...", "system_time": "...", "offset_us": 0.42,
              "time_since_last_fix": 0.3, "leap_seconds": 18 },
  "gps":    { "mode": 3, "satellites_visible": 12, "satellites_used": 9,
              "hdop": 0.8, "vdop": 1.1, "pdop": 1.3, "gdop": 1.5, "tdop": 0.7,
              "ept": 0.005, "latitude": 51.5, "longitude": -0.12, "altitude": 35.2,
              "epx": 3.1, "epy": 4.2, "epv": 6.0, "track": 0.0, "speed": 0.0,
              "climb": 0.0, "eps": 0.5 },
  "chrony": { "reference_id": "PPS", "stratum": 1, "system_time": 0.0000004,
              "last_offset": 0.0000004, "rms_offset": 0.0000012,
              "frequency_ppm": -2.3, "residual_freq_ppm": 0.001, "skew_ppm": 0.02,
              "root_delay": 0.00001, "root_dispersion": 0.00002,
              "update_interval": 16.0, "leap_status": "Normal" },
  "device": { "path": "/dev/serial0", "driver": "u-blox", "subtype": "...",
              "bps": 115200, "parity": "N", "native": true, "cycle": 1.0, "mincycle": 1.0 },
  "version":{ "release": "3.25", "proto_major": 3, "proto_minor": 15 },
  "satellites": [
    { "prn": 5, "system": "GPS", "elevation": 70, "azimuth": 251, "snr": 42, "used": true }
  ]
}

Telemetry::to_json() in src/main.rs is the single source of truth for this contract — if you want to add a field, that’s the one place to touch.

7d. Calibrating the GPS offset (optional)

If PPS refuses to lock, let it run ~15 minutes, then read the measured GPS offset:

chronyc sourcestats

Take the GPS source’s Offset (say +512ms), convert to seconds, and set it as offset on the SHM 0 line in chrony.conf (e.g. offset 0.512). Restart chrony. This nudges the NMEA label back inside the ±200 ms window so PPS gets accepted — the fix for that falseticker trap I mentioned above.

Part 8 — Configuration via environment variables

I wanted to be able to change behavior without recompiling, so the daemon reads a handful of environment variables at startup. Set them when running manually, or via Environment= lines in the systemd unit (Part 9).

VariableDefaultPurpose
PORT8080Port to bind (binds 0.0.0.0:PORT).
LISTEN_ADDR(unset)Full host:port; overrides PORT. Use 127.0.0.1:8088 to serve only locally, 0.0.0.0:8088 for the LAN.
SATINFO_ENABLED(off)1/true/yes/on enables the optional NAVCEN satellite-info fetcher. Off means the daemon makes zero outbound network calls — safe for air-gapped setups.
SATINFO_INTERVAL_HOURS168 (weekly)How often to refresh the NAVCEN data. The constellation changes only a few times a year, so weekly (or 720 ≈ monthly) is plenty.
SATINFO_URLNAVCEN YUMA almanacOverride the source if needed.

The optional satellite-info fetcher

When SATINFO_ENABLED=1, a background task fetches the NAVCEN GPS almanac (US Coast Guard, public-domain US-government data), caches it in memory, and serves it at /satinfo. The dashboard uses it to add live health status to each GPS satellite’s hover tooltip. On failure it retries in an hour; on success it waits the full interval. Left disabled (the default), /satinfo returns {"enabled": false} and the dashboard silently falls back to its built-in reference table — so nothing breaks either way.

Quick checks once you’ve enabled it:

journalctl -u timeserver-dashboard -n 20 --no-pager   # look for: satinfo: fetched N satellites
curl -s localhost:8080/satinfo | head -c 300          # expect "enabled":true and a satellites object

On data sources & licensing (not legal advice): NAVCEN is public-domain US-government data — no account, redistribution-safe. The dashboard’s built-in reference table is assembled from public GPS records. If you instead point live mode at CelesTrak, respect their access etiquette (cache; don’t poll more than roughly every 2 hours) and cite them. There’s a fuller Licensing appendix at the bottom.

Part 9 — Running it at boot

I want this thing to just come back on its own after a power blip, so let’s give it a systemd unit:

sudo vi /etc/systemd/system/timeserver-dashboard.service
[Unit]
Description=GPS Timeserver Dashboard
After=network-online.target gpsd.service chrony.service
Wants=network-online.target

[Service]
# Adjust User and paths to where you put the project.
User=pi
WorkingDirectory=/home/pi/gps-timeserver-daemon
ExecStart=/home/pi/gps-timeserver-daemon/target/release/gps-timeserver-daemon
Restart=on-failure
RestartSec=3

# Optional configuration (see Part 8):
Environment=PORT=8088
# Environment=SATINFO_ENABLED=1
# Environment=SATINFO_INTERVAL_HOURS=168

[Install]
WantedBy=multi-user.target

WorkingDirectory matters more than it looks. The daemon serves static/index.html relative to where it runs, and systemd does not inherit your shell’s current directory. Point this at the wrong place and you’ll get a 404 for the dashboard.

Enable and verify:

sudo systemctl daemon-reload
sudo systemctl enable --now timeserver-dashboard
systemctl status timeserver-dashboard          # expect: active (running)
journalctl -u timeserver-dashboard -n 5 --no-pager   # confirm the listen address

Whenever you edit the unit file (say, to change an Environment= line), run sudo systemctl daemon-reload and then sudo systemctl restart timeserver-dashboard. Skipping the reload is the classic “my change didn’t take” gotcha. systemctl show timeserver-dashboard -p Environment prints what systemd is actually passing, which is handy when you’re sure you fixed it but it disagrees.

One nice quality-of-life note: editing only static/index.html (HTML/CSS/JS) needs no rebuild and no restart — the daemon serves it from disk at request time. Just hard-refresh the browser (Ctrl-Shift-R). Only changes to src/main.rs or Cargo.toml require cargo build --release.

Part 10 — Serving time to the rest of the network

Everything so far only disciplines the Pi’s own clock. To let other machines sync from it, chrony has to answer NTP requests — and it refuses by default, which is a good safety default but not what we want here.

10a. Match the allow line to your LAN

hostname -I               # your Pi's IP, e.g. 192.168.1.50

If the Pi is 192.168.1.50, your subnet is likely 192.168.1.0/24. Edit chrony.conf so the allow line matches — a whole subnet (allow 192.168.1.0/24), a single host (allow 192.168.1.100), or allow all only if you deliberately mean to serve the public — then restart:

sudo systemctl restart chrony

10b. Confirm it’s serving

sudo ss -ulnp 'sport = :123'    # something should be bound to UDP 123
chronyc clients                 # machines that have requested time (empty until clients connect)

If you run a firewall on the same Raspberry Pi (ufw, nftables, or Docker-managed iptables), open NTP: sudo ufw allow 123/udp. A plain Lite install has no firewall and needs nothing.

10c. Point your clients at the Pi

Your Pi is stratum 1, so anything syncing to it becomes stratum 2 — perfect for a LAN. Here’s how I pointed a few common clients at it:

  • Router: set its NTP server to the Pi’s IP — this pushes time locally to every device automatically, using it as an ntp source.
  • Linux: add server <pi-ip> iburst to /etc/chrony/chrony.conf, restart chrony.
  • Windows: w32tm /config /manualpeerlist:"<pi-ip>" /syncfromflags:manual /update then w32tm /resync.
  • macOS: System Settings → General → Date & Time, or sudo sntp -sS <pi-ip>.

Verify from a client with chronyc -h <pi-ip> tracking, sntp <pi-ip>, or ntpdate -q <pi-ip>. Back on the Pi, chronyc clients will list everyone pulling time from you.

Part 11 — Actually using the dashboard

Open http://<pi-ip>:<port> (default 8080, or 8088 in my examples above). Everything updates once per second from the live feed.

Time header. A large clock with millisecond precision, plus GPS time, system time, and the chrony offset. The UTC / Local toggle under the clock switches every time on the page between UTC and your browser’s local zone, and it remembers your choice across reloads.

Orbital globe (the hero panel, and my favorite). A holographic Earth with each GNSS satellite placed in true 3D from its azimuth/elevation, color-coded by constellation, with beams running from the satellites in use down to a marker at your location.

  • Drag to rotate (mouse or touch): horizontal spins around the poles, vertical tilts.
  • Home button (top-right) or double-click the globe to glide back to the default view.
  • Hover a satellite for its details: PRN, constellation, elevation, azimuth, SNR, and whether it’s in the solution — plus, for GPS, catalog facts (block, manufacturer, clock type, launch year, name, SVN) and, if /satinfo is enabled, live health.
  • Hover a legend entry (GPS/GLONASS/Galileo/BeiDou/SBAS) for a one-line explanation of that constellation.

Status panels. GPS Status, Chrony Status, Position & Navigation, and System Info. Hover any metric label (they’ve got a dotted underline) for an explanation of what it means and what a good value looks like — HDOP, TDOP, EPX/EPY/EPV, the chrony offsets, and so on. I added these mostly so I’d stop having to look them up myself.

Sky view + satellite table. Polar az/el plot, plus a detailed per-satellite table.

Satellite-info modes (dashboard side)

Near the top of the <script> in static/index.html:

  • SATINFO_MODE — 'daemon' (default; asks this server’s /satinfo, no CORS, falls back to the built-in table if the server has fetching disabled), 'offline' (built-in table only, never fetches), 'celestrak' (fetch CelesTrak directly from the browser; may hit CORS), or 'off'.
  • The built-in table always supplies block/manufacturer/clock/name/launch; /satinfo adds live health on top.

Previewing with simulated data

static/index.html ships with const USE_SIMULATION = false; (live). To preview the UI without any hardware — say you’re building this on your laptop before the HAT arrives — open a copy with it set to true. It generates fake data (a fixed location, invented satellites, a locked stratum-1 state) so every visual works offline.

Heads up: the globe and all hover tooltips are mouse-based, so the hover interactions won’t appear on touchscreens (dragging the globe still works via touch).

Part 12 — Optional: running the dashboard in Docker

You can containerize the dashboard daemon — but keep gpsd + chrony on the host. The kernel clock is a single host-wide resource; putting chrony in a container means granting it SYS_TIME and steering the host clock across the container boundary — extra layers wrapped around your most timing-sensitive component. The daemon, on the other hand, is a read-only consumer and containerizes cleanly with zero precision impact.

Here’s an example multi-stage Dockerfile:

# Build
FROM rust:1-bookworm AS build
WORKDIR /app
COPY Cargo.toml Cargo.lock* ./
COPY src ./src
RUN cargo build --release

# Runtime
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
      chrony ca-certificates && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=build /app/target/release/gps-timeserver-daemon /usr/local/bin/
COPY static ./static
CMD ["gps-timeserver-daemon"]

Note that chrony is installed here only for its chronyc client (the daemon shells out to it) — the container does not run a chrony daemon. Run it sharing the host network and chrony’s command socket:

docker build -t timeserver-dashboard .

docker run -d --name timeserver-dashboard \
  --network host \
  -e PORT=8088 \
  -e SATINFO_ENABLED=1 \
  -v /run/chrony:/run/chrony:ro \
  --restart unless-stopped \
  timeserver-dashboard
  • --network host lets the daemon reach host gpsd at 127.0.0.1:2947 and keeps PORT/LISTEN_ADDR working normally.
  • Mounting /run/chrony gives the container’s chronyc access to chrony’s command socket.
  • You do not need --device=/dev/serial0 or /dev/pps0 here — the daemon never touches the hardware directly; it reads gpsd and chronyc. (Those devices only matter if you were containerizing gpsd/chrony too, which, again, I’d steer you away from.)

Troubleshooting

Here’s a running list of things I’ve stumbled across:

SymptomLikely cause / fix
cat /dev/serial0 shows nothingUART not freed. Confirm dtoverlay=disable-bt is in /boot/firmware/config.txt (not /boot/), hciuart disabled, rebooted. Check ls -l /dev/serial0 points to ttyAMA0.
cat /dev/serial0 shows garbled textBaud-rate mismatch (not a fault). Set the rate with stty -F /dev/serial0 <rate> raw and re-read; use the working rate in gpsd’s -s.
Silent serial on Trixie only, same HW worked on BookwormSuspected Trixie UART/PL011 regression. Diagnose: pinctrl poll 14-15 &, then trigger GPS data — if GPIO15 (RX) never changes level, no bytes are reaching the pin (a kernel issue, not your config). Try linux-modules-extra-raspi, pin an older kernel, or fall back to Bookworm.
ppstest prints nothingNo fix yet, or wrong PPS pin. Give it sky view; verify gpiopin= (4 vs 18).
chrony PPS stuck at #?GPS/NMEA offset > 200 ms → falseticker. Calibrate the SHM 0 offset (Part 7d); make sure /dev/pps0 is in gpsd DEVICES and /run/chrony.pps0.sock exists.
Dashboard “Waiting for data…”Daemon can’t reach gpsd. Check systemctl status timeserver-dashboard and that gpsd is up on :2947.
Dashboard returns 404WorkingDirectory in the unit doesn’t point at the project root, so static/index.html isn’t found. Fix the path, daemon-reload, restart.
Daemon exits with AddrInUseThe port’s taken (e.g. Docker on 8080). Set Environment=PORT=8088 (or another free port); sudo ss -ltnp 'sport = :8080' shows the culprit.
/satinfo shows enabled:falseSATINFO_ENABLED not set/true, or daemon-reload skipped after editing the unit. systemctl show … -p Environment to verify.
/satinfo enabled:true but count:0NAVCEN fetch/parse failed (check the error field and the journal). Network blip → it retries within an hour; persistent → check outbound HTTPS.
cargo: command not foundPATH not loaded: source "$HOME/.cargo/env". If ~/.cargo/bin/cargo is missing, re-run the rustup installer.
cargo build → linker 'cc' not foundsudo apt install -y build-essential, then rebuild.
Clients can’t syncThe allow subnet in chrony.conf has to match your LAN; open UDP/123 if you run a firewall.
Satellites look frozen on the globeExpected! GPS is MEO (~2 orbits/day) and moves only gradually in az/el; the receiver also updates angles slowly. Check back in 20–30 min and the pattern will have shifted.

What you end up with

So where does all this leave you? Two things:

  • A stratum-1 NTP server disciplined by GPS + PPS, accurate to the microsecond or better, serving your whole LAN with no internet dependency. Every device on my network now sets its clock from a little box in my attic pulling time straight out of the sky, and I find that quietly delightful.
  • A live dashboard showing the calculated time, full gpsd + chrony stats with hover explanations, an interactive 3D constellation globe with per-satellite detail, and a sky view — all fed by the same hardware over SSE, with a clean JSON contract you can extend however you like.

If you want to keep tinkering, there are really just two files worth revisiting: src/main.rs (where Telemetry::to_json() defines the data contract) and static/index.html (METRIC_HELP, GPS_SAT_DB, and the render functions). Everything I’d want to customize lives in one of those two places.

If you made it this far, congrats on your new clock! Your clock is now more accurate than it has any reason to be!


Appendix A — Debian 12 (Bookworm) vs Debian 13 (Trixie)

Both work; most steps are identical. I verified everything on Debian 12 & 13 exactly as written above.

TopicDebian 12 / BookwormDebian 13 / Trixie
Boot config path/boot/firmware/config.txtSame
disable-bt / pps-gpio overlaysSameSame
Serial device/dev/serial0 → ttyAMA0 (always prefer serial0)Same
systemctl disable hciuartUsually succeedsOften Unit … does not exist — harmless
GPIO UART receiving GPSReliableRegression risk (mid-2026): no RX bytes despite correct config. Diagnose with pinctrl poll 14-15; may need linux-modules-extra-raspi, an older kernel, or Bookworm
gpsd / chronyStock; config as writtenNewer; same config syntax, chronyc -c tracking CSV unchanged
RecommendationPreferred for a time serverWorks, but verify the serial link early

Remember: serial baud rate is a property of your GPS module, not the OS — it’s handled identically on both.

Appendix B — Environment variables (quick reference)

Daemon (systemd Environment= or shell):

VariableDefaultNotes
PORT8080Bind port.
LISTEN_ADDR(unset)Full host:port; overrides PORT.
SATINFO_ENABLEDoff1/true/yes/on to enable NAVCEN fetch. Off = no outbound calls.
SATINFO_INTERVAL_HOURS168Refresh cadence (weekly).
SATINFO_URLNAVCEN YUMASource override.

Dashboard (static/index.html constants):

ConstantDefaultNotes
USE_SIMULATIONfalsetrue = fake data for previewing.
SATINFO_MODE'daemon'daemon / offline / celestrak / off.

Appendix C — Licensing & data sources

Not legal advice. For personal, non-commercial use you’re clear on all of the below.

  • GPS orbital/almanac data is US-government, public-domain. NAVCEN (US Coast Guard) serves it with no account and is redistribution-safe — the recommended live source here.
  • CelesTrak (an alternative live source) redistributes the same government data and adds convenient PRN/block labeling. Respect their access etiquette: cache results and don’t poll more than roughly once every two hours; cite CelesTrak; note that it isn’t an official source. Commercial redistribution is a “contact them first” situation.
  • The dashboard’s built-in reference table is factual data (which PRN, block, launch year, manufacturer) assembled from public records; facts aren’t copyrightable.
  • For anything commercial or internet-facing at scale using, review each source’s current terms directly.

Headless Raspberry Pi Image based on Debian Trixie (Like Raspbian Lite)

I’m in the process on repurposing a few Raspberry Pis and noticed Debian 13 has officially been released (at time of writing), but Raspberry Pi OS is still on Debian 12.

If you’re looking to create a custom Raspberry Pi OS image tailored for headless deployment, this guide walks you through using the official pi-gen tool to build your own image — based on Debian Trixie (Debian 13).

While I’ve tried to write this document in the context of a how-to, it’s more of a reflection on what I did vs something formal.

⚠️ Disclaimer: Since this image is not built or maintained by the Raspberry Pi Foundation, you may encounter compatibility issues, hardware quirks, or missing Raspberry Pi-specific features. Official support for this setup is limited, and it’s best suited for experimentation or advanced use cases. I don’t work for the Raspberry Pi project, so follow at your own risk.

That all being said, here’s what following this page would yield:

  • Uses the same tool the Raspberry Pi Foundation uses to generate official images
  • Builds a minimal system similar to Raspberry Pi OS Lite
  • Builds an Arm64 image (not 32-bit)
  • Has SSH enabled
  • Supports customization of locale, timezone, and user credentials

✅ Why Build Your Own Image?

You may want a custom image when:

  • You need headless deployment out of the box (no keyboard/mouse/monitor)
  • You want SSH access enabled by default
  • You want to preconfigure locales or packages for embedded or production use

🔧 Prerequisites

To follow this guide, it assumes usage of a few linux utilities / command. It assumes you are using and have available:

  • A macOS (Apple Silicon) or Linux machine
  • git, docker, and optionally qemu-user-static (only needed for x86)
  • ~10–20GB of free disk space

🧰 Step 1: Clone pi-gen

git clone --branch arm64 https://github.com/RPi-Distro/pi-gen.git
cd pi-gen

From the docs at time of this article:

Note: 32 bit images should be built from the master branch. 64 bit images should be built from the arm64 branch.

Tip: They have different branches based on debian OS version you want to target.


🧾 Step 2: Create the config File

You can define a file to customize that tells the image how to be customized out of box. Since we are a headless setup, we don’t want to have to connect a monitor, keyboard, mouse, etc; so this file will define how the operating system should be customized on first startup.

Inside the pi-gen directory, create a file named config:

touch config

Then edit it with your preferred editor (nano, vim, VSCode, etc.):

Example config File

# Debian release to build (note, you should target the applicable branch when changing releases)
RELEASE=trixie

# Name of the image
IMG_NAME="raspios-trixie-arm64"

# Build 64-bit image
ARCH=arm64

# Enable SSH by default
ENABLE_SSH=1

# Set default username and password
FIRST_USER_NAME="pi"
FIRST_USER_PASS="raspberry"

# Don't compress the image – output raw .img file
DEPLOY_COMPRESSION=none

# Set timezone to UTC+0
TIMEZONE_DEFAULT="Etc/UTC"

# Don't use QEMU (set to 1 if not on Arm based architecture)
USE_QEMU=0


🌍 Locales, Keyboard, and Timezone

To change the system locale or timezone, you can add these optional variables:

# Locale
LOCALE_DEFAULT="en_US.UTF-8"

# Keyboard layout
KEYBOARD_KEYMAP="us"
KEYBOARD_LAYOUT="English (US)"

# Timezone (examples)
TIMEZONE_DEFAULT="Etc/UTC"       # UTC+0
TIMEZONE_DEFAULT="America/New_York"
TIMEZONE_DEFAULT="Europe/London"
TIMEZONE_DEFAULT="Asia/Tokyo"

You can find all available timezones on a Linux system via:

ls /usr/share/zoneinfo

🏗️ Step 3: Build the Image

If you don’t want the desktop environment (headless setup), create the following skip files to eliminate creation of the full desktop images:

touch ./stage3/SKIP ./stage4/SKIP ./stage5/SKIP
touch ./stage4/SKIP_IMAGES ./stage5/SKIP_IMAGES

To start the build, run the following:

./build-docker.sh

This will:

  • Pull the necessary Docker images
  • Run through the build stages (stage0 to stage2 (assuming you created the skips))
  • Output a raw .img file(s) in the deploy/ folder

Example output without skip

copying log from container pigen_work to deploy/
total 35235816
drwxr-xr-x  10 jack  staff   320B Sep  6 11:58 .
drwxr-xr-x  23 jack  staff   736B Sep  6 11:57 ..
-rw-r--r--   1 jack  staff   8.3G Sep  6 11:57 2025-09-06-raspios-trixie-arm64-full.img
-rw-r--r--   1 jack  staff   245K Sep  6 11:57 2025-09-06-raspios-trixie-arm64-full.info
-rw-r--r--   1 jack  staff   2.6G Sep  6 11:56 2025-09-06-raspios-trixie-arm64-lite.img
-rw-r--r--   1 jack  staff    76K Sep  6 11:56 2025-09-06-raspios-trixie-arm64-lite.info
-rw-r--r--   1 jack  staff   5.8G Sep  6 11:56 2025-09-06-raspios-trixie-arm64.img
-rw-r--r--   1 jack  staff   213K Sep  6 11:56 2025-09-06-raspios-trixie-arm64.info
-rw-r--r--   1 jack  staff   1.0M Sep  6 11:58 build-docker.log
-rw-r--r--   1 jack  staff    10K Sep  6 11:57 build.log

Example output with the skip files

copying results from deploy/
copying log from container pigen_work to deploy/
total 5525296
drwxr-xr-x   6 jack  staff   192B Sep  6 12:03 .
drwxr-xr-x  23 jack  staff   736B Sep  6 12:03 ..
-rw-r--r--   1 jack  staff   2.6G Sep  6 12:03 2025-09-06-raspios-trixie-arm64-lite.img
-rw-r--r--   1 jack  staff    76K Sep  6 12:03 2025-09-06-raspios-trixie-arm64-lite.info
-rw-r--r--   1 jack  staff   308K Sep  6 12:03 build-docker.log
-rw-r--r--   1 jack  staff   5.8K Sep  6 12:03 build.log

💾 Step 4: Flash the Image to SD Card

After the build completes, write the image to an SD card. You can do this via command line or via the official Raspberry Pi Imager.

WARNING: make absolutely sure if you use the commands below you do not target the wrong disk. These commands can result in data loss. You do not hold me liable for data loss if using this approach!

On macOS or Linux:

  1. Identify the SD card:
diskutil list       # macOS
lsblk               # Linux
  1. Unmount the disk (macOS):
diskutil unmountDisk /dev/disk2
  1. Write the image:
sudo dd if=deploy/2025-09-06-raspios-trixie-arm64-lite.img of=/dev/rdisk5 bs=4m conv=fsync

⚠️ Replace /dev/rdisk5 with the correct device for your SD card.


🔌 Step 5: Boot the Pi

  • Insert the SD card into your Pi
  • Connect an Ethernet cable
  • Power on the Pi

You should:

  • See the Pi boot
  • Get a login prompt (no GUI)
  • Be able to SSH in using the IP address assigned via DHCP

🧩 Optional: Add SSH Key or Preseed Files

You can also preconfigure:

  • Your SSH public key
  • A custom hostname
  • Static IP
  • Additional packages

Let me know if you want a follow-up post for those.


✅ Recap

FeatureConfigured?
Debian Version✅ Trixie (Debian 13)
SSH Enabled✅ Yes
GUI/Desktop❌ No
Output Format✅ Raw .img
Build Host✅ Apple Silicon (no QEMU)
Locale / Timezone✅ UTC (editable)

🧭 Conclusion

By using pi-gen, you’re following the official image-building process used by the Raspberry Pi Foundation — but with full control over your system. This process can also be used for embedded projects, production environments, or anyone who needs a tightly configured headless system.

Configuring an MQTT broker for Home Assistant

I recently purchased a ratgdo device to replace MyQ’s kit for a local non-cloud dependent solution. ratgdo offers homekit, mqtt, and Control4, Nice/Elan, or Crestron integration. For this tutorial, I’m going to cover ratgdo and MQTT integration.

If you run Home Assistant as-is or are using their hardware, you can easily setup an MQTT broker by navigating to Add-Ons and installing the MQTT Broker, however in the past I have written articles on Home Assistant and Z-Wave JS as separate containers, so I wanted to follow the same concept by running the MQTT broker as a container as well.

Across the board, the consensus seems to be that most people are running Mosquitto as an MQTT broker, so here is how you can get that setup as a container.

  1. Download the docker image for Mosquitto
docker pull eclipse-mosquitto

2. Create directories for Mosquitto’s config and data files. If desired, you can create one for logs as well, but I’m ok not persisting that.

mkdir /home/docker/mosquitto/
mkdir /home/docker/mosquitto/config
mkdir /home/docker/mosquitto/data

3. Create a configuration file for mosquitto. This file will configure what ports MQTT data should be listed on as well as its corresponding port for receiving data via WebSocket. In addition, we will define where data should be stored, and require authentication to be able to connect. For now, leave the password file, which contains the username/password combo for who can authenticate.

vi /home/docker/mosquitto/config/mosquitto.conf

Press i to enter insert mode and paste the following:

listener 1883 0.0.0.0
listener 9001 0.0.0.0
protocol websockets
persistence true
persistence_file mosquitto.db
persistence_location /mosquitto/data/
allow_anonymous false
#password_file /mosquitto/config/passwd

Type !wq to save and quit.

4. Start the mosquitto container. We’ll map both the mqtt and websocket ports and volumes for config and data to persist.

docker run -d --restart=always --name="mosquitto-mqtt" -p 1883:1883 -p 9001:9001 -v /home/docker/mosquitto/config:/mosquitto/config -v /home/docker/mosquitto/data:/mosquitto/data eclipse-mosquitto

5. Launch shell on the container

docker exec -it -u 1883 mosquitto-mqtt sh

6. Use the mosquitto_passwd utility to generate an encrypted username and password. An ask for the password will prompt once you run the command. Type exit to return back to your local terminal.

mosquitto_passwd -c /mosquitto/config/passwd mqtt-user
exit

7. Modify your mosquitto.conf file.

vi /home/docker/mosquitto/config/mosquitto.conf

Uncomment the password file by removing the # sign and then type !wq to save and quit.

8. Restart the container so mosquitto will pickup the username/password

docker restart mosquitto-mqtt

At this point, your mqtt broker service should be up and ready! If you’d like to test connectivity and authentication, download a copy of MQTT MQTT Explorer | An all-round MQTT client that provides a structured topic overview (mqtt-explorer.com)

Set out of office / autoreply for distribution list for Exchange online

One thing that is a bummer is Exchange online does not support setting an autoreply / out of office message for a distribution list. Usually if you want such functionality, you’d convert the distribution list to a shared mailbox and configure the autoreply or use a 3rd party utility, or potentially come up with some complex transform rule.

Solution

One workaround you can apply is to enable out of office / autoreply messages from recipients in the distribution list. By default, Exchange Online will suppress autoreply messages when going to a distribution list, but you can quickly configure the behavior to allow the messages per distribution list.

Steps

  1. Install Exchange Online PowerShell module
    • Open PowerShell as an administrator and execute the following command:
      Install-Module exchangeonlinemanagement
  2. Import the module for use
    • Import-Module ExchangeOnlineManagement
  3. Login to Exchange online
  4. Configure the distribution list to allow the out of office / autoreply messages to be returned to the sender / originator.
    Set-DistributionGroup -identity [email protected] -SendOofMessageToOriginatorEnabled $true

Result

Now when someone emails the distribution list, they will receive an out of office / autoreply if configured by an individual member. Note, if multiple members have the autoreply configured, the sender/originator will receive multiple replies.

How to generate a large files for testing

You can generate large files for testing on both Linux and Windows machines without having to leverage a 3rd party utility.

Windows

In Windows, you can use the fsutil utility to create a new file with a defined number of bytes. In this case, the following command will generate a 1 GB file. The contents of the file will consist of spaces.

fsutil file createnew "C:\Users\<username>\Desktop\sample.txt" 1073741824

Linux

In Linux, you can use the dd utility. In this case, this command will create a 1 GB file filled with 0s. The bs parameter is the block size and count is the number of blocks to create.

dd if=/dev/zero of=testFile_dd bs=512M count=2

Configuring DKIM for Postfix

Fighting spam can be tricky. In addition to SPF records, DKIM is nearly mandatory to help prevent sent emails from being classified as spam. Beginning February of 2024, both Google and Yahoo will require DMARC, which require either SPF or DKIM; and in some cases for a high volume of emails (5,000+), both.

In this tutorial, we will look at signed outbound messages with DKIM via use of the open source project OpenDKIM. If you followed my previous tutorial on Postfix + Dovecot + Mysql/MaraiDB, you may have multiple domain names, so this guide will assume you will want to configure separate DKIM keys for each domain name you are hosting.

Step 1: Install OpenDKIM

First, update packages for your distribution.

sudo apt-get update && sudo apt-get upgrade

Install OpenDKIM and OpenDKIM tools. OpenDKIM-tools has a utility to generate the keys we will use.

sudo apt-get install opendkim opendkim-tools

Step 2: Created trusted hosts configuration file for OpenDKIM

First, create a file that OpenDKIM will use that defines the trusted hosts that can send messages.

sudo mkdir /etc/opendkim
sudo vi /etc/opendkim/TrustedHosts

Add the IP addresses and fqdn of the server sending messages by typing i to change into insert mode in vi.

127.0.0.1
localhost
192.168.1.2
mail.mydomain.com

Type :wq to commit the changes in vi.

Step 3: Modify OpenDKIM configuration file

Modify the opendkim.conf configuration file

sudo vi /etc/opendkim.conf

Search for #Canonicalization simple and uncomment the line by removing the # symbol.

Search for #Mode and remove the # symbol to uncomment the line. Ensure the line is configured with s for signing outbound emails or sv for verifying dkim keys on sent and received emails.

If you have subdomains, search for #SubDomains and remove the # and change to yes. For example:

SubDomains              yes

Search for Socket local:/var/run/opendkim/opendkim.sock and comment the line by adding a # in front of the line.

Search for #Socket inet:8891t@localhost and uncomment the line. If the line does not exist in your document, then add the following at the end of your document.

Socket inet:8891@localhost

Next, add the following lines to reference our DKIM configurations for each domain:

KeyTable /etc/opendkim/KeyTable
SigningTable /etc/opendkim/SigningTable
ExternalIgnoreList /etc/opendkim/TrustedHosts
InternalHosts /etc/opendkim/TrustedHosts

Type :wq to save the change and close the file

Step 4: Configure Postfix

sudo vi /etc/postfix/main.cf

Add the following lines to the end of the file:

milter_default_action = accept
milter_protocol = 2
smtpd_milters = inet:localhost:8891
non_smtpd_milters = inet:localhost:8891

Type :wq to save the changes and close the file.

Step 5: Restart services to use the changes (optional)

Execute the following to apply the changes if you wish to add domain names at a later time. If not, you can skip this step.

sudo /etc/init.d/opendkim restart
sudo /etc/init.d/postfix reload
sudo /etc/init.d/postfix restart

Step 5/6: Create a DKIM key for a domain

Run the following command to create a new folder and change directory to it for where we will generate our key used to sign the outgoing emails.

sudo mkdir -p /etc/opendkim/keys/mydomain.com
cd /etc/opendkim/keys/mydomain.com

Execute the following command to generate the key:

sudo opendkim-genkey -r -d mydomain.com

Delegate access to the opendkim user and group to access the key (note, if you modified the user in your opendkim.conf file, you will want to use that instead)

sudo chown opendkim:opendkim default.private

Step 7: Reference the key via OpenDKIM KeyTable

Modify the Keytable with vi

sudo vi /etc/opendkim/KeyTable

Add the following line to the file to define your selector. In this example, we will call the selector default, but if your domain requires multiple DKIM keys, ensure you make this unique. You can modify the file by pressing i to enter insert mode in vi:

default._domainkey.mydomain.com mydomain.com:default:/etc/opendkim/keys/mydomain.com/default.private

Type :wq to write and quite vi

Step 8: Specify the domain in your OpenDKIM SigningTable

Open the SigningTable file via vi

sudo vi /etc/opendkim/SigningTable

Add the following line to the file by pressing i to enter insert mode (changing default if specified a different selector earlier on):

mydomain.com default._domainkey.mydomain.com

Type :wq to write and quite vi

Step 8: Update your services to apply the changes

Restart your services to begin signing your messages:

sudo /etc/init.d/opendkim restart
sudo /etc/init.d/postfix reload
sudo /etc/init.d/postfix restart

Step 9: Update DNS

Get the DNS record values we need to publish by executing the following command:

sudo cat /etc/opendkim/keys/mydomain.com/default.txt

Create a new TXT record within your nameservers and specify the value between the quotes (don’t include the quotes). I.e.:

v=DKIM1; h=sha256; k=rsa; s=email; p=ABCDEFG.....

Note: I choose to update DNS last as once you update DNS, any servers that would receive mail before you apply the previous configuration may discard your emails. Then again, you didn’t have DKIM before, so you were probably going to junk mail anyways ;^)

Credits

Shoutout to Diego on stackoverflow, edoceo, and suenotek for consolidating a lot of these steps:
postfix – Using DKIM in my server for multiple domains (websites) – Ask Ubuntu

How To: Installing and Configuring OpenDKIM for multiple domains with Postfix on Linux | Edoceo

Roundcube mail app and SPF, DKIM & DMARC on Ubuntu 20.04 (suenotek.com)

How to generate a root certificate and create a self-signed server certificate issued from the root

This is going to be a quick tutorial, but here’s a quick way to generate a root certificate, server certificate, and bundle them together via pfx file. This can be useful to validate scenarios where a certificate chain is required. For this tutorial, we’ll be using the openssl utility, which can be freely downloaded here: Win32/Win64 OpenSSL Installer for Windows – Shining Light Productions (slproweb.com)

Generate the Root Certificate

Execute the following command to generate a key for the root certificate:

openssl ecparam -out root.key -name prime256v1 -genkey

Execute the following command to generate a certificate signing request. Note: During this step, you will be prompted to specify several certificate attributes; for the common name, you can specify the name you’d like as the issuer (i.e. MyCorp)

openssl req -new -sha256 -key root.key -out root.csr

Execute the following to generate the public certificate. During this step, you’ll specify the validity of the root certificate (you may want this to be longer than 365 days as the root).

openssl x509 -req -sha256 -days 3650 -in root.csr -signkey root.key -out root.crt

Generate the Server Certificate

Execute the following command to generate a private key for the server certificate:

openssl ecparam -out server-cert.key -name prime256v1 -genkey

Execute the following command to generate a certificate signing request. Note: During this step, you will be prompted to specify several certificate attributes; for the common name, specify the FQDN to your server. You do not need to start the value of the common name with CN=

openssl req -new -sha256 -key server-cert.key -out server-cert.csr

Execute the following command to generate the public certificate for the server certificate. During this step, you’ll specify the validity of the server certificate. Generally speaking, the validity of this certificate would be much shorter than your root.

openssl x509 -req -in server-cert.csr -CA root.crt -CAkey root.key -CAcreateserial -out server-cert.crt -days 365 -sha256

Verify certificate chain

Optionally, you can verify the issuer or expiry dates of the server certificate is correct via the following command:

openssl x509 -in server-cert.crt -text -noout

Generate PFX from Root and Server certificate

Execute the following command to generate a PFX file containing the public and private keys of the server certificate as well as public key of the root certificate. Note, you will be prompted for a password for the PFX file, which can increase security when needing to move these sensitive files around.

openssl pkcs12 -export -out mycert.pfx -inkey server-cert.key -in server-cert.crt -certfile root.crt

Using SDRplay RSPduo with RTLSDR-Airplay and a RaspberryPi

One of the side projects I have is rebroadcasting local ATC (Air Traffic Control) audio from my local airport to LiveATC.net. I previously had an RTL-SDR dongle connected to a RaspberryPi 1 Model B, which then rebroadcasted to LiveATC via IceCast. While I’ve had success the past few years broadcasting, overhead plans were really the only thing that was clear; being distant from the airport, receiving broadcasts from the tower were a slim to none at best.

In doing a bit of research I settled on purchasing a SDRplay RSPduo and Raspberry Pi 4, which seems to help with noise. Pairing the SDRplay with the newest version of RTLSDR-Airplay, I was able to achieve much clearer audio/hear things I couldn’t before. While I’m using the SDRplay RSPduo, this guide can be used for their other devices such as the Rsp1a and RSPdx as well (likely others as this guide ages). Here’s a reflection on how I got things setup.

Update Raspbian packages

First, update your Linux packages to latest version. I’m running the latest version of Raspbian / Debian.

sudo apt-get update && sudo apt-get upgrade

Disable WiFi/Bluetooth

This is optional, but I figured I’d disable the radios on the RaspberryPi to further mitigate as much possible noise as possible. First, you can disable both radios by editing /boot/config.txt via the vi text editor (this can actually be configured by placing this file on your SD-Card you attach to your Raspberry Pi during first-time boot). Official details on the boot overlays can be found here.

sudo vi /boot/config.txt

Once in vi, press i to insert the following lines:

dtoverlay=disable-bt
dtoverlay=disable-wifi

Press the escape key and then type :wq to write the changes to the file and exit vi.

Lastly, execute the following command to disable the UART bluetooth service.

sudo systemctl disable hciuart

Download & Install RSP Control Library + Driver

First, you will want to grab the latest SDRplay Drivers and Libraries. You can do this by navigating to SDRplay’s website and selecting RSPduo and ARM Raspberry Pi OS for the download. Then click the API button. Now this is kinda difficult if you are SSHed into the Pi, so I’d find the latest version from their website and then use the following commands below to remotely download the software (substituting in the version number to grab the latest download) and install it and reboot after install (rebooting after installation is strongly recommended).

Execute the following commands:

# Navigate to home directory
cd ~
# Download latest API Library + Driver
wget https://www.sdrplay.com/software/SDRplay_RSP_API-Linux-3.15.2.run
# Provide execution rights to install the software
chmod a+x SDRplay_RSP_API-Linux-3.15.2.run
# Run the installer
sudo ./SDRplay_RSP_API-Linux-3.15.2.run
# Reboot the machine
sudo reboot now

Build and install SoapySDR from source

In this section, we need to install SoapySDR which is a vendor and platform neutral SDR support library. Essentially this means that instead of needing a bunch of developers to write integrations into all the different SDRs, other software can leverage these interfaces to skip worrying about device compatibility and focus on what the application needs to do. As we’ll see later, RTLSDR-Airband does exactly this to provide support for tons of different SDRs. Kudos to the PothosWare team for enabling developers all over the world to build all sorts of SDR projects!

So, to get this installed, we need to clone the source code from their GitHub repo and compile the project. Official documentation on this process can be found on their wiki, but I’m going to try and simplify everything here.

Since Raspberry Pi doesn’t come with Git, I am going to use wget and unzip to do this, but if you don’t mind installing Git, that’d be the easier way to “clone” down the latest source code from GitHub (make sure you replace versions where appropriate, at time of writing this, 0.8.1 is the latest version).

# Install dependencies needed to build this project
sudo apt-get install cmake g++ libpython-dev python-numpy swig
# Make sure we are back in our home directory
cd ~
# Grab latest tarball from GitHub
wget -O soapy-sdr-0.8.1.tar.gz https://github.com/pothosware/SoapySDR/archive/soapy-sdr-0.8.1.tar.gz
# Extract the tarball (this is like unzipping a .zip on Windows)
tar xvfz soapy-sdr-0.8.1.tar.gz

# Change directories into the new SoapSDR folder
cd SoapySDR-soapy-sdr-0.8.1

# Make a new folder called build
mkdir build

# Change directories into the build folder
cd build

# Execute cmake build automation
cmake ..

# Make installer (-j4 parameter increases build threads to make compilation quicker)
make -j4
# Make the installer copy files to right locations
sudo make install
sudo ldconfig #needed on debian systems
# Navigate back to home directory
cd ~
# Delete the SoapySDR folder since we are done with it
sudo rm -R SoapySDR-soapy-sdr-0.8.1

At this point, you should be able to execute the SoapySDRUtil command and see the version you installed.

SoapySDRUtil --info

You should get something like this (note: you will likely see a missing module; that’s ok for right now!):

Build and install SoapySDR Play module from source

Now that we have SoapySDR installed, we need to install the module to allow it to control the SDRplay device. Similiar to SoapySDR install, we’ll pull down the latest files from the SoapySDR Play Module GitHub repo, build the installer, execute it, and verify that all went well. Official instructions can be found on their wiki as well.

# Make sure we are back in our home directory
cd ~
# Grab latest tarball from GitHub
wget -O SoapySDRPlay.zip https://github.com/pothosware/SoapySDRPlay3/archive/refs/heads/master.zip
# Unzip the archive
unzip SoapySDRPlay.zip
# Change directories into the new SoapSDR folder
cd SoapySDRPlay3-master
# Make a new folder called build
mkdir build
# Change directories into the build folder
cd build
# Execute cmake build automation
cmake ..
# Make installer
make
# Make the installer copy files to right locations
sudo make install
sudo ldconfig #needed on debian systems
# Navigate back to home directory
cd ~
# Delete the SoapySDR folder since we are done with it
rm -R SoapySDRPlay3-master

Plug in SDRplay RSPduo device and verify we see it

If you haven’t already, go ahead and plug in your SDRplay RSPduo. Next, let’s verify we see it using the SopaySDRUtil command.

SoapySDRUtil --probe="driver=sdrplay"

You should see something like this and you should see your device and hardware version (note the hardware hardware= value as you may need that later). In addition, one thing that is neat about the RSPduo is there are multiple tuners/antennas. You will be able to see these values in the probe output. Once you enable RTLSDR-Airplay, you’ll notice active antennas are removed from the list of available devices.


pi@raspberrypi:~ $ SoapySDRUtil --probe="driver=sdrplay"
######################################################
##     Soapy SDR -- the SDR abstraction library     ##
######################################################

Probe device driver=sdrplay
[INFO] devIdx: 0
[INFO] hwVer: 3
[INFO] rspDuoMode: 1
[INFO] tuner: 1
[INFO] rspDuoSampleFreq: 0.000000

----------------------------------------------------
-- Device identification
----------------------------------------------------
  driver=SDRplay
  hardware=RSPduo
  sdrplay_api_api_version=3.070000
  sdrplay_api_hw_version=3

----------------------------------------------------
-- Peripheral summary
----------------------------------------------------
  Channels: 1 Rx, 0 Tx
  Timestamps: NO
  Other Settings:
     * RF Gain Select - RF Gain Select
       [key=rfgain_sel, default=4, type=string, options=(0, 1, 2, 3, 4, 5, 6, 7, 8, 9)]
     * IQ Correction - IQ Correction Control
       [key=iqcorr_ctrl, default=true, type=bool]
     * AGC Setpoint - AGC Setpoint (dBfs)
       [key=agc_setpoint, default=-30, type=int, range=[-60, 0]]
     * ExtRef Enable - External Reference Control
       [key=extref_ctrl, default=true, type=bool]
     * BiasT Enable - BiasT Control
       [key=biasT_ctrl, default=true, type=bool]
     * RfNotch Enable - RF Notch Filter Control
       [key=rfnotch_ctrl, default=true, type=bool]
     * DabNotch Enable - DAB Notch Filter Control
       [key=dabnotch_ctrl, default=true, type=bool]

----------------------------------------------------
-- RX Channel 0
----------------------------------------------------
  Full-duplex: NO
  Supports AGC: YES
  Stream formats: CS16, CF32
  Native format: CS16 [full-scale=32767]
  Antennas: Tuner 1 50 ohm, Tuner 1 Hi-Z, Tuner 2 50 ohm
  Corrections: DC removal
  Full gain range: [0, 48] dB
    IFGR gain range: [20, 59] dB
    RFGR gain range: [0, 9] dB
  Full freq range: [0.001, 2000] MHz
    RF freq range: [0.001, 2000] MHz
    CORR freq range:  MHz
  Sample rates: 0.0625, 0.096, 0.125, 0.192, 0.25, ..., 6, 7, 8, 9, 10 MSps
  Filter bandwidths: 0.2, 0.3, 0.6, 1.536, 5, 6, 7, 8 MHz

Build and install RTLSDR-Airband from source

RTLSDR-Airband is an open source project that allows you to receive analog radio voice channels and produce audio streams which can be routed to various outputs, such as online streaming via Icecast server, PulseAudio server, Audio file, or Raw I/Q file. In our case, we are going to stream to an Icecast server in this example.

Similar to our previous section in SoapySDR, we need to download the latest source code, build and install RTLSDR, and then modify the configuration file. Official documentation can be found on the RTLSDR-Airplay GitHub Wiki.

# Install RTLSDR-Airplay dependencies
sudo apt-get install build-essential cmake pkg-config libmp3lame-dev libshout3-dev 'libconfig++-dev' libraspberrypi-dev libfftw3-dev
# Navigate back to our home directory
cd ~
# Download the latest source from GitHub
wget -O v5.1.1.tar.gz https://github.com/rtl-airband/RTLSDR-Airband/archive/refs/tags/v5.1.1.tar.gz
# Extract the tarball
tar xvfz v5.1.1.tar.gz
# Change directory into the RTLSDR-Airband folder
cd RTLSDR-Airband-5.1.1
mkdir build
cd build
cmake ../
make
# Install the program
sudo make install

Configure RTLSDR-Airband

For my particular setup, I want to stream to an external icecast server. To do that, I recommend creating a backup of the default configuration file (as a backup).

# Rename the original config file as a backup
sudo mv /usr/local/etc/rtl_airband.conf /usr/local/etc/rtl_airband.conf.bak

Next, we can create a new configuration file with the proper configuration. Execute the following command to open vi.

sudo vi /usr/local/etc/rtl_airband.conf

Press i to go into insert mode and paste the following (replacing the values applicable to your environment; you may want to change the name of the stream, authentication parameters, and gain”). Also, note that we are using the first Antenna and specifying the hardware version of RSPduo from the previous step where we probed the SDRplay device (if you have a different SDRplay device, substitute that value accordingly).

# Configure IceCast Stream
devices:
({
  type = "soapysdr";
  index = 0;
  device_string = "driver=sdrplay,hardware=RSPduo";
  channel = 0;
  gain = 35;
  correction = 1;
  antenna = "Tuner 1 50 ohm";
  mode = "scan";
  channels: ( 
    {
      freqs = ( 133.8 );
      outputs: ( 
          {
             type = "icecast";
             server = "audio-in.myicecastserver.net";
             port = 8010;
             mountpoint = "station"
             username = "username"
             password = "mypassword";
             name = "Tower";
             description = "Tower - 133.8Mhz";
             genre = "ATC";
          }
       );
    }
 );
});

Type :wq to write and save the changes to the file.

Validate RTLSDR-Airband Configuration

Once you have your configuration, you can validate everything is ready to go by running RTLSDR-Airplay in foreground mode.

From their wiki: you will see simple text waterfalls, one per each configured channel. This is an example for three devices running in multichannel mode. The meaning of the fields is as follows:

User interface screenshot
  • The number at the top of each waterfall is the channel frequency. When running in scan mode, this will be the first one from the list of frequencies to scan.
  • The number before the forward slash is the current signal level
  • The number after the forward slash is the current noise level estimate.
  • If there is an asterisk * after the second number, it means the squelch is currently open.
  • If there is a > or < character after the second number, it means AFC has been configured and is currently correcting the frequency in the respective direction.

Execute the following command to start running in foreground mode:

# Test in foreground mode
/usr/local/bin/rtl_airband -f

Press Cntrl+C to break out of the stream once you are satisfied with your testing.

Enable RTLSDR-Airband to autostart

To enable RTLSDR-Airband to automatically start up each time your Raspberry Pi is rebooted, you can execute the following commands from within the RTLSDR-Airband directory.

sudo cp init.d/rtl_airband.service /etc/systemd/system
sudo chown root:root /etc/systemd/system/rtl_airband.service
sudo systemctl daemon-reload
sudo systemctl enable rtl_airband
sudo systemctl start rtl_airband

Hurray! We are done!

If you made it this far you have completed all the steps! Enjoy your new streaming SDR solution!