Siim 3bb334f1c6 Updated readme
Might've pasted a bit too much
2026-04-25 18:44:58 +02:00
2026-04-25 17:43:46 +02:00
2026-04-25 16:40:06 +02:00
2026-04-25 18:44:58 +02:00

So to start learning Bash I tasked myself with rewriting my simple Powershell script. Turns out I have quite a lot to learn. This doc is basically my learning notes meant mostly for my own use.

Prerequisites

  • Use Cloudflare DNS

Step 1 - Get a Token

Get a Cloudflare API token with the necessary permissions. Log in to your CloudFlare account, go to Manage Account and to Account API Tokens. Click that big, blue Create Token button and select the Edit zone DNS template.

Select the relevant Zone Resources, I just gave it permissions to edit all my zones: !Pasted image 20250415131506.png

Once you've configured it all, continue to summary, click Create Token and remember to save it. It will only be shown once, so if you forget to save it you'll have to create a new token.

Getting started

#!/bin/bash

Right off the bat, I'm learning stuff. I thougt that #!/bin/bash was just another comment, a nice-to-have note about this being a Bash script. Nope, it's actually called a shebang (no, really, it is) and it specifies which interpreter to use. I guess it's a bit like CMD vs PWSH in Windows land: there is some overlap, but they're not the same.

timeStamp=$(date +"%Y-%m-%dT%H:%M:%S")
cfToken="hurdyburdytokengoeshere"
myIp=$(curl "api.ipify.org" -s)
cfAPIBaseURI="https://api.cloudflare.com/client/v4/"
authHeader="Authorization: Bearer $cfToken"

Now we're establishing some basic variables that'll come in handy later on. Note that variables are initially created without the leading $, but referenced with a leading $. Also, as in the case of the $myIp variable, when the variable is the resulting output of a command, it's created by wrapping the command in parentheses prefaced with a $, like in the next step:

zones=$(curl -X GET "${cfAPIBaseURI}zones" --header "$authHeader" -s)

Using the URI & header variables we just established, the script pulls a JSON array of my DNS zones from Cloudflare, as seen here. curl -X means pass whatever string or arguments we tack on, including whitespace and everything. The -s at the end is for "silent", so no progress reporting, just grab the returned result.

Reading the JSON array string

This is were all my OOP knowledge from +10 years of Powershell started becoming a liability instead of an asset. A JSON array is actually just a very convoluted string. In Powershell, you deal with it by piping it to ConvertFrom-JSON and then you have an object with a bunch of properties and sub-properties that you can easily reference; like this:

$jsonObject = ($jsonArray | ConvertFrom-JSON)
$name = $jsonObject.name
$street = $jsonObject.address.street

Well, at least it's super simple when you're used to working with it. In Bash, however, a string is a string is a string. Enter jq, a brillant tool for dealing with JSON. It can prettify JSON strings for (wildly) improved readability in the terminal, but also convert to and from JSON. Let's use it to turn the JSON string into a Bash array:

readarray -t zoneArray < <(jq -c '.result.[]' <<< $zones)

A lot to unpack here, if you're new to Bash. readarray simply means "load this stuff into an array", the -t removes trailing delimiters. zoneArray is the name of our array variable. < < is a here string, indicating basically "put stuff in here". I think. (jq -c '.result.[]' <<< $zones) actually kinda makes more sense if you read it right-to-left. First we have $zones, the variable containing our JSON string of DNS zones. <<< feeds it to jq, with -c signifying "compact" mode, ie one messy-looking JSON string, not a pretty waterfall of properties. '.result.[]' means that the array ([]) chilling in the result part of the JSON string is what we want to work with.

Yes, I am well aware that hardcore bashmancers would probably just grep and awk their way through the JSON string without even dealing with it as an array. However, I find that by handling the array as an array, it's much simpler to adapt when you need to pull other properties from it or work with other JSON arrays.

Traversing the array of DNS zones

Deal with one zone at a time:

for zone in "${zoneArray[@]}"; do
	zoneName=$(jq --raw-output '.name' <<< "$zone")

First we decide that the current object in our array of DNS zones shall be referred to as $zone. Notice the [@] tacked on to $zoneArray, letting Bash know we want to deal with the objects in the array called "zoneArray". The next line is another jq call that's easier to read from right to left. We're working with $zone , feeding it into jq and reading the raw output of the name property, saving it as the $zoneNamevariable.

zoneId=$(jq -r '.id' <<< "$zone")

We'll need the zone ID for the next step, so we repeat the JQ trick, this time pulling the id property from the zone we're currently handling.

dnsRecords=$(curl -X GET "${cfAPIBaseURI}zones/${zoneId}/dns_records" --header "$authHeader" -s)
readarray -t dnsRecordsArray < <(jq -c '.result.[]' <<< $dnsRecords)

Traversing the arrays of DNS records in the array of DNS zones

Time for another API call via curl! Same procedure as last time, only this time we're pulling a JSON array of DNS records for the current DNS zone.

for dnsRecord in "${dnsRecordsArray[@]}"; do
	 dnsRecordType=$(jq -r '.type' <<< "$dnsRecord")

We've been here before, this time we're using jq to read the type property of our DNS record and storing it as the $dnsRecordType variable...

if [ $dnsRecordType == "A" ]
then

...because we only want to deal with A records. Txt, MX and CNAME records are not relevant to this endeavour.

dnsRecordIp=$(jq -r '.content' <<< "$dnsRecord")

The contentproperty of our DNS record holds the IP address, which is what we actually came here for, so let's store it as the $dnsRecordIpvariable for use in the next step:

Acting on regex matches, ie checking the IP

[[ "$dnsRecordIp" =~ ^100|$myIp ]] && continue ||

This is another whopper for a Bash noob like me. The first part, the double square brackets, hold a regex comparison that simply checks if the $dnsRecordIp variable (double quotes treats it as a string) matches the regex pattern of ^100|$myIp, meaning it checks if the currently registered IP for the DNS record either starts with "100" (^100) or (|) matches the $myIp variable that we grabbed right at the beginning of the script. If it starts with 100, it means it's a Tailscale IP and we don't mess with those records. If it matches $myIp it means it already has the correct IP and there's no need to mess with it.

&& continue ||

Here's the funky bit. The first half (before ||) is the code to run if the regex comparison returns true. In this case we just want it to skip to the next DNS record (we're currently traversing those, remember?) and && continue simply means "move along to the next item in the array". However, what to do if the regex comparison returns false, ie the IP does not start with 100 and does not match $myIp? The line after || is blank, which simply means that we carry on executing the rest of the loop.

Updating the DNS record(s)

dnsRecordId=$(jq -r '.id' <<< "$dnsRecord")

I hope this is starting to look familiar. We read the id property of the DNS record we're currently processing by feeding it into jq and store that ID as $dnsRecordId, 'cause we'll need it soon.

comment="${timeStamp} | Updated automatically from ${dnsRecordIp} to ${myIp}"
content="${myIp}"

These two variables will be used in the JSON formatted body we'll be sending to Cloudflare when updating the IP of the DNS record.

recordBody=$(jq -n -c \
--arg comment "${comment}" \
--arg content "${content}" \
'{comment: $comment, content: $content}')

jq to the rescue once again, this time for building the JSON payload. In this case, simply creating a string would have been easier, but I wanted to learn how to build JSON. The -n option is a shorter form of --null-input, simply meaning we're buidling JSON from scratch. --arg comment "${comment}" means we're feeding jq an argument called "comment" and set the value to the contents of $comment. Same thing with --arg content and the $content variable. Last line is simply the JSON we want jq to build for us.

result=$(curl -X PATCH "${cfAPIBaseURI}zones/${zoneId}/dns_records/${dnsRecordId}" --header "$authHeader" --header "Content-Type: application/json" -d "${recordBody}" -s)

Freshly minted JSON payload in hand, we update the DNS record to the current WAN IP, using curl -X PATCH. I hit a snub here because at first I forgot the Content-Type: application/json header letting Cloudflare know that the incoming payload (or data, -d) would be JSON. The output from curl is stored as the $result variable and whaddayaknow, it's a JSON string!

success=$(jq -r '.success' <<< "$result")
	if [ $success ]
	then
		echo "Successfully updated the A record for ${zoneName} from ${dnsRecordIp} to ${myIp}"
	else
		echo "Failed to update the A record for ${zoneName} from ${dnsRecordIp} to ${myIp}"
	fi

Parsing the string using jq, we can read the boolean success property. If everything worked out, it'll return true, otherwise it'll be false. Then it's simply a matter of outputting the relevant result and our script can carry on repeating the process for every DNS record of every zone in our Cloudflare account.

Doing it all automatically

The point of using a script to check that your DNS records are actually pointing to your home IP is to have it run automatically. On whichever linux instance you deem suited (it should be located where you want your DNS records to point, such as your homelab), simply create a cron job:

crontab -e

This will open an editor showing the current (if any) scheduled jobs. I want my script, /etc/fixDNS.sh, to run every hour, on the hour, 24/7. This means my cron entry looks like this:

0 * * * * /etc/fixDNS.sh

Absolutely unreadable if you've never dealt with cron jobs before, but there is logic to it:

[Minute] [hour] [Day_of_the_Month] [Month_of_the_Year] [Day_of_the_Week] [command]

First column is the minutes. We want it to run at 0, meaning 12:00, 13:00 etc. Second column is the hour. To run it at noon every day, set this to 12. We want it every hour of the day, so we're going with *. Third: Day of the month, type in 1to run it on the first, we want it running every day, so it's another * for us. Fourth column: Month of the year. Only want to do this in June and July? Go with 6,7. We want 'em all, * yet again. Fifth column: Day of the week. For Mondays, 1 is what you need, 2for Tuesdays etc. Take a wild guess at what * means here Sixth and final columt is what to actually do. Simple enough in this case, the script is saved in /etc and called "fixDNS.sh", so /etc/fixDNS.sh gets the job done.

Now save the file (ctrl+x in Nano) and that's all there is to it!

Full source code

#!/bin/bash
#Handle Cloudflare DNS for my domains automatically
#But with a shell script instead of Powershell this time
timeStamp=$(date +"%Y-%m-%dT%H:%M:%S")
cfToken="hurdyburdytokengoeshere"
myIp=$(curl "api.ipify.org" -s)
cfAPIBaseURI="https://api.cloudflare.com/client/v4/"
authHeader="Authorization: Bearer $cfToken"
zones=$(curl -X GET "${cfAPIBaseURI}zones" --header "$authHeader" -s)
#Stuff it all in a Bash array
readarray -t zoneArray < <(jq -c '.result.[]' <<< $zones)
# iterate through the Bash array
for zone in "${zoneArray[@]}"; do
   zoneName=$(jq --raw-output '.name' <<< "$zone")
   #echo $zoneName
   zoneId=$(jq -r '.id' <<< "$zone")
   #Pull the DNS records
   dnsRecords=$(curl -X GET "${cfAPIBaseURI}zones/${zoneId}/dns_records" --header "$authHeader" -s)
   #Stuff it all in a Bash array
   readarray -t dnsRecordsArray < <(jq -c '.result.[]' <<< $dnsRecords)
   # iterate through the Bash array
      for dnsRecord in "${dnsRecordsArray[@]}"; do
         dnsRecordType=$(jq -r '.type' <<< "$dnsRecord")
         #A records only, pls
         if [ $dnsRecordType == "A" ] 
         then
            #Check the IP
            dnsRecordIp=$(jq -r '.content' <<< "$dnsRecord")
            #Never mind the TailScale ones and the ones matching myIp (The latter 'cause they don't need to be changed)
            [[ "$dnsRecordIp" =~ ^100|$myIp ]] && continue || 
                #echo "Found a mismatch in the A record for zone ID ${zoneId}"
                #Get the record ID
                dnsRecordId=$(jq -r '.id' <<< "$dnsRecord")
                comment="${timeStamp} | Updated automatically from ${dnsRecordIp} to ${myIp}"
                content="${myIp}"
                #Build a JSON array to send to Cloudflare as the request body
                recordBody=$(jq -n -c \
                --arg comment "${comment}" \
                --arg content "${content}" \
                '{comment: $comment, content: $content}')
                #Now send that body to Cloudflare so the changes are made
                result=$(curl -X PATCH  "${cfAPIBaseURI}zones/${zoneId}/dns_records/${dnsRecordId}" --header "$authHeader" --header "Content-Type: application/json" -d "${recordBody}" -s)
                #Parse the JSON result
                success=$(jq -r '.success' <<< "$result")
                #echo $success
                if [ $success ]
                then
                    echo "Successfully updated the A record for ${zoneName} from ${dnsRecordIp} to ${myIp}"
                else
                    echo "Failed to update the A record for ${zoneName} from ${dnsRecordIp} to ${myIp}"
                fi
         fi
      done
done
S
Description
Automatically updating the Cloudflare DNS records for your self-hosted domain - with bash!
Readme GPL-3.0 69 KiB
Languages
Shell 100%