Setting Up a Direct Connect Wireguard Tunnel with Cloudflare DDNS and IPv6

2024-07-11

8 min read

In my Build-a-Router series, I touched on setting up a Wireguard tunnel via a VPS relay. While this works, it's not ideal. The speed is limited by the slowest link along the path, and using a VPS adds additional hops.

But the biggest drawback for me is increased latency. Packets first go to the VPS before reaching the router, and responses follow the same path back. If I'm far from home, 15ms added latency might not matter, but most of the time I am just within 15 miles from home, so the relay essentially doubles the latency. You might say 30 ms and 15 ms don't make much of a difference, but I won't be a nerd or a homelabber if I am easily satisfied. So let's build a direct connect tunnel!

Overview

Undoubtedly we will use IPv6 for direct connection. It's the future of the Internet.

Even if I'd love to also set up IPv4 direct connection, I couldn't do UDP punching before bringing up Wireguard, at least not without writing a somewhat complicated piece of software. If you're looking for that, Tailscale is a great option. Remember to use Headscale instead of the official handshake server, or you are not a true selfhoster!

First, let's build a DDNS service. Any existing DDNS service will work, but I would use Cloudflare API to update a DNS record since I own a domain.

  1. Create a Cloudflare token with access to Zone.DNS for your domain.
  2. Create an AAAA-only record for the DDNS subdomain, e.g. ddns-deadbeef.tongkl.com, pointing to any address. The domain will be updated programatically, but since there's no put_or_update-like API from Cloudflare, I find it easier to just create a stub entry and update it.
  3. Lastly, use a script to monitor any IP address changes and update the DNS record to a subdomain. The script should listen to any IP changes, and update the record as soon as the changes happen if possible. One common approach people on Reddit adopt is simply a cron job running every 30 minutes. But I decided to use ip monitor to listen to IP address changes.
# /usr/local/bin/ddns-update.sh

#!/bin/bash

# Variables
INTERFACE="br0"  # The LAN interface of my router gets an IPv6 GUA instead of the WAN interface
DOMAIN="ddns-deadbeef.tongkl.com"
CLOUDFLARE_API_URL="https://api.cloudflare.com/client/v4/zones/deadbeef/dns_records/deadbeef"
CLOUDFLARE_API_TOKEN="deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"

# Function to check if an address is a GUA
is_gua() {
    local ip=$1
    if [[ $ip =~ ^2[0-9a-fA-F]{3}:|^3[0-9a-fA-F]{3}: ]]; then
        return 0
    else
        return 1
    fi
}

# Function to get the current GUA for the interface
get_current_gua() {
    ip -6 addr show $INTERFACE | grep "inet6" | awk '{print $2}' | cut -d/ -f1 | while read -r ip; do
        if is_gua "$ip"; then
            echo "$ip"
            return
        fi
    done
}

# Function to get the current DNS AAAA record
get_current_dns_aaaa() {
    dig +short AAAA $DOMAIN
}

# Function to update the Cloudflare DNS record
update_dns_record() {
    local new_ip=$1
    curl -X PUT "$CLOUDFLARE_API_URL" \
        -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
        -H "Content-Type: application/json" \
        --data '{ "type": "AAAA", "name": "'$DOMAIN'", "content": "'"$new_ip"'", "ttl": 600, "proxied": false }'
}

# Check initial GUA
current_gua=$(get_current_gua)

if [[ -n "$current_gua" ]]; then
    dns_ip=$(get_current_dns_aaaa)
    if [[ "$current_gua" != "$dns_ip" ]]; then
        update_dns_record "$current_gua"
    fi
fi

# Monitor for IP address changes
ip monitor address | while read -r line; do
    if echo "$line" | grep -q "$INTERFACE"; then
        new_gua=$(get_current_gua)
        if [[ -n "$new_gua" && "$new_gua" != "$current_gua" ]]; then
            current_gua=$new_gua
            dns_ip=$(get_current_dns_aaaa)
            if [[ "$current_gua" != "$dns_ip" ]]; then
                update_dns_record "$current_gua"
            fi
        fi
    fi
done

Proceed to set up the script as a daemon:

# /etc/systemd/system/ddns-update.service

[Unit]
Description=Dynamic DNS Update Service
After=network.target

[Service]
ExecStart=/usr/local/bin/ddns-update.sh

[Install]
WantedBy=default.target

Enable and start the service:

$ sudo chmod +x /usr/local/bin/ddns-update.sh
$ sudo systemctl enable ddns-update
$ sudo systemctl start ddns-update

Now I can see my home IPv6 address with nslookup. The update is updated instantly upon restart or periodic ISP prefix changes. Perfect DDNS solution!

Let's build the Wireguard tunnel.

My initial idea was to put two peers in my client devices: a direct connection first and a relay second. Wireguard attempts to connect to the first available peer, so I get direct connection whenever possible and a relay fallback. Great right?

Unfortunately it didn't work. Here's the reason.

Assume we are using the subnet fd12::/64 for the VPS relay tunnel. The VPS takes fd12::1, the router takes fd12::2, and the laptop client takes fd12::3. Let's say we have the VPS relay tunnel set up already. The configs for all 3 devices will look like this:

# VPS
[Interface]
Address = 192.168.222.1/24, fd12::1/64
PrivateKey = ...
ListenPort = 51820

[Peer]  # Router
PublicKey = ...
AllowedIPs = 192.168.222.2, fd12::2, <whatever subnet(s) your LAN is using>

[Peer]  # Laptop
PublicKey = ...
AllowedIPs= 192.168.222.3, fd12::3
# Router
[Interface]
Address = 192.168.222.2/24, fd12::2/64
PrivateKey = ...

[Peer]  # VPS
PublicKey = ...
AllowedIPs = 192.168.222.0/24, fd12::/64
Endpoint = <Public IP of VPS>
PersistentKeepAlive = 25
# Laptop
[Interface]
Address = 192.168.222.3/24, fd12::3/64
PrivateKey = ...

[Peer]  # VPS
PublicKey = ...
Endpoint = <Public IP of VPS>
AllowedIPs = 192.168.222.0/24, fd12::/64, <whatever subnet(s) your LAN is using>

The highlight here is that the router is forwarding all the fd12::/64 subnet traffic to the VPS. Now let's say we need to add a direct connection on top of this setting. We need to add a ListenPort directive to the router, along with... all the clients. So this is what the router config will end up like:

# Router
[Interface]
Address = 192.168.222.2/24, fd12::2/64
PrivateKey = ...
ListenPort = 51821

[Peer]  # VPS
PublicKey = ...
AllowedIPs = 192.168.222.0/24, fd12::/64
Endpoint = <Public IP of VPS>
PersistentKeepAlive = 25

[Peer]  # Laptop
PublicKey = ...
AllowedIPs= fd12::3  #  <-------- NOOOOOO

Can you see the problem? The direct connection will work, but the relay will break. It's because Wireguard always selects the most specific route to send packets to. If a fd12::3/128 route has been configured in the router, it won't send the traffic to the VPS following the fd12::/64 rule, even if the laptop is not connected to the router! So the client sends a ping packet via the relay tunnel, the VPS forwards the packet to the router, but the router sends the ping reply via the invalid route, causing the packet to be dropped. We will have to set up a separate tunnel for direct connection.

Let's assume we use 192.168.223.0/24 and fd13::/64 for the new tunnel. The new tunnel couldn't be easier because it's a direct connection wireguard setup.

# Router, /etc/wireguard/wg1.conf
[Interface]
Address = 192.168.223.1/24, fd13::1/64
ListenPort = 51822
PrivateKey = ...

PostUp = iptables -A FORWARD -i wg1 -o br0 -j ACCEPT
PostUp = iptables -A FORWARD -i br0 -o wg1 -j ACCEPT
PostUp = iptables -A FORWARD -i wg1 -o wg0 -j ACCEPT
PostUp = iptables -A FORWARD -i wg0 -o wg1 -j ACCEPT
PostUp = iptables -A FORWARD -i wg1 -o br0.3 -j ACCEPT
PostUp = iptables -A FORWARD -i br0.3 -o wg1 -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
PreDown = iptables -D FORWARD -i wg1 -o br0 -j ACCEPT
PreDown = iptables -D FORWARD -i br0 -o wg1 -j ACCEPT
PreDown = iptables -D FORWARD -i wg1 -o wg0 -j ACCEPT
PreDown = iptables -D FORWARD -i wg0 -o wg1 -j ACCEPT
PreDown = iptables -D FORWARD -i wg1 -o br0.3 -j ACCEPT
PreDown = iptables -D FORWARD -i br0.3 -o wg1 -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT

PostUp = ip6tables -A FORWARD -i wg1 -o br0 -j ACCEPT
PostUp = ip6tables -A FORWARD -i br0 -o wg1 -j ACCEPT
PostUp = ip6tables -A FORWARD -i wg0 -o br0.4 -j ACCEPT
PostUp = ip6tables -A FORWARD -i br0.4 -o wg0 -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
PostUp = ip6tables -A FORWARD -i wg1 -o wg0 -j ACCEPT
PostUp = ip6tables -A FORWARD -i wg0 -o wg1 -j ACCEPT
PostUp = ip6tables -I INPUT -i enp1s0 -p udp --dport 51822 -j ACCEPT
PreDown = ip6tables -D FORWARD -i wg1 -o br0 -j ACCEPT
PreDown = ip6tables -D FORWARD -i br0 -o wg1 -j ACCEPT
PreDown = ip6tables -D FORWARD -i wg0 -o br0.4 -j ACCEPT
PreDown = ip6tables -D FORWARD -i br0.4 -o wg0 -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
PreDown = ip6tables -D FORWARD -i wg1 -o wg0 -j ACCEPT
PreDown = ip6tables -D FORWARD -i wg0 -o wg1 -j ACCEPT
PreDown = ip6tables -D INPUT -i enp1s0 -p udp --dport 51822 -j ACCEPT

[Peer]
PublicKey = ...
AllowedIPs = 192.168.223.3/32, fd13::3

The iptables rules ensure the new tunnel can reach all VLANs in my home network. It also connects the two Wireguard tunnels, providing additional flexibility. I probably won't have any real use cases for this, but it provides some sense of achievement.

And the laptop config:

# Laptop
[Interface]
Address = 192.168.223.3/24, fd13::3/64
PrivateKey = ...

[Peer]  # Router
PublicKey = ...
Endpoint = ddns-deadbeef.tongkl.com:51822
AllowedIPs = 192.168.223.0/24, fd13::/64, <whatever subnet(s) your LAN is using>

And we are done. I have to occasionally switch between the two tunnels because sometimes I connect to a IPv4-only WiFi and the IPv6-only tunnel no longer works, but I am pretty satisfied with the ability to make direct connections.