Skip to content

Instantly share code, notes, and snippets.

@pirate
Last active December 28, 2023 15:00
Show Gist options
  • Star 4 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save pirate/d3e6312b999409705fade0eaf67dd9a0 to your computer and use it in GitHub Desktop.
Save pirate/d3e6312b999409705fade0eaf67dd9a0 to your computer and use it in GitHub Desktop.
Dynamic DNS updater script for DigitalOcean and CloudFlare (using bash, curl, and jq)
#!/usr/bin/env bash
# set -o xtrace
set -o errexit
set -o errtrace
set -o nounset
set -o pipefail
SCRIPTNAME="$0"
HELP_TEXT="
DNS update helper script (to get or update a DNS record on multiple providers).
Usage:
$SCRIPTNAME domain.example.com A [--get|--set=value] [...options]
Options:
[domain] The DNS domain you want to get or set (required)
[type] The DNS record type, e.g. A, CNAME, etc. (required)
-g|--get Get the record value (the default)
-s=|--set=value Set the record value, e.g. 123.235.324.234 or the
special value 'pubip' to use current public ip
-t=|--ttl=n Set the record TTL to n seconds (overrides api default)
-p=|--proxied Set the record to be proxied through CDN (Cloudflare only)
-a=|--api=api1,api2 Comma-separated list of DNS providers to use, e.g. cf,do
(cf=cloudflare, do=digitalocean, default is all)
-c=|--config=file Path to a dotenv-formatted config file to load
-r=|--refresh=n Run continusouly every n seconds in a loop
-w=|--timeout=n Wait n seconds before aborting and retrying
-h|--help Show this help message
-v|--verbose Show more verbose output
-q|--quiet Supress all output except for errors and warnings
--color Force showing of colors in the stderr output
--nocolor Force hiding of colors in the stderr output
--notimestamps Force hiding of timestamps in stderr output
--nologlevels Force hiding of log levels in stderr output
Config: (passed via --config=file or environment variables)
CF_API_KEY=12345 Clouflare API token: https://dash.cloudflare.com/<account_id>/profile/api-tokens
DO_API_KEY=12345 DigitalOcean API token: https://cloud.digitalocean.com/account/api/tokens
VEBOSE=1 Show debug output [0]/1
QUIET=0 Hide info output: [0]/1
COLOR=1 Colorize stderr output: [1]/0
TIMEOUT=15 Seconds to wait before aborting and retrying
NS1=1.1.1.1 Nameserver #1 to use for lookups (default)
NS2=8.8.8.8 Nameserver #2 to use for lookups (fallback)
NS3=208.67.222.222 Nameserver #3 to use for lookups (fallback)
Examples:
$SCRIPTNAME abc.example.com A
$SCRIPTNAME abc.example.com A --set=1.2.3.4 --ttl=300 --api=digitalocean
$SCRIPTNAME abc.example.com A --set=pubip --api=digitalocean,cloudflare --refresh=30 --config=./secrets.env
$SCRIPTNAME abc.example.com A --get --refresh=30 --config=~/.cloudflare.conf
"
API_KEY_PLACEHOLDER="set-this-value-in-your-config-file"
### Config
CF_API_KEY="$API_KEY_PLACEHOLDER"
DO_API_KEY="$API_KEY_PLACEHOLDER"
TTL='default'
PROXIED='false'
APIS='all'
CONFIG=''
REFRESH='0'
TIMEOUT=15
VERBOSE=0
QUIET=0
COLOR=1
TIMESTAMPS=1
LOGLEVELS=1
NS1="1.1.1.1"
NS2="8.8.8.8"
NS3="208.67.222.222"
AVAILABLE_APIS="cf,do"
CF_API_URL="https://api.cloudflare.com/client/v4"
DO_API_URL="https://api.digitalocean.com/v2"
CF_DEFAULT_TTL="1"
DO_DEFAULT_TTL="300"
### Helpers
[[ ! -t 2 ]] && COLOR='0' # if stderr is not a tty, turn of log coloring
GRAY='\033[2;37m'
RED='\033[0;31m'
YELLOW='\033[0;33m'
CYAN='\033[0;36m'
RESET='\033[0m'
function log {
LEVEL="$1"; shift; STRING="$*";
case "$LEVEL" in
DEBUG) ANSI="$GRAY";;
INFO) ANSI="$CYAN";;
WARN) ANSI="$YELLOW";;
ERROR) ANSI="$RED";;
FATAL) ANSI="$RED";;
*) ANSI="";;
esac
# replace newlines and repeated whitespace with a single space
STRING="$(echo -e "$STRING" | sed -e "s/[[:space:]]\+/ /g")"
if [[ "$TIMESTAMPS" == "1" ]]; then
TS="[$(date +"%Y-%m-%d %H:%M")] "
else
TS=""
fi
if [[ "$LOGLEVELS" == "1" ]]; then
LEVEL="$(printf '%-7s' "[${LEVEL}] ")"
else
LEVEL=""
fi
if [[ "$COLOR" == "1" ]]; then
echo -e "${GRAY}${TS}${ANSI}${LEVEL}$RESET${STRING}" >&2
else
echo -e "${TS}${LEVEL}${STRING}" >&2
fi
}
function debug {
set +o xtrace
[[ ! "$VERBOSE" == "1" ]] && return 0
log DEBUG "$*"
# set -o xtrace
}
function info {
set +o xtrace
[[ "$QUIET" == "1" ]] && return 0
log INFO "$*"
# set -o xtrace
}
function warn {
set +o xtrace
log WARN "$*"
# set -o xtrace
}
function error {
set +o xtrace
log ERROR "$*"
# set -o xtrace
}
function fatal {
set +o xtrace
echo "" >&2
log FATAL "$*"
exit 3
}
function on_quit {
local reason="$*"
fatal "Stopped. (received $reason)"
}
trap 'on_quit SIGINT' SIGINT
trap 'on_quit SIGQUIT' SIGQUIT
trap 'on_quit SIGTSTP' SIGTSTP
trap 'on_quit TIMEOUT' SIGALRM
function timed {
MAX="$1"; shift; CMD="$*";
ppid="$$"
(
eval "$CMD" & cmd_pid=$!
(
sleep "$MAX"
warn "Reached ${MAX}s timeout, aborting and retrying..."
kill $cmd_pid 2> /dev/null
) & killer_pid=$!
debug "[timed][1/2] Timer started ppid=$ppid cmd_pid=$cmd_pid killer=$killer_pid"
wait $cmd_pid || true
kill $killer_pid
debug "[timed][2/2] Finished sucesfully ppid=$ppid cmd_pid=$cmd_pid killer=$killer_pid"
)
}
IPV4_BLOCK='(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)'
IPV4_REGEX="$IPV4_BLOCK\.$IPV4_BLOCK\.$IPV4_BLOCK\.$IPV4_BLOCK"
function get_rootdomain {
# www.sub.example.dev -> example.dev
DOMAIN="$1"
echo "$DOMAIN" | rev | cut -d "." -f1-2 | rev
}
function get_subdomain {
# www.sub.example.dev -> www.sub
DOMAIN="$1"
ROOTDOMAIN="$(get_rootdomain "$DOMAIN")"
echo "$DOMAIN" | replace ".$ROOTDOMAIN" ''
}
function canonical_ip {
DOMAIN="$1"; TYPE="${2:-A}"; shift 2; NS="$*";
debug "[canonical_ip][1/3] dig $DOMAIN $TYPE @$NS | grep -Eo '\$IPV4_REGEX'"
if [[ -n "$NS" ]]; then
# Resolve domain to IP using NS
OUTPUT="$(dig -4 +short +tries=5 +time=5 "$DOMAIN" "$TYPE" "@$NS" 2>&1)"
STATUS="$?"
else
for NS in "$NS1" "$NS2" "$NS3"; do
OUTPUT="$(dig -4 +short +tries=2 +time=3 "$DOMAIN" "$TYPE" "@$NS" 2>&1)" && break
STATUS="$?"
done
fi
debug "[canonical_ip][2/3] dig (exitstatus=$STATUS) => $OUTPUT"
if ((STATUS>0)); then
error "Resolving $DOMAIN $TYPE @$NS failed. (Is the internet down?)"
return 1
fi
PARSED_OUTPUT="$(echo "$OUTPUT" | grep -Eo "$IPV4_REGEX" | head -1)"; STATUS="$?"
debug "[canonical_ip][3/3] grep (exitstatus=$STATUS) => $PARSED_OUTPUT"
if ((STATUS>0)); then
error "Parsing $DOMAIN $TYPE @$NS failed. (got $OUTPUT)"
return 1
fi
echo "$PARSED_OUTPUT"
}
function akamai_get_public_ip {
curl --silent "http://whatismyip.akamai.com/" | grep -Eo "$IPV4_REGEX" || return 1
}
function tyk_get_public_ip {
curl --silent 'http://ip.tyk.nu/' | grep -Eo "$IPV4_REGEX" || return 1
}
function openhttp_get_public_ip {
curl --silent "https://diagnostic.opendns.com/myip" | grep -Eo "$IPV4_REGEX" || return 1
}
function ifconfig_get_public_ip {
curl --silent 'https://ifconfig.me' | grep -Eo "$IPV4_REGEX" || return 1
}
function opendns_get_public_ip {
canonical_ip "myip.opendns.com" "A" "resolver1.opendns.com" || return 1
}
function google_get_public_ip {
canonical_ip "o-o.myaddr.l.google.com" "TXT" "ns1.google.com" || return 1
}
function dnscrypt_get_public_ip {
canonical_ip "resolver.dnscrypt.info" "TXT" "$NS1" || return 1
}
function get_public_ip {
akamai_get_public_ip || \
ifconfig_get_public_ip || \
openhttp_get_public_ip || \
tyk_get_public_ip || \
dnscrypt_get_public_ip || \
opendns_get_public_ip || \
google_get_public_ip || \
{
error "Unable to get public IP from any source!"
return 1
}
}
function call_api {
API="$1"; METHOD="$2"; URL="$3"; JSON_PATH="$4"; shift 4; DATA="$*";
case "$API" in
digitalocean|'do')
API_KEY="$DO_API_KEY"; URL="$DO_API_URL$URL";;
cloudflare|'cf')
API_KEY="$CF_API_KEY"; URL="$CF_API_URL$URL";;
*)
fatal "Invalid API type $API";;
esac
IFS=""
CMD=(
"curl"
"--silent"
"--request" "$METHOD"
"--url" "'$URL'"
"--header" "'Authorization: Bearer $API_KEY'"
"--header" "'Content-Type: application/json'"
)
[[ -n "$DATA" ]] && {
CMD+=("--data" "'$DATA'")
}
debug "[call_api][1/3] curl -X $METHOD $URL --data '$DATA' ($API) | jq '$JSON_PATH'"
IFS=" "
OUTPUT="$(eval "${CMD[@]}")"; STATUS="$?"
debug "[call_api][2/3] curl (exitstatus=$STATUS) => $OUTPUT"
if ((STATUS>0)); then
debug "> $OUTPUT"
error "API request to $API failed. (status=$?)"
return 1
fi
PARSED_OUTPUT="$(echo "$OUTPUT" | jq --raw-output "$JSON_PATH")"; STATUS="$?"
debug "[call_api][3/3] jq (exitstatus=$STATUS) => $PARSED_OUTPUT"
if ((STATUS>0)); then
error "API response from $API could not be parsed. (status=$?)"
return 1
fi
if [[ -z "$PARSED_OUTPUT" || "$PARSED_OUTPUT" == "null" || "$PARSED_OUTPUT" == "undefined" ]]; then
warn "API response from $API indicates no matching record exists. (got $PARSED_OUTPUT)"
echo "null"
return 1
else
echo "$PARSED_OUTPUT"
return 0
fi
}
### DigitalOcean
function do_record_url {
DOMAIN="$1"; TYPE="${2:-A}";
ROOTDOMAIN="$(get_rootdomain "$DOMAIN")"
SUBDOMAIN="$(get_subdomain "$DOMAIN")"
RECORDS_URL="/domains/$ROOTDOMAIN/records"
RECORDS_JSON_PATH=".domain_records[] | select(.name == \"$SUBDOMAIN\" and .type == \"$TYPE\") | .id"
RECORD_ID="$(call_api digitalocean GET "$RECORDS_URL" "$RECORDS_JSON_PATH")" || return $?
echo "/domains/$ROOTDOMAIN/records/$RECORD_ID"
}
function do_get_record {
DOMAIN="$1"; TYPE="${2:-A}"
URL="$(do_record_url "$DOMAIN" "$TYPE")" || return $?
JSON_PATH=".domain_record.data"
call_api digitalocean GET "$URL" "$JSON_PATH"
}
function do_create_record {
DOMAIN="$1"; TYPE="$2"; VALUE="$3"; TTL="${4:-default}";
[[ "$TTL" == "default" ]] && TTL="$DO_DEFAULT_TTL"
ROOTDOMAIN="$(get_rootdomain "$DOMAIN")"
SUBDOMAIN="$(get_subdomain "$DOMAIN")"
URL="/domains/$ROOTDOMAIN/records"
JSON_PATH=".domain_record.data"
DATA='{
"type": "'$TYPE'",
"name": "'$SUBDOMAIN'",
"data": "'$VALUE'",
"ttl": '$TTL'
}'
call_api digitalocean POST "$URL" "$JSON_PATH" "$DATA"
}
function do_set_record {
DOMAIN="$1"; TYPE="$2"; VALUE="$3"; TTL="${4:-default}";
[[ "$TTL" == "default" ]] && TTL="$DO_DEFAULT_TTL"
SUBDOMAIN="$(get_subdomain "$DOMAIN")"
URL="$(do_record_url "$DOMAIN" "$TYPE")" || return $?
JSON_PATH=".domain_record.data"
DATA='{
"type": "'$TYPE'",
"name": "'$SUBDOMAIN'",
"data": "'$VALUE'",
"ttl": '$TTL'
}'
call_api digitalocean PUT "$URL" "$JSON_PATH" "$DATA"
}
### Cloudflare
function cf_record_url {
DOMAIN="$1"; TYPE="${2:-A}";
ROOTDOMAIN="$(get_rootdomain "$DOMAIN")"
ZONES_URL="/zones"
ZONES_JSON_PATH=".result[] | select(.name == \"$ROOTDOMAIN\") | .id"
ZONE_ID="$(call_api cloudflare GET "$ZONES_URL" "$ZONES_JSON_PATH")"
RECORDS_URL="/zones/$ZONE_ID/dns_records?name=$DOMAIN&type=$TYPE"
RECORDS_JSON_PATH='.result[0].id'
RECORD_ID="$(call_api cloudflare GET "$RECORDS_URL" "$RECORDS_JSON_PATH")"
echo "/zones/$ZONE_ID/dns_records/$RECORD_ID"
}
function cf_get_record {
DOMAIN="$1"; TYPE="${2:-A}"
URL="$(cf_record_url "$DOMAIN" "$TYPE")"
JSON_PATH='.result.content'
call_api cloudflare GET "$URL" "$JSON_PATH"
}
function cf_create_record {
DOMAIN="$1"; TYPE="$2"; VALUE="$3"; TTL="${4:-default}"; PROXIED="${5:-$PROXIED}"
[[ "$TTL" == "default" ]] && TTL="$CF_DEFAULT_TTL"
ROOTDOMAIN="$(get_rootdomain "$DOMAIN")"
ZONES_URL="/zones"
ZONES_JSON_PATH=".result[] | select(.name == \"$ROOTDOMAIN\") | .id"
ZONE_ID="$(call_api cloudflare GET "$ZONES_URL" "$ZONES_JSON_PATH")"
URL="/zones/$ZONE_ID/dns_records"
JSON_PATH='.result.content'
DATA='{
"type": "'$TYPE'",
"name": "'$DOMAIN'",
"content": "'$VALUE'",
"ttl": '$TTL',
"proxied": '$PROXIED'
}'
call_api cloudflare POST "$URL" "$JSON_PATH" "$DATA"
}
function cf_set_record {
DOMAIN="$1"; TYPE="$2"; VALUE="$3"; TTL="${4:-default}"; PROXIED="${5:-$PROXIED}"
[[ "$TTL" == "default" ]] && TTL="$CF_DEFAULT_TTL"
URL="$(cf_record_url "$DOMAIN" "$TYPE")"
JSON_PATH='.result.content'
DATA='{
"type": "'$TYPE'",
"name": "'$DOMAIN'",
"content": "'$VALUE'",
"ttl": '$TTL',
"proxied": '$PROXIED'
}'
call_api cloudflare PUT "$URL" "$JSON_PATH" "$DATA"
}
### Main Functions
function get_record {
API="$1"; DOMAIN="$2"; TYPE="$3";
VALUE="$(
"${API}_get_record" \
"$DOMAIN" \
"$TYPE"
)"
echo "$VALUE"
}
function update_record {
API="$1"; DOMAIN="$2"; TYPE="$3"; VALUE="$4"; TTL="$5"; PROXIED="$6"
[[ "$VALUE" == "pubip" ]] && VALUE="$(get_public_ip)"
VALUE_BEFORE="$(
"${API}_get_record" \
"$DOMAIN" \
"$TYPE"
)"
if [[ "$VALUE_BEFORE" == "null" ]]; then
warn "$API/$DOMAIN/$TYPE=$VALUE creating new record..."
VALUE_AFTER="$(
"${API}_create_record" \
"$DOMAIN" \
"$TYPE" \
"$VALUE" \
"$TTL" \
"$PROXIED"
)"
elif [[ "$VALUE_BEFORE" == "$VALUE" ]]; then
info "$API/$DOMAIN/$TYPE=$VALUE_BEFORE is up-to-date."
return 0
else
warn "$API/$DOMAIN/$TYPE=$VALUE_BEFORE updating to $VALUE..."
VALUE_AFTER="$(
"${API}_set_record" \
"$DOMAIN" \
"$TYPE" \
"$VALUE" \
"$TTL" \
"$PROXIED"
)"
fi
if [[ "$VALUE_AFTER" != "$VALUE" ]]; then
error "$API/$DOMAIN/$TYPE=$VALUE update failed (got $VALUE_AFTER)."
return 1
else
info "$API/$DOMAIN/$TYPE=$VALUE update succeeded."
return 0
fi
}
function main {
declare -A KWARGS=(
[domain]=''
[type]=''
[set]=''
[api]=''
[ttl]=''
[proxied]=''
[refresh]=''
[config]=''
[verbose]=''
[quiet]=''
[color]=''
[timestamps]=''
[loglevels]=''
)
while (( "$#" )); do
case "$1" in
-h|--help|help)
echo "$HELP_TEXT"
exit 0;;
-v|--verbose)
KWARGS[verbose]='1'
KWARGS[quiet]='0'
shift;;
-q|--quiet)
KWARGS[verbose]='0'
KWARGS[quiet]='1'
shift;;
--color)
KWARGS[color]='1'
shift;;
--nocolor)
KWARGS[color]='0'
shift;;
--notimestamps)
KWARGS[timestamps]='0'
shift;;
--nologlevels)
KWARGS[loglevels]='0'
shift;;
-g|--get)
shift;;
-s|--set|-s=*|--set=*)
if [[ "$1" == *'='* ]]; then
KWARGS[set]="${1#*=}"
else
shift
KWARGS[set]="$1"
fi
shift;;
-t|-t=*|--ttl|--ttl=*)
if [[ "$1" == *'='* ]]; then
KWARGS[ttl]="${1#*=}"
else
shift
KWARGS[ttl]="$1"
fi
shift;;
-p|--proxied)
KWARGS[proxied]='true'
shift;;
-a|-a=*|--api|--api=*)
if [[ "$1" == *'='* ]]; then
KWARGS[api]="${1#*=}"
else
shift
KWARGS[api]="$1"
fi
shift;;
-c|--config|-c=*|--config=*)
if [[ "$1" == *'='* ]]; then
KWARGS[config]="${1#*=}"
else
shift
KWARGS[config]="$1"
fi
shift;;
-r|-r=*|-refresh|--refresh=*)
if [[ "$1" == *'='* ]]; then
KWARGS[refresh]="${1#*=}"
else
shift
KWARGS[refresh]="$1"
fi
shift;;
-w|-w=*|--timeout|--timeout=*)
if [[ "$1" == *'='* ]]; then
KWARGS[timeout]="${1#*=}"
else
shift
KWARGS[timeout]="$1"
fi
shift;;
*)
if [[ ! "${KWARGS[domain]}" ]]; then
KWARGS[domain]="$1"
elif [[ ! "${KWARGS[type]}" ]]; then
KWARGS[type]="$1"
else
fatal "Got unrecognized argument '$1'"
fi
shift;;
esac
done
echo "---------------------------------------------------------------------"
echo -n "$SCRIPTNAME "
for key in "${!KWARGS[@]}"; do
echo -n "$key=${KWARGS[$key]} "
done
echo
echo "---------------------------------------------------------------------"
DOMAIN="${KWARGS[domain]}"
TYPE="${KWARGS[type]}"
VALUE="${KWARGS[set]}"
TTL="${KWARGS[ttl]:-$TTL}"
PROXIED="${KWARGS[proxied]:-$PROXIED}"
APIS="${KWARGS[api]:-$APIS}"
CONFIG="${KWARGS[config]}"
REFRESH="${KWARGS[refresh]}"
TIMEOUT="${KWARGS[timeout]:-$TIMEOUT}"
VERBOSE="${KWARGS[verbose]:-$VERBOSE}"
QUIET="${KWARGS[quiet]:-$QUIET}"
COLOR="${KWARGS[color]:-$COLOR}"
TIMESTAMPS="${KWARGS[timestamps]:-$TIMESTAMPS}"
LOGLEVELS="${KWARGS[loglevels]:-$LOGLEVELS}"
# Load config from file
if [[ "$CONFIG" ]]; then
[[ ! -f "$CONFIG" ]] && fatal "Unable to find config file at $CONFIG."
source "$CONFIG" || {
fatal "Unable to load config values from $CONFIG."
}
fi
# Validate config
if [[ ! "$DOMAIN" || ! "$TYPE" ]]; then
fatal "Missing [domain] or [type] argument (pass --help for usage and examples)."
fi
if [[ "$REFRESH" ]] && ! ((REFRESH>0)); then
fatal "Invalid refresh value $REFRESH (pass --help for usage and examples)."
fi
[[ "$APIS" == "all" ]] && APIS="$AVAILABLE_APIS"
# Begin check/update process
((REFRESH>0)) && INTERVAL="every $REFRESH seconds" || INTERVAL="once"
if [[ "${KWARGS[set]}" ]]; then
info "Starting: ${APIS[*]}/$DOMAIN/$TYPE=$VALUE (updating $INTERVAL)..."
else
info "Starting: ${APIS[*]}/$DOMAIN/$TYPE (checking $INTERVAL)..."
fi
while :; do
for API in ${APIS//,/ }; do
case "$API" in
digitalocean|'do')
API="do"
[[ "$DO_API_KEY" == "$API_KEY_PLACEHOLDER" ]] && {
fatal "You must pass your DO_API_KEY via environment variable or --config=file.env (pass --help for more info)."
};;
cloudflare|'cf')
API="cf"
[[ "$CF_API_KEY" == "$API_KEY_PLACEHOLDER" ]] && {
fatal "You must pass your CF_API_KEY via environment variable or --config=file.env (pass --help for more info)."
};;
*)
fatal "Invalid API type '$API'. (must be one or more of: $AVAILABLE_APIS)";;
esac
if [[ "${KWARGS[set]}" ]]; then
timed "$TIMEOUT" update_record "$API" "$DOMAIN" "$TYPE" "$VALUE" "$TTL" "$PROXIED"
else
timed "$TIMEOUT" get_record "$API" "$DOMAIN" "$TYPE"
fi
done
((REFRESH>0)) || break
sleep "$REFRESH"
done
}
main "$@"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment