Skip to content

Instantly share code, notes, and snippets.

@jaytaylor
Last active March 7, 2023 04:27
Show Gist options
  • Star 17 You must be signed in to star a gist
  • Fork 3 You must be signed in to fork a gist
  • Save jaytaylor/6273175 to your computer and use it in GitHub Desktop.
Save jaytaylor/6273175 to your computer and use it in GitHub Desktop.
CloudFlare command-line DNS management shell script, now with CloudFlare v4 API support!
#!/usr/bin/env bash
##
# @author Jay Taylor [@jtaylor]
# @date 2013-08-15
#
# @description CloudFlare management script.
#
# Path ENV VAR override.
if test -z "${CF_PATH:-}"; then
CF_PATH="${HOME}/.cloudflare"
fi
# Ensure base path exists.
set -e
mkdir -p "${CF_PATH}" || ( echo "error: failed to create CF_PATH=${CF_PATH}" 1>&2 ; exit 1 )
set +e
##
# Begin Configuration
#
# NB: Hard-code these configuration values or place them in the corresponding file.
tkn=$(test -r "${CF_PATH}/token" && cat "${CF_PATH}/token" || echo '<YOUR_API_TOKEN_HERE>')
email=$(test -r "${CF_PATH}/email" && cat "${CF_PATH}/email" || echo '<YOUR_EMAIL_ADDRESS_HERE>')
zone=$(test -r "${CF_PATH}/zone" && cat "${CF_PATH}/zone" || echo '<YOUR_ZONE_HERE>')
ttl=$(test -r "${CF_PATH}/ttl" && cat "${CF_PATH}/ttl" || echo '<DEFAULT_TTL_SECONDS_HERE>')
# "service_mode" [applies to A/AAAA/CNAME]
# Status of CloudFlare Proxy, 1 = orange cloud, 0 = grey cloud.
cfProxy=0
#
# End Configuration
##
zid=$(test -r "${HOME}/.cloudflare/zid" && cat "${HOME}/.cloudflare/zid" || echo '')
# Attempt zone-id lookup if it is absent.
if test -z "${zid}"; then
response=$(
curl \
--fail \
--silent \
--show-error \
--compressed \
-H "X-Auth-Email: ${email}" \
-H "X-Auth-Key: ${tkn}" \
"https://api.cloudflare.com/client/v4/zones?name=${zone}"
)
rc=$?
if test $rc -ne 0; then
echo "error: failed to lookup zone-id for domain name \"${zone}\", curl exit code=${rc}" 1>&2
exit $rc
else
zid=$(
echo "${response}" | python -c 'import json, sys
data = json.loads(sys.argv[1] if len(sys.argv)>1 else sys.stdin.read())
print(data.get("result", [{}])[0].get("id", "") if len(data.get("result", [])) > 0 else "")'
)
if test -z "${zid}"; then
echo "error: no zone-id found matching domain name \"${zone}\"" 1>&2
exit 1
else
echo "${zid}" > "${HOME}/.cloudflare/zid"
fi
fi
fi
if test -z "$1" || test "$1" = '-h' || test "$1" = '--help'; then
echo "usage: $0 [ACTION] [additionalParameters?]..
ACTION - one of \"create\", \"read\", \"update\", \"delete\", or \"id\"" 1>&2
exit 1
fi
action=$1
# Action aliases.
if test "${action}" = 'add' || test "${action}" = '+'; then action='create'; fi
if test "${action}" = 'edit' || test "${action}" = 'modify' || test "${action}" = '~'; then action='update'; fi
if test "${action}" = 'remove' || test "${action}" = 'rm' || test "${action}" = 'erase' || test "${action}" = '-'; then action='delete'; fi
if test "${action}" = 'list'; then action='read'; fi
# Validate action.
test "${action}" != 'create' && \
test "${action}" != 'read' && \
test "${action}" != 'update' && \
test "${action}" != 'delete' && \
test "${action}" != 'id' && \
echo "error: unrecognized action \"${action}\" (see -h or --help), operation aborted" 1>&2 && exit 1 || true
# Translate requested action to CloudFlare's name for the action.
if test "${action}" = 'create'; then a='rec_new'; fi
if test "${action}" = 'read' || test "${action}" = 'id'; then a='rec_load_all'; fi
if test "${action}" = 'update'; then a='rec_edit'; fi
if test "${action}" = 'delete'; then a='rec_delete'; fi
if test "${action}" = 'id'; then
test -z "$2" && echo 'error: missing required parameter: search query' 1>&2 && exit 1 || true
results=''
page=1
while true; do
searchQuery=$2
response=$(
curl \
--fail \
--silent \
--show-error \
--compressed \
-H "X-Auth-Email: ${email}" \
-H "X-Auth-Key: ${tkn}" \
-H 'Content-Type: application/json' \
"https://api.cloudflare.com/client/v4/zones/${zid}/dns_records?page=${page}&per_page=100"
)
rc=$?
if test $rc -ne 0; then
echo "error: cloudflare dns record query failed on page=${page}, curl exit code=${rc}" 1>&2
exit $rc
fi
result=$(
echo "${response}" | python -c 'import json, sys
data = json.loads(sys.argv[1] if len(sys.argv)>1 else sys.stdin.read())
#sys.stderr.write("%s" % data)
for record in data.get("result", []):
if record["type"].upper() in ("CNAME", "A", "TXT"):
print("{0} {1} {2}".format(record["id"], record["name"], record["content"]))' \
| grep "${searchQuery}"
)
results=$(echo -e -n "${results}\n${result}" | grep -v '^$')
numRecords=$(
echo "${response}" | python -c 'import json, sys
data = json.loads(sys.argv[1] if len(sys.argv)>1 else sys.stdin.read())
sys.stdout.write("%s" % (len(data.get("result", [])),))'
)
if test ${numRecords} -lt 100; then
break
fi
page=$(($page + 1))
done
test -z "${results}" && echo "error: no results found for search query \"${searchQuery}\"" 1>&2 && exit 1 || true
test $(echo "${results}" | wc -l) -gt 1 && echo -e "error: too many results found for search query \"${searchQuery}\":\n${results}" 1>&2 && exit 1 || true
echo "${results}" | cut -d' ' -f1
exit 0
fi
if test "${action}" = 'read'; then
filter=$2
page=1
out='id\tname\tcontent
--\t----\t-------
'
while true; do
response=$(
curl \
--fail \
--silent \
--show-error \
--compressed \
-H "X-Auth-Email: ${email}" \
-H "X-Auth-Key: ${tkn}" \
-H 'Content-Type: application/json' \
"https://api.cloudflare.com/client/v4/zones/${zid}/dns_records?page=${page}&per_page=100"
)
rc=$?
if test $rc -ne 0; then
echo "error: cloudflare dns record query failed on page=${page}, curl exit code=${rc}" 1>&2
exit $rc
fi
out="${out}
$(echo "${response}" | python -c 'import json, sys
data = json.loads(sys.argv[1] if len(sys.argv)>1 else sys.stdin.read())
for record in data.get("result", []):
if record["type"].upper() in ("CNAME", "A", "TXT"):
print("{0}\t{1}\t{2}".format(record["id"], record["name"], record["content"]))' \
| grep "$(test -n "${filter}" && echo "${filter}" || echo '.*')")"
numRecords=$(
echo "${response}" | python -c 'import json, sys
data = json.loads(sys.argv[1] if len(sys.argv)>1 else sys.stdin.read())
sys.stdout.write("%s" % (len(data.get("result", [])),))'
)
if test ${numRecords} -lt 100; then
break
fi
page=$(($page + 1))
done
column -t <<< "$(echo -e "${out}")"
fi
if test "${action}" = 'create'; then
method='POST'
elif test "${action}" = 'update'; then
method='PUT'
elif test "${action}" = 'delete'; then
method='DELETE'
fi
apiUrl="https://api.cloudflare.com/client/v4/zones/${zid}/dns_records"
httpJsonData='{'
if test "${action}" = 'create' || test "${action}" = 'update'; then
test -z "$2" && echo 'error: missing required parameter: subdomain name' 1>&2 && exit 1 || true
httpJsonData="${httpJsonData}\"name\": \"$2\""
test -z "$3" && echo 'error: missing required parameter: ip or cname hostname' 1>&2 && exit 1 || true
if test -z "$4"; then
if test -n "$(echo "$3" | grep '^[0-9\.]\+$')"; then
httpJsonData="${httpJsonData}, \"type\": \"A\""
else
httpJsonData="${httpJsonData}, \"type\": \"CNAME\""
fi
else
httpJsonData="${httpJsonData}, \"type\": \"$4\""
fi
httpJsonData="${httpJsonData}, \"content\": \"$3\", \"ttl\": ${ttl}"
fi
if test "${action}" = 'update'; then
test -z "$2" && echo 'error: missing required parameter: subdomain name' && exit 1 || true
test -z "$3" && echo 'error: missing required parameter: ip-address or target domain name' && exit 1 || true
if test -n "$4"; then
recordId="$4"
else
specifier=$2
echo "info: attempting to resolve record id for \"${specifier}\"" 1>&2
recordId=$("$0" id "${specifier}")
rc=$?
test $rc -ne 0 && echo "error: id resolution failed for specifier \"${specifier}\"" 1>&2 && exit 1 || true
fi
apiUrl="${apiUrl}/${recordId}"
httpJsonData="${httpJsonData}, \"id\": \"${recordId}\""
fi
httpJsonData="${httpJsonData}}"
if test "${action}" = 'delete'; then
test -z "$2" && echo 'error: missing required parameter: record specifier (id, or name to resolve to id)' 1>&2 && exit 1 || true
# Test if specifier
specifier=$2
if test -z "$(echo "${specifier}" | grep '^[0-9a-f]\+')"; then
echo "info: attempting to resolve record id for \"${specifier}\""
recordId=$("$0" id "${specifier}")
rc=$?
test $rc -ne 0 && echo "error: id resolution failed for specifier \"${specifier}\"" 1>&2 && exit 1 || true
fi
apiUrl="${apiUrl}/${recordId}"
httpJsonData=''
fi
if test "${action}" = 'create' || test "${action}" = 'update' || test "${action}" = 'delete'; then
curl \
--silent \
--show-error \
--fail \
--compressed \
-X "${method}" \
-H 'Content-Type: application/json' \
-H "X-Auth-Email: ${email}" \
-H "X-Auth-Key: ${tkn}" \
--data "${httpJsonData}" \
"${apiUrl}"
rc=$?
echo ''
if test $rc -ne 0; then
echo "error: failed to ${action} record, curl return code=${rc}" 1>&2
exit $rc
fi
fi
exit 0
@cf-b
Copy link

cf-b commented Aug 19, 2013

You may want to modify your script to support DNS record listing returns with more than 180 DNS records (the maximum limit per API response). Multiple calls are necessary, specifying an offset to paginate through the records. Here's an example of this in PHP:

$records = array();
$i = 0;

while(true) {
    $response['response']['recs']['has_more'] = 0;
    $params_array = array(
        'tkn' => $user_api_key,
        'email' => $user_email,
        'a' => 'rec_load_all',
        'z' => $zone_name,
        'o' => $i
    );
    $response = perform_request($params_array);

    foreach ($response['response']['recs']['objs'] as $record) {
        $records[] = $record;
    }
    if ($response['response']['recs']['has_more'] == '1') {
        $i = $i + 180;
    } else {
        break 1;
    }
}

@jaytaylor
Copy link
Author

@cf-b Thanks for the helpful suggestion.

As a sidenote: I guess GitHub doesn't send email notifications for gists, because I just saw this now -- 14 months after you posted the message.

@dgehri
Copy link

dgehri commented Aug 2, 2016

Hi Jay,

CloudFlare will migrate to a new API version in November 2016 (https://www.cloudflare.com/migrating-to-v4/). Do you intend to update your script to support the new API?

  • Daniel

@jaytaylor
Copy link
Author

It now uses the V4 API. Thank you for your help @dgehri!

@mitjax
Copy link

mitjax commented Aug 26, 2016

What is the proper way to use delete?

./cf.sh delete demo
curl: (22) The requested URL returned error: 400 Bad Request

error: failed to delete record, curl return code=22

@mitjax
Copy link

mitjax commented Aug 26, 2016

Just found out, you probably have a typo, change that to:
--- cf.sh.orig 2016-08-26 17:53:26.544435110 +0200
+++ cf.sh 2016-08-26 23:31:41.496494641 +0200
@@ -245,7 +245,7 @@
test -z "$2" && echo 'error: missing required parameter: record specifier (id, or name to resolve to id)' 1>&2 && exit 1 || true
# Test if specifier
specifier=$2

  • if test -z "$(echo "${specifier}" | grep '^[0-9a-f]+')"; then
  • if test -n "$(echo "${specifier}" | grep '^[0-9a-f]+')"; then
    echo "info: attempting to resolve record id for "${specifier}""
    recordId=$("$0" id "${specifier}")
    rc=$?

@ylluminate
Copy link

ylluminate commented Oct 10, 2016

Have you considered putting more detailed help output into this? Would be nice to have some examples; eg, in my use case I want to take ngrok output and assign the temporarily assigned domain to a CloudFlare name alias. It'd be neat to see this kind of example in a help.

@hassanhashmy
Copy link

hassanhashmy commented Feb 14, 2021

the description is nothing https://gist.github.com/ylluminate @ylluminate @mitjax how to run simple add a cname against this like
demo.example.co cnam kkjbjlknllmlklmkbknl ? is it working script?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment