Skip to content

Instantly share code, notes, and snippets.

@stuudmuffin
Last active February 5, 2025 16:40
Show Gist options
  • Save stuudmuffin/81c44601534b8c0b136eace6fe324054 to your computer and use it in GitHub Desktop.
Save stuudmuffin/81c44601534b8c0b136eace6fe324054 to your computer and use it in GitHub Desktop.
Update Cloudflare DNS entries for Homelab
#!/bin/bash
# requirements: apt install jq curl sed awk tr
#
# This script does a few limited things.
# 1: when run with ./cloudflare.sh {IP} auto
# it will take the IP address, and attempt to apply it to all dns record IDS listed in $domain_data
# 2: help you get the Zone ID's and Record ID's needed for domain_data, or manually update one domains dns records
#
# When running with no arguments `./cloudflare.sh` you'll be presented with 2 inputs.
# 1: Will ask which domain you wish to work with (manual runs only work with 1 domain per script execution)
# 2: Choose between "find zone ID, retrieve DNS records, and update DNS records"
#
# To get started, all you need is the api tokens for your domains in their respective files, and the domain name in this
# $domain_data json blob
# option 1 of the cloudflare actions will use this domain name to find the zone id
# This can then be put into its respective place in domain_data
# option 2 of the cloudflare actions will use the zone_id saved in the previous step to get all A and AAAA records
# NOTE I haven't added any logic to actually handle replacing AAAA records. only ipv4
# option 3 of the cloudflare actions will use the zone_id, and saved dns record IDs to get the current record information.
# when run with `./cloudflare 1.2.3.4` and then selecting option 3, the record_list ids of your select domain will attempt
# to update only those dns records with the ip 1.2.3.4
#
# once the domain_data information is all filled out, you can run `./cloudflare.sh 1.2.3.4 auto` to update all record_list
# ids to the new ip. I've combined this with automation in my home router (mikrotik) to trigger cloudflare.sh when the
# router detects it has a new IP. (see update_dns_mikrotik_script file for this logic)
new_ip=$1
auto_run=$2
# I uncomment this for debugging before fully allowing the script to be remotely triggered
#echo "hello $new_ip. you will $auto_run"
#exit
# accepting the format `dns_cloudflare_api_token = token` with nothing else in the file
# I am using this file for letsencrypt to aid in ssl generation, and I didn't want duplicate API token locations
dns_cloudflare_api_token_example1=$(sed 's/[[:space:]]*=[[:space:]]*/=/g' /etc/ssl/private/example1.ini | awk -F= '{print $2}' | tr -d '[:space:]')
dns_cloudflare_api_token_example2=$(sed 's/[[:space:]]*=[[:space:]]*/=/g' /etc/ssl/private/example2.ini | awk -F= '{print $2}' | tr -d '[:space:]')
# Dynamically create the domain data JSON string with expanded variables
domain_data=$(jq -n \
--arg example_token1 "$dns_cloudflare_api_token_example1" \
--arg example_token2 "$dns_cloudflare_api_token_example2" \
'{
"example1.com": {
token: $example_token1,
zone_id: "example9f86d081884c7d659a2feaa0c5",
record_list: [ "example60303ae22b998861bce3b28", "examplefd61a03af4f77d870fc2" ]
},
"example2.com": {
token: $example_token2,
zone_id: "examplea4e624d686e03ed2767c0abd8",
record_list: [ "examplea140c0c1eda2def2b830363b" ]
}
}')
# Build list of domains bases on domain_data
#domains=("example1.com" "example2.com")
domains=($(echo "$domain_data" | jq -r 'keys[]'))
# Function to interact with the Cloudflare API
cloudflare_api_call() {
local api_token=$1 # First arugment is the api token
local api_path=$2 # Second argument is the API path (empty when doing zone id lookup)
local data=$3 # Third argument is optional data (for PUT requests or similar)
# Base URL for Cloudflare API
local api_url="https://api.cloudflare.com/client/v4/zones$api_path"
# If data is provided, use it in a PUT; otherwise, use a GET request
if [[ -n "$data" ]]; then
# POST request with data
response=$(curl -s -X PUT "$api_url" \
-H "Authorization: Bearer $api_token" \
-H "Content-Type: application/json" \
--data "$data")
else
# GET request (no data)
response=$(curl -s -X GET "$api_url" \
-H "Authorization: Bearer $api_token" \
-H "Content-Type: application/json")
fi
# Return the response (could be JSON or status code, etc.)
echo "$response"
}
# Function to validate the update data
validate_update_data() {
local update_data="$1"
# 1. Validate "type" - it should be either A or AAAA
type=$(echo "$update_data" | jq -r '.type')
if [[ ! "$type" =~ ^(A|AAAA)$ ]]; then
echo "Invalid type. It should be 'A' or 'AAAA'."
return 1 # Return false
fi
# 2. Validate "name" - it should be a valid domain name (basic validation)
name=$(echo "$update_data" | jq -r '.name')
if [[ ! "$name" =~ ^[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$ ]]; then
echo "Invalid domain name format: $name"
return 1 # Return false
fi
# 3. Validate "content" - it should be a valid IP address (IPv4 validation)
content=$(echo "$update_data" | jq -r '.content')
if [[ ! "$content" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]]; then
echo "Invalid IP address format: $content"
return 1 # Return false
fi
# Check if each octet is in the range 0-255
IFS='.' read -r -a octets <<< "$content"
for octet in "${octets[@]}"; do
if [[ $octet -gt 255 || $octet -lt 0 ]]; then
echo "IP address octet out of range: $octet"
return 1 # Return false
fi
done
# 4. Validate "ttl" - it should be a number
ttl=$(echo "$update_data" | jq -r '.ttl')
if [[ ! "$ttl" =~ ^[0-9]+$ ]]; then
echo "Invalid ttl. It should be a number."
return 1 # Return false
fi
# 5. Validate "proxied" - it should be true or false
proxied=$(echo "$update_data" | jq -r '.proxied')
if [[ ! "$proxied" =~ ^(true|false)$ ]]; then
echo "Invalid proxied value. It should be true or false."
return 1 # Return false
fi
return 0 # Return true
}
# This is in a funciton so it best handles being run manually, or automatically
cloudflare_update_dns() {
local api_token=$1 # First arugment is the api token
local domain=$2 # Second argument is the domain
local new_ip=$3 # the new ip for our payload
# Get our zone_id and record_list from our data_domain array
local zone_id=$(echo "$domain_data" | jq -r ".\"$domain\".zone_id")
local record_list
mapfile -t record_list < <(echo "$domain_data" | jq -r ".\"$domain\".record_list[]?")
# handle empty record_list cases
if [ ${#record_list[@]} -eq 0 ]; then
echo "No records found in record_list for $domain. Skipping..."
echo ""
return
fi
# Echoing the full record list for logging
echo "Processing record list ${record_list[@]}"
for cur_record_id in "${record_list[@]}"; do
echo "Gathering data for $cur_record_id"
response=$(cloudflare_api_call "$api_token" "/$zone_id/dns_records/$cur_record_id")
# Handle api call failiures
success=$(echo "$response" | jq -r '.success')
if [ "$success" != "true" ]; then
echo "API call failed. Exiting..."
echo $response
exit 1
else
echo "$response" | jq -r '"Record found for \(.result.name)"'
fi
# Lets not update the record if we don't need to
cur_ip=$(echo "$response" | jq -r '.result.content')
if [[ "$cur_ip" == "$new_ip" ]]; then
echo "Record already matches the desired ip $cur_ip"
echo ""
continue
fi
# if script is started without an IP, then we'll just report the current record_list data
if [ -z "$new_ip" ]; then
update_data=$(echo "$response" | jq -r '{"type": .result.type, "name": .result.name, "content": .result.content, "ttl": .result.ttl, "proxied": .result.proxied}')
echo "$update_data"
else
# inject our new ip
update_data=$(echo "$response" | jq -r --arg new_ip "$new_ip" '{"type": .result.type, "name": .result.name, "content": $new_ip, "ttl": .result.ttl, "proxied": .result.proxied}')
fi
# Call the validation function. Don't want to try submitting malformed data
if ! validate_update_data "$update_data"; then
echo "Validation failed! Quitting..."
exit
fi
if [ -n "$new_ip" ]; then
echo "Updating record..."
response=$(cloudflare_api_call "$api_token" "/$zone_id/dns_records/$cur_record_id" "$update_data")
# Exit if an update fails, end echo the failed response
success=$(echo "$response" | jq -r '.success')
if [ "$success" != "true" ]; then
echo "API call failed. Exiting..."
echo $response
exit 1
else
echo "$response" | jq -r '"Success for \(.result.name)"'
fi
fi
echo ""
done
}
# Handling for autonomus runs
if [ -n "$auto_run" ] && [ "$auto_run" == "auto" ]; then
for cur_domain in "${domains[@]}"; do
cur_token=$(echo "$domain_data" | jq -r ".\"$cur_domain\".token")
echo "Updating relevent records for $cur_domain - $cur_token"
cloudflare_update_dns "$cur_token" "$cur_domain" "$new_ip"
done
exit
fi
# Hanlding for manual runs
while true; do
# Define your domains and prompt for selection
echo "Select the domain you want to check:"
for i in "${!domains[@]}"; do
echo "$((i + 1))) ${domains[$i]}"
done
read -p "Enter the number of the domain you want to check: " choice
# Check if the choice is a valid index, and set cur_domain
if [[ "$choice" -ge 1 && "$choice" -le ${#domains[@]} ]]; then
cur_domain="${domains[$((choice - 1))]}"
echo "You selected $cur_domain"
break
else
# If invalid, display an error
echo "Invalid selection! Please choose a valid number."
fi
done
cur_token=$(echo "$domain_data" | jq -r ".\"$cur_domain\".token")
cur_zoid=$(echo "$domain_data" | jq -r ".\"$cur_domain\".zone_id")
mapfile -t cur_reli < <(echo "$domain_data" | jq -r ".\"$cur_domain\".record_list[]?")
# Example of accessing and displaying the extracted values
echo "Selected domain token: $cur_token"
echo "Selected domain zone_id: $cur_zoid"
echo "Selected domain reli: ${cur_reli[@]}"
while true; do
# Define your api functions
echo "Select the function you wish to perform:"
echo "1) get zone id"
echo "2) get dns records"
echo "3) update dns record"
# Prompt user for domain choice
read -p "Function number: " choice
# Use a case statement to handle the domain selection
case $choice in
1)
# Example: Call Cloudflare API to get DNS Zone ID
echo "Getting Zone ID..."
response=$(cloudflare_api_call "$cur_token" "" | jq -r '.result[0].id')
echo "Response: $response"
break 1
;;
2)
# Example: Call Cloudflare API to get DNS records (no data needed)
echo "Getting DNS records..."
response=$(cloudflare_api_call "$cur_token" "/$cur_zoid/dns_records")
records=()
# Initialize an empty array to hold the results
records=()
# Use jq to filter A and AAAA records and store them in the array
while IFS= read -r record; do
records+=("$record")
done < <(echo "$response" | jq -r '.result[] | select(.type == "A" or .type == "AAAA") | "\(.id) - \(.type) record for \(.name): \(.content)"')
# Loop over the array and display the records
echo "Displaying A and AAAA records:"
for record in "${records[@]}"; do
echo "$record"
done
break 1
;;
3)
if [ -z "$new_ip" ]; then
echo "no new ip set. Only reporting current records"
fi
echo "Updating relevent records for this zone"
cloudflare_update_dns "$cur_token" "$cur_domain" "$new_ip"
break 1
;;
4)
# Loop over the list of DNS record IDs
for cur_record_id in "${cur_reli[@]}"; do
echo "Gathering data for $cur_record_id"
response=$(cloudflare_api_call "$cur_token" "/$cur_zoid/dns_records/$cur_record_id")
success=$(echo "$response" | jq -r '.success')
if [ "$success" != "true" ]; then
echo "API call failed. Exiting..."
echo $response
exit 1
else
echo "$response" | jq -r '"Record found for \(.result.name)"'
fi
if [ -z "$new_ip" ]; then
update_data=$(echo "$response" | jq -r '{"type": .result.type, "name": .result.name, "content": .result.content, "ttl": .result.ttl, "proxied": .result.proxied}')
else
update_data=$(echo "$response" | jq -r --arg new_ip "$new_ip" '{"type": .result.type, "name": .result.name, "content": $new_ip, "ttl": .result.ttl, "proxied": .result.proxied}')
fi
# Call the validation function
if ! validate_update_data "$update_data"; then
echo "Validation failed! Quitting..."
exit
fi
if [ -n "$new_ip" ]; then
echo "Skipping Updating the record..."
# since this script is meant to be run automatically, I don't want to accidently cause problems if I test
# a manual run. Uncomment this if you want to debug
# echo "Updating record..."
# response=$(cloudflare_api_call "$cur_token" "/$cur_zoid/dns_records/$cur_record_id" "$update_data")
# Exit if an update fails, end echo the failed response
success=$(echo "$response" | jq -r '.success')
if [ "$success" != "true" ]; then
echo "API call failed. Exiting..."
echo $response
exit 1
else
echo "$response" | jq -r '"Success for \(.result.name)"'
fi
fi
echo ""
exit
done
break 1
;;
*)
# Default case for invalid input
echo "Invalid selection! Please choose a valid number."
;;
esac
done
dns_cloudflare_api_token = exampleU3a_7jPDVj3_ujdevaa140c0c
# Simple mikrotik script to ssh-exec the cloudflare.sh file on a remote server.
# runs on a schedule to periodically check if our public ip has changed
# ip lookup includes the /subnet. for my purposes I want this removed
:local currentipsub [/ip address get [find interface=ether1] address];
:local currentip [:pick $currentipsub 0 [:find $currentipsub "/"]];
:local lastip [/file get last_ip.txt contents];
# Check if the IP address has changed
:if ($currentip != $lastip) do={
# Do something when the IP changes (e.g., log, send email, etc.)
/log info message="IP address changed from $lastip to $currentip";
# Store the new IP to the file
/file set last_ip.txt contents=$currentip;
# Phone a friend to update DNS entries
/system/ssh-exec address=192.168.1.2.com user=ubuntu command="~/scripts/cloudflare.sh $currentip auto"
}
# else={
# These lines are here just for debugging
# /log info message="IP address currently $currentip. no change";
# /system/ssh-exec address=192.168.1.2.com user=ubuntu command="~/scripts/cloudflare.sh $currentip auto"
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment