#!/bin/bash # cf-ddns - update Cloudflare DNS to this machine's outbound IP # # Usage: # cf-ddns # cf-ddns -h # # Updates Cloudflare DNS to use the current machine's outbound IP address; # deletes all existing A records for the domain and creates a new one # Run periodically from host to achieve basic Dynamic DNS capabilities # Required API token permissions: Zone.Zone, Zone.DNS _cf_ddns() ( local SCRIPT_NAME; SCRIPT_NAME="$(basename "${BASH_SOURCE[0]}")" case "${BASH_SOURCE[0]}" in /dev/*|/proc/*) SCRIPT_NAME="" ;; esac case "$SCRIPT_NAME" in ""|bash|sh|zsh|dash) SCRIPT_NAME="cf-ddns" ;; esac local API_VERSION="v4" local API_BASE="https://api.cloudflare.com/client/$API_VERSION" local A_RECORD_TTL=60 local CURL_LOG_FILE="/tmp/curl.$$.log" # Clean up inner functions on return _show_help() { local s; [ -t 1 ] && s=$'\033[4m' local r; [ -t 1 ] && r=$'\033[24m' echo "NAME" echo " $SCRIPT_NAME - update Cloudflare DNS to this machine's outbound IP" echo "SYNOPSIS" echo " $SCRIPT_NAME ${s}api_token${r} ${s}domain${r}" echo " $SCRIPT_NAME -h" echo "DESCRIPTION" echo " Detects the machine's outbound IP via api.ipify.org, compares it to the" echo " domain's current A record (via Google DNS), and updates Cloudflare if they" echo " differ. All existing A records for the domain are deleted first." echo " Run periodically from the host to achieve basic Dynamic DNS." echo "OPTIONS" echo " -h, --help Show this help message" echo "ENVIRONMENT" echo " DDNS_DEBUG Set to any value to enable verbose curl/debug output" echo "EXIT STATUS" echo " 0 Success (DNS already correct or successfully updated)" echo " 1 Runtime failure (IP detection failed, API returned unexpected JSON," echo " no zone for domain)" echo " 2 Usage error (missing API token or domain)" echo " 3 Dependency error (curl, jq, or dig missing)" echo "DEPENDENCIES" echo " curl, jq, dig" echo "EXTERNAL SERVICES" echo " api.ipify.org used to detect this machine's public IP" echo " 8.8.8.8 Google DNS (TCP), used to resolve the domain's current A record" echo " If either endpoint is unreachable (firewall, restricted network), the" echo " script will fail -- make sure outbound access to both is permitted." echo "API TOKEN PERMISSIONS" echo " Zone.Zone, Zone.DNS" } _color() { [ -t 2 ] && [ -z "${NO_COLOR:-}" ] && printf '%s' "$1"; } _error() { printf '%s[ERR][%s] %s%s\n' "$(_color $'\033[31m')" "$SCRIPT_NAME" "$*" "$(_color $'\033[0m')" >&2; } _info() { printf '%s[INF][%s] %s%s\n' "$(_color $'\033[2m')" "$SCRIPT_NAME" "$*" "$(_color $'\033[0m')" >&2; } _debug() { [ -z "$DDNS_DEBUG" ] && return 0; printf '%s[DBG][%s] %s%s\n' "$(_color $'\033[36m')" "$SCRIPT_NAME" "$*" "$(_color $'\033[0m')" >&2; } case "$1" in -h|--help) _show_help; return 0 ;; esac # Check dependencies local dep for dep in jq curl dig; do if ! command -v "$dep" >/dev/null 2>&1; then _error "$dep is required" return 3 fi done #/# Script Parameters #\# if [ -z "${1:-}" ]; then _error "Must provide an API bearer token. Run \`$SCRIPT_NAME -h\` for usage" return 2 fi local api_token="$1"; shift if [ -z "${1:-}" ]; then _error "Must provide a domain name. Run \`$SCRIPT_NAME -h\` for usage" return 2 fi local domain="$1"; shift #local ip_addresses=("$@") #\# #/# ###/### Local Functions ###\### _exception() { local error_message="${1:?"error message is required"}" local log_file="${2:?"log file is required"}" _error "$error_message" _error "[START INTERNAL ERROR]" cat "$log_file" >&2 _error "[END INTERNAL ERROR]" } #\# #/# # curl wrapper for API calls: sets URL and auth, logs stderr+stdout to file _curl() { local method="${1:?"HTTP method is required"}" && shift local path="${1:?"API path is required"}" && shift # shellcheck disable=SC2094 # "Make sure not to read and write the same file in the same pipeline." -- both writes are append-only; nothing reads the file in this pipeline curl -v -sSf -X "$method" \ --url "$API_BASE/${path#/}" \ -H "Authorization: Bearer $api_token" \ "$@" \ 2>>"$CURL_LOG_FILE" \ | tee -a "$CURL_LOG_FILE" } #/# API calls #\# _get_zones() { _debug "GET /zones" _curl GET "/zones" } _get_dns_records() { local zone_id="${1:?"zone ID is required"}" _debug "GET /zones/$zone_id/dns_records" _curl GET "/zones/$zone_id/dns_records" } _delete_dns_record() { local zone_id="${1:?"zone ID is required"}" local record_id="${2:?"DNS record ID is required"}" _debug "DELETE /zones/$zone_id/dns_records/$record_id" _curl DELETE "/zones/$zone_id/dns_records/$record_id" } _create_dns_record() { local zone_id="${1:?"zone ID is required"}" local domain="${2:?"domain name is required"}" local type="${3:?"DNS record type is required"}" local content="${4:?"DNS record content is required"}" _debug "POST /zones/$zone_id/dns_records" _curl POST "/zones/$zone_id/dns_records" \ -H "Content-Type: application/json" \ --data '{ "name": "'"$domain"'", "type": "'"$type"'", "content": "'"$content"'", "ttl": '$A_RECORD_TTL' }' } #\# #/# # Gets the current system's outbound IP address _outgoing_ip() { curl -s 'https://api.ipify.org' } # Returns success (0) status code if success property of JSON object is true _api_success() { [ "$(echo "$1" | jq '.success')" = "true" ] } ###\### ###/### ###/### Main ###\### # Print log file name so user can tail if desired _debug "curl output logged to: $CURL_LOG_FILE" # Get current outbound (inbound) IP local outgoing_ip if ! outgoing_ip="$(_outgoing_ip)"; then _error "Failed to get device's IP" return 1 fi _info "Device's IP: $outgoing_ip" # Get current domain IP from Google DNS (primary) - adding TCP flag because some networks block UDP traffic local current_ip; current_ip="$(dig @8.8.8.8 +tcp +short "$domain")" _info "Domain's IP: ${current_ip:-"NONE"} ($domain)" # Check whether DNS needs to be updated if [ "$outgoing_ip" = "$current_ip" ]; then _info "DNS IP address already up-to-date" return 0 else _info "IP addresses do not match, updating DNS" fi # Get zone ID from domain name local zone_id if ! zone_id="$(_get_zones | jq -r '.result[] | select(.name=="'"$domain"'") | .id' 2>"/tmp/get_zones.$$.err")"; then _exception "Unexpected error while retrieving zone ID for domain \"$domain\"" "/tmp/get_zones.$$.err" return 1 fi if [ -z "$zone_id" ]; then _error "No zone found for domain \"$domain\"" return 1 fi # Get all A records for zone local a_records if ! a_records="$(_get_dns_records "$zone_id" | jq -r '.result[] | select(.type=="A") | "\(.content)=\(.id)"' 2>"/tmp/get_dns_records.$$.err")"; then _exception "Unexpected error while retrieving DNS records from zone \"$zone_id\" ($domain)" "/tmp/get_dns_records.$$.err" return 1 fi if [ -z "$a_records" ]; then _info "No existing A records in zone \"$zone_id\" ($domain)" fi # Delete all found A records - this is why we are not using PUT local record for record in $a_records; do local record_ip="${record%=*}" local record_id="${record#*=}" _info "Deleting A record for $record_ip ($record_id)" local delete_response; delete_response="$(_delete_dns_record "$zone_id" "$record_id")" if _api_success "$delete_response"; then : #_info "Successfully deleted A record for $record_ip ($record_id)" else _error "Delete DNS Record request failed, printing raw response" echo "$delete_response" >&2 fi done # Create a new A record with the system's current outbound IP _info "Creating A record for $outgoing_ip" local create_response; create_response="$(_create_dns_record "$zone_id" "$domain" "A" "$outgoing_ip")" if _api_success "$create_response"; then : #_info "Successfully created A record for $outgoing_ip" else _error "Create DNS Record request failed, printing raw response" echo "$create_response" >&2 fi ###\### ###/### ) # Call main function, passing script arguments _cf_ddns "$@" __cf_ddns_rc=$? unset -f _cf_ddns if [ -n "${BASH_SOURCE[0]}" ] && [ "${BASH_SOURCE[0]}" != "$0" ]; then eval "unset __cf_ddns_rc; return $__cf_ddns_rc" fi eval "unset __cf_ddns_rc; exit $__cf_ddns_rc"