Aidan Houck

It’s always DNS until it isn’t

Context

My home ISP gives me a dynamic IPv4 address / IPv6 prefix. This is OK for general internet usage but can get tricky when trying to self-host various services at home. In my case I use my own domain for email, but forward it through Gmail. This means that in order to have a legitimate SPF record I need to tell Google my home IP address is allowed to send emails from *@aidanhouck.com addresses.

Different solutions

Dynamic DNS is a relatively straightforward tool you can use to automate solving this problem. Typical DNS records will map a domain name to an IP address, such as one.one.one.one to 1.1.1.1:

aidan@DESKTOP:~$ dig +noall +answer one.one.one.one
one.one.one.one.        0       IN      A       1.1.1.1
one.one.one.one.        0       IN      A       1.0.0.1
The “dynamic” in “Dynamic DNS” is often a script, program, or service that will monitor your IP address (1.1.1.1) and automatically update your domain name one.one.one.one. There are lots of cool ways to do this, for example:

These all seem OK but don’t do exactly what I need. With the possible exception of ddclient which I did not find out about until halfway through finishing my script… At any rate, why use a working solution when you can spend 5x the time making your own?

Putting the DIY in DDNS

Below is the Bash script I created to solve my DDNS problems. It works with Porkbun’s API on a Debian machine. It also pipes output (successful and unsuccessful) to the local mail program which notifies you when your address has changed. You may want to disable this if you have an IP that changes excessively, but mine appears to be only every month or two so I like the extra info.

The first few revisions of this script were heavily inspired by Jason Burk’s blog post, but over time as I added things like state checking and IPv6 support it grew quite a bit.

Making API requests

First, define a function that we will use to actually send the requests to Porkbun over curl.

porkbun_request () {
curl \
--header "Content-type: application/json" \
--data '{"secretapikey":"'"$1"'","apikey":"'"$2"'","content":"'"$3"'"}' \
"$4"
}

Define constant variables

Then, we need to define a few constants. These include things like the domain we’re using and the subdomain we want to assign our public IP address to.

This is also where I’ve defined the file paths to my API key / API secret as well as the URL of the API endpoint. You can find more complete documentation for Porkbun’s API here but this script only really needs to use the one endpoint.

ddns_domain="ddns"
domain="aidanhouck.com"
domain_fqdn="${ddns_domain}.${domain}"

api_file="/opt/porkbun/.api"
secret_file="/opt/porkbun/.secret"

porkbun_base="https://api.porkbun.com/api/json/v3/dns/editByNameType/${domain}"

Fetch current DNS record values

Then I start to actually make requests. This is where I grab the following:
  1. My current IP addresses (output4/output6)
  2. The current A/AAAA record values of my WAN FQDN (test4/test6)
  3. The current SPF record value compared to expected (output_spf/test_spf)

output4="$(curl -s https://api.ipify.org | head -c -1)"
test4=$(dig +short ${domain_fqdn} a @1.1.1.1)

output6="$(curl -s https://api64.ipify.org | cut -d':' -f1-4 | head -c -3)00::1"
test6=$(dig +short ${domain_fqdn} aaaa @1.1.1.1)
output6_cidr="$output6/56"

output_spf="v=spf1 include:spf.improvmx.com ip4:$output4 ip6:$output6_cidr ~all"
test_spf=$(dig +short ${domain} txt @1.1.1.1 | tr -d '"')

Compare DNS values and make API requests

Finally, we compare each record type (A, AAAA, SPF) to see if the expected and real values match. If they do not we use the function defined earlier to send API requests and return the log/email the results.

API=$(<"$api_file")
SECRET=$(<"$secret_file")

if ! [ "$output4" = "$test4" ]; then
    echo "`date +"%b %d %H:%M%:S"` Attempting to update IPv4 DDNS from `hostname`"
        result=$(porkbun_request "$SECRET" "$API" "$output4" "${porkbun_base}/A/${ddns_domain}")
        printf "%s\n\n%s\n%s\n" "Tried changing ${test4} to ${output4}" "Result:" "$result" | tee /dev/stderr | /usr/bin/mail -s "`hostname` updated IPv4 DDNS" `hostname`@aidanhouck.com
fi

if ! [ "$output6" = "$test6" ]; then
    echo "`date +"%b %d %H:%M%:S"` Attempting to update IPv6 DDNS from `hostname`"
        result $(porkbun_request "$SECRET" "$API" "$output6" "${porkbun_base}/AAAA/${ddns_domain}")
        printf "%s\n\n%s\n%s\n" "Tried changing ${test6} to ${output6}" "Result:" "$result" | tee /dev/stderr | /usr/bin/mail -s "`hostname` updated IPv6 DDNS" `hostname`@aidanhouck.com
fi

if ! [ "$output_spf" = "$test_spf" ]; then
    echo "`date +"%b %d %H:%M%:S"` Attempting to update SPF DDNS from `hostname`"
        result=$(porkbun_request "$SECRET" "$API" "$output_spf" "${porkbun_base}/TXT")
        printf "%s\n\n%s\n%s\n" "Tried changing ${test_spf} to ${output_spf}" "Result:" "$result" | tee /dev/stderr | /usr/bin/mail -s "`hostname` updated SPF DDNS" `hostname`@aidanhouck.com
fi

Automating things with a Cronjob

I have two DNS containers both running Pi-hole and Unbound, so the script is added to a recurring Cronjob on both hosts. In crontab -e I have the following lines added:

# ON NS1
00 1,13 * * * "/opt/porkbun/ddns-update.sh" 2>&1 >> /var/log/syslog

# ON NS2
15 1,13 * * * "/opt/porkbun/ddns-update.sh" 2>&1 >> /var/log/syslog

This means that NS1 will always run at 0100/1300, while NS2 will run at 0115/1315. The primary DNS server will handle any legitimate updates 99% of the time, while the secondary is available to do updates if NS1 does not catch them for whatever reason.

Full Script

If you’d like the full script for ease of copy-paste you can find it in the spoiler below. Have a good day.
ddns-update.sh

#!/bin/bash

# A script to dynamically update porkbun DNS values with changing public IP address

# USAGE: porkbun_request SECRET API IP URL/TYPE/SUB
porkbun_request () {
curl \
--header "Content-type: application/json" \
--data '{"secretapikey":"'"$1"'","apikey":"'"$2"'","content":"'"$3"'"}' \
"$4"
}

# Declare vars
ddns_domain="ddns"
domain="aidanhouck.com"
domain_fqdn="${ddns_domain}.${domain}"

api_file="/opt/porkbun/.api"
secret_file="/opt/porkbun/.secret"

porkbun_base="https://api.porkbun.com/api/json/v3/dns/editByNameType/${domain}"

# Grab data
output4="$(curl -s https://api.ipify.org | head -c -1)"
test4=$(dig +short ${domain_fqdn} a @1.1.1.1)

output6="$(curl -s https://api64.ipify.org | cut -d':' -f1-4 | head -c -3)00::1"
test6=$(dig +short ${domain_fqdn} aaaa @1.1.1.1)
output6_cidr="$output6/56"

output_spf="v=spf1 include:spf.improvmx.com ip4:$output4 ip6:$output6_cidr ~all"
test_spf=$(dig +short ${domain} txt @1.1.1.1 | tr -d '"')

# Make sure requests complete
sleep 2s

# Read API keys from file
API=$(<"$api_file")
SECRET=$(<"$secret_file")

# Make requests if resource needs updated
if ! [ "$output4" = "$test4" ]; then
    echo "`date +"%b %d %H:%M%:S"` Attempting to update IPv4 DDNS from `hostname`"
        result=$(porkbun_request "$SECRET" "$API" "$output4" "${porkbun_base}/A/${ddns_domain}")
        printf "%s\n\n%s\n%s\n" "Tried changing ${test4} to ${output4}" "Result:" "$result" | tee /dev/stderr | /usr/bin/mail -s "`hostname` updated IPv4 DDNS" `hostname`@aidanhouck.com
fi

if ! [ "$output6" = "$test6" ]; then
    echo "`date +"%b %d %H:%M%:S"` Attempting to update IPv6 DDNS from `hostname`"
        result $(porkbun_request "$SECRET" "$API" "$output6" "${porkbun_base}/AAAA/${ddns_domain}")
        printf "%s\n\n%s\n%s\n" "Tried changing ${test6} to ${output6}" "Result:" "$result" | tee /dev/stderr | /usr/bin/mail -s "`hostname` updated IPv6 DDNS" `hostname`@aidanhouck.com
fi

if ! [ "$output_spf" = "$test_spf" ]; then
    echo "`date +"%b %d %H:%M%:S"` Attempting to update SPF DDNS from `hostname`"
        result=$(porkbun_request "$SECRET" "$API" "$output_spf" "${porkbun_base}/TXT")
        printf "%s\n\n%s\n%s\n" "Tried changing ${test_spf} to ${output_spf}" "Result:" "$result" | tee /dev/stderr | /usr/bin/mail -s "`hostname` updated SPF DDNS" `hostname`@aidanhouck.com
fi