#!/bin/sh
usage() {
printf "\\nUsage: %s [-5] [-6] [-l TTL] [-f] [-t] [-e] [-v] [-s] [-i EXT_IF] [-a APIKEY] -d EXAMPLE.COM -r \"RECORD-NAMES\"
-5: Use Gandi's Legacy LiveDNS platform
-6: Update AAAA record(s) instead of A record(s)
-l: Set a custom TTL on records (only supported on LiveDNS)
-f: Force the creation of a new zonefile regardless of IP address or TTL discrepancy
-t: On Gandi's legacy DNS platform, if a new version of the zonefile is created, don't activate it. On LiveDNS, just print the updates that would be made if this flag wasn't used.
-e: Print debugging information to stdout
-v: Print information to stdout even if an update isn't needed
-s: Use stdin instead of iCanHazIP to determine external IP address
-i: Use ifconfig instead of iCanHazIP to determine external IP address
TTL: The custom TTL value (in seconds) to set on all records
EXT_IF: The name of your external network interface
APIKEY: Your API key provided by Gandi (loaded from the file ~/.gandiapi if not specified)
EXAMPLE.COM: The domain name whose active zonefile will be updated
RECORD-NAMES: A space-separated list of the name(s) of the A or AAAA record(s) to update or create\\n\\n" "$0"
exit 1
}
#
# Process parameters
#
while [ $# -gt 0 ]; do
case "$1" in
-5) v5="yes";;
-6) ipv6="yes";;
-l) ttl="$2"; shift;;
-f) force="yes";;
-t) testing="yes";;
-e) debug="yes";;
-v) verbose="yes";;
-s) stdin_ip="yes";;
-i) ext_if="$2"; shift;;
-a) apikey="$2"; shift;;
-d) domain="$2"; shift;;
-r) records="$2"; shift;;
*) usage; break
esac
shift
done
if [ -z "$domain" -o -z "$records" ]; then
usage
fi
if [ -z "$apikey" ]; then
if [ -f "${HOME}/.gandiapi" ]; then
apikey=$(cat "${HOME}/.gandiapi")
else
usage
fi
fi
if [ ! -z "$ttl" -a "$v5" != "yes" ]; then
printf "Setting a custom TTL on records is not supported on Gandi's legacy DNS platform.\\n"
exit 1
fi
if [ "$ipv6" = "yes" ]; then
record_type="AAAA"
ip_regex="\([0-9A-Fa-f:]*\)"
inet="inet6"
else
record_type="A"
ip_regex="\([0-9]*\.[0-9]*\.[0-9]*\.[0-9]*\)"
inet="inet"
fi
if [ "$debug" = "yes" ]; then
printf "Initial variables:\\n---\\napikey = %s\\ndomain = %s\\nrecords = %s\\nttl (only relevant with LiveDNS) = %s\\nrecord_type = %s\\nip_regex = %s\\n---\\n\\n" "$apikey" "$domain" "$records" "$ttl" "$record_type" "$ip_regex"
fi
#
# Set API address and script version
#
if [ "$v5" = "yes" ]; then # Swapping defaults
gandi="rpc.gandi.net:443"
else
gandi="dns.api.gandi.net:443"
fi
gad_version="1.4.2"
#
# Function to call Gandi's v5/LiveDNS REST API
#
# $1 is the HTTP verb. Only GET, PUT, and POST are used in this script.
# $2 is the API endpoint
# $3 is the body of the request. If the verb is GET and a third parameter is
# provided, it is ignored.
#
rest() {
if [ "$debug" = "yes" ]; then
printf "REST call to endpoint:\\n---\\n%s\\n---\\n\\n" "$2" 1>&2
fi
# Throw away third argument to function if verb is GET
if [ "$1" != "GET" ]; then
tmp_json="$3"
fi
tmp_request="${1} /api/v5/${2} HTTP/1.1
User-Agent: Gandi Automatic DNS shell script/${gad_version}
Host: $(printf "%s" "$gandi" | cut -d ':' -f 1 -)
Content-Type: application/json
Content-Length: $(printf "%s" "$tmp_json" | wc -c | tr -d "[:space:]")
X-Api-Key: ${apikey}
"
if [ "$1" != "GET" ]; then
tmp_message="${tmp_request}${tmp_json}"
else
tmp_message="$tmp_request"
fi
if [ "$debug" = "yes" ]; then
printf "Sending REST message tmp_message:\\n---\\n%s\\n---\\n\\n" "$tmp_message" 1>&2
fi
printf "%s" "$tmp_message" | openssl s_client -quiet -connect "$gandi" 2> /dev/null | tail -1
unset tmp_json
unset tmp_request
unset tmp_message
}
#
# Function to get a specified field from JSON
#
# This probably won't work with arbitrary JSON for input, but it works for the
# JSON that is returned by the Gandi API endpoints that are used by this
# script. The function returns nothing if the field isn't found.
#
# $1 is the field that you want to get the value of
# $2 is the JSON content
#
get_json_field() {
# Set $updated_json to the provided JSON content
updated_json="$2"
# This helps handle JSON lists by editing list values to be separated by
# spaces instead of commas, so we can split JSON fields on commas with awk
# later, and loop over them to find the value we want
while true; do
# Find the first instance of [ and replace content up to the first comma
# with the content followed by a space
updated_json=$(printf "%s" "$updated_json" | sed 's/\(\[[^,]*"\),/\1 /g')
# Check for the pattern again, and restart the loop if grep returns 0
# indicating the pattern was found. Otherwise break out of this loop.
if printf "%s" "$updated_json" | grep -e '\[[^,]*",' > /dev/null; then
continue
else
break
fi
done
# Set internal field separator to a new line, so our for loop loops over the
# records returned on separate lines by the awk command below
IFS='
'
# Trim out curly braces, anything like a newline, split records on ", " with
# awk, replace any leading whitespace with sed, and loop over records.
for i in $(printf "%s" "$updated_json" | tr -d '{}\t\n\r\f' | awk 'BEGIN{RS=", "}{print $0}' | sed 's/^ //'); do
# Find the current field name and value by using ": " as the awk field
# separator, and treating field 1 as the name and field 2 as the value.
# Also trim out any double quotes and brackets.
field_name=$(printf "%s" "$i" | awk -F ': ' '{print $1}' | tr -d '"' | tr -d '[]')
field_value=$(printf "%s" "$i" | awk -F ': ' '{print $2}' | tr -d '"' | tr -d '[]')
# If the current field name matches the one we're looking for, print the
# value and break out of this loop
if [ "$field_name" = "$1" ]; then
printf "%s" "$field_value"
break
fi
done
unset IFS
}
#
# Function to call Gandi's legacy XML-RPC API
#
# $1 is the API method
# $2 and all subsequent arguments are datatype/value pairs (for the first pair,
# $2 would be the datatype and $3 would be the value) or structs ($2 would
# be "struct", $3 would be the name of the struct, $4 would be the datatype,
# and $5 would be the value. Structs must come after any datatype/value
# pairs.
#
rpc() {
if [ "$debug" = "yes" ]; then
printf "RPC call to methodName:\\n---\\n%s\\n---\\n\\n" "$1" 1>&2
fi
tmp_xml="
${1}
${apikey}
"
shift
while [ ! -z "$1" ]; do
if [ "$1" != "struct" ]; then
tmp_xml="${tmp_xml}
<${1}>${2}${1}>
"
shift; shift
else
tmp_xml="${tmp_xml}
"
shift;
while [ ! -z "$1" ]; do
if [ "$1" != "struct" ]; then
tmp_xml="${tmp_xml}
${1}
<${2}>${3}${2}>
"
shift; shift; shift;
else
break
fi
done
tmp_xml="${tmp_xml}
"
fi
done
tmp_xml="${tmp_xml}
"
tmp_post="POST /xmlrpc/ HTTP/1.1
User-Agent: Gandi Automatic DNS shell script/${gad_version}
Host: $(printf "%s" "$gandi" | cut -d ':' -f 1 -)
Content-Type: text/xml
Content-Length: $(printf "%s" "$tmp_xml" | wc -c | tr -d "[:space:]")
"
tmp_message="${tmp_post}${tmp_xml}"
if [ "$debug" = "yes" ]; then
printf "Sending XML-RPC message tmp_message:\\n---\\n%s\\n---\\n\\n" "$tmp_message" 1>&2
fi
printf "%s" "$tmp_message" | openssl s_client -quiet -connect "$gandi" 2> /dev/null
unset tmp_xml
unset tmp_post
unset tmp_message
}
#
# Function to update existing DNS records with a new value
#
# $1 is a space-separated list of record names to update
#
update() {
while [ ! -z "$1" ]; do
if [ "$v5" = "yes" ]; then
new_record_id=$(rpc "domain.zone.record.list" "int" "$zone_id" "int" "$new_version_id" "struct" "name" "string" "$1" "type" "string" "$record_type" | grep -A 1 ">id<" | sed -n 's/.*\([0-9]*\).*/\1/p')
if [ "$debug" = "yes" ]; then
printf "new_record_id:\\n---\\n%s\\n---\\n\\n" "$new_record_id"
fi
rpc "domain.zone.record.update" "int" "$zone_id" "int" "$new_version_id" "struct" "id" "int" "$new_record_id" "struct" "name" "string" "$1" "type" "string" "$record_type" "value" "string" "$ext_ip"
else
new_record_json=$(rest "PUT" "zones/${zone_id}/records/${1}/${record_type}" "{\"rrset_ttl\": \"${new_ttl}\", \"rrset_values\": [\"${ext_ip}\"]}")
new_record_message=$(get_json_field "message" "$new_record_json")
if [ "$debug" = "yes" ]; then
printf "new_record_json:\\n---\\n%s\\n---\\n\\n" "$new_record_json"
printf "new_record_message:\\n---\\n%s\\n---\\n\\n" "$new_record_message"
fi
fi
shift
done
}
#
# Function to create new DNS records
#
# $1 is a space-separated list of record names to create
#
create() {
while [ ! -z "$1" ]; do
if [ "$v5" = "yes" ]; then
rpc "domain.zone.record.add" "int" "$zone_id" "int" "$new_version_id" "struct" "name" "string" "$1" "type" "string" "$record_type" "value" "string" "$ext_ip"
else
new_record_json=$(rest "POST" "zones/${zone_id}/records/${1}/${record_type}" "{\"rrset_ttl\": \"${new_ttl}\", \"rrset_values\": [\"${ext_ip}\"]}")
new_record_message=$(get_json_field "message" "$new_record_json")
if [ "$debug" = "yes" ]; then
printf "new_record_json:\\n---\\n%s\\n---\\n\\n" "$new_record_json"
printf "new_record_message:\\n---\\n%s\\n---\\n\\n" "$new_record_message"
fi
fi
shift
done
}
#
# Function to check existing DNS information and see if it matches the external
# IP address (and TTL in the case of LiveDNS)
#
# $1 is a space-separated list of record names to check
#
check() {
while [ ! -z "$1" ]; do
if [ "$v5" = "yes" ]; then
record_value=$(rpc "domain.zone.record.list" "int" "$zone_id" "int" "0" "struct" "name" "string" "$1" "type" "string" "$record_type" | grep -A 1 ">value<" | sed -n "s/.*${ip_regex}.*/\1/p")
record_count=$(printf "%s" "$record_value" | wc -w)
else
record_json=$(rest "GET" "zones/${zone_id}/records/${1}/${record_type}")
if [ "$debug" = "yes" ]; then
printf "record_json:\\n---\\n%s\\n---\\n\\n" "$record_json"
fi
record_value=$(get_json_field "rrset_values" "$record_json")
if [ "$debug" = "yes" ]; then
printf "record_value:\\n---\\n%s\\n---\\n\\n" "$record_value"
fi
record_ttl=$(get_json_field "rrset_ttl" "$record_json")
record_count=$(printf "%s" "$record_value" | wc -w)
# If a custom TTL wasn't provided, just set it to the existing one.
# If the record TTL is empty (because the record doesn't exist) and
# no custom TTL was provided, set a default.
if [ -z "$record_ttl" -a -z "$ttl" ]; then
new_ttl="10800"
elif [ -z "$ttl" ]; then
new_ttl="$record_ttl"
else
new_ttl="$ttl"
fi
fi
if [ "$record_count" -gt "1" ]; then
printf "Sorry, but gad does not support updating multiple records with the same name.\\n"
exit 1
elif [ -z "$record_value" ]; then
if [ -z "$records_to_create" ]; then
records_to_create="$1"
else
records_to_create="${records_to_create} ${1}"
fi
elif [ "$ext_ip" != "$record_value" -o "$new_ttl" != "$record_ttl" -o "$force" = "yes" ]; then
if [ -z "$records_to_update" ]; then
records_to_update="$1"
else
records_to_update="${records_to_update} ${1}"
fi
fi
if [ "$debug" = "yes" ]; then
printf "Results after checking record:\\n---\\nrecord: %s\\nrecord_value: %s\\nrecords_to_create: %s\\nrecords_to_update: %s\\n---\\n\\n" "$1" "$record_value" "$records_to_create" "$records_to_update"
fi
shift
done
}
#
# Get correct IP address
#
if [ "$stdin_ip" = "yes" ]; then
ext_ip_method="standard input"
read ext_ip
elif [ ! -z "$ext_if" ]; then
ext_ip_method="ifconfig ${ext_if}"
ext_ip=$(ifconfig "$ext_if" | sed -n "s/.*${inet} \(addr:\)* *${ip_regex}.*/\2/p" | head -1)
else
ext_ip_method="ICanHazIP.com"
if [ "$record_type" = "A" ]; then
ext_ip=$(curl -s -4 https://ipv4.icanhazip.com/)
else
ext_ip=$(curl -s -6 https://ipv6.icanhazip.com/)
fi
fi
if [ -z "$ext_ip" ]; then
printf "Failed to determine external IP address with %s. See above error.\\n" "$ext_ip_method"
exit 1
fi
if [ "$debug" = "yes" ]; then
printf "IP information:\\n---\\next_ip_method: %s\\next_ip: %s\\n---\\n\\n" "$ext_ip_method" "$ext_ip"
fi
#
# Get the active zonefile for the domain
#
if [ "$v5" = "yes" ]; then
zone_id=$(rpc "domain.info" "string" "$domain" | grep -A 1 zone_id | sed -n 's/.*\([0-9]*\).*/\1/p')
else
domain_json=$(rest "GET" "domains/${domain}")
if [ "$debug" = "yes" ]; then
printf "domain_json:\\n---\\n%s\\n---\\n\\n" "$domain_json"
fi
zone_id=$(get_json_field "zone_uuid" "$domain_json")
fi
if [ -z "$zone_id" ]; then
printf "No zone_id returned. This is expected with Gandi's test API or if you send a LiveDNS API key to Gandi's legacy API. Use gad's -t flag for testing or the -5 flag for LiveDNS.\\n"
exit 1
fi
if [ "$debug" = "yes" ]; then
printf "zone_id:\\n---\\n%s\\n---\\n\\n" "$zone_id"
fi
#
# Check values of records in the active version of the zonefile
#
set -f
check $records
set +f
#
# If there are any mismatches, create a new version of the zonefile, update the incorrect records, and activate it
#
if [ ! -z "$records_to_update" -o ! -z "$records_to_create" ]; then
if [ "$v5" = "yes" ]; then
new_version_id=$(rpc "domain.zone.version.new" "int" "$zone_id" | sed -n 's/.*\([0-9]*\).*/\1/p')
if [ "$debug" = "yes" ]; then
printf "new_version_id:\\n---\\n%s\\n---\\n\\n" "$new_version_id"
fi
set -f
update $records_to_update
create $records_to_create
set +f
if [ "$testing" != "yes" ]; then
printf "Activating version %s of the zonefile for domain %s...\\n\\nopenssl s_client output and domain.zone.version.set() method response:\\n\\n" "$new_version_id" "$domain"
rpc "domain.zone.version.set" "int" "$zone_id" "int" "$new_version_id"
printf "\\nTried to update the following %s records to %s: %s %s\\n\\nThere is no error checking on the RPCs so check the web interface if you want to be sure the update was successful, or look at the methodResponse from domain.zone.version.set() above (a response of "1" means success).\\n" "$record_type" "$ext_ip" "$records_to_update" "$records_to_create"
else
printf "Created version %s of the zonefile for domain %s.\\n\\nTried to update the following %s records to %s: %s %s\\n\\nThere is no error checking on the RPCs so check the web interface if you want to be sure the update was successful.\\n" "$new_version_id" "$domain" "$record_type" "$ext_ip" "$records_to_update" "$records_to_create"
fi
exit
else
new_snapshot_json=$(rest "POST" "zones/${zone_id}/snapshots" "")
new_snapshot_id=$(get_json_field "uuid" "$new_snapshot_json")
if [ "$debug" = "yes" ]; then
printf "new_snapshot_json:\\n---\\n%s\\n---\\n\\n" "$new_snapshot_json"
printf "new_snapshot_id:\\n---\\n%s\\n---\\n\\n" "$new_snapshot_id"
fi
if [ "$testing" != "yes" ]; then
set -f
update $records_to_update
create $records_to_create
set +f
printf "Created a new snapshot and tried to update the following live %s records to %s with TTL of %s seconds: %s %s\\n" "$record_type" "$ext_ip" "$new_ttl" "$records_to_update" "$records_to_create"
else
printf "Testing mode! Not sending any updates to the LiveDNS API.\\nIn non-testing mode, gad would have tried to update the following live %s records to %s with TTL of %s seconds: %s %s\\n" "$record_type" "$ext_ip" "$new_ttl" "$records_to_update" "$records_to_create"
fi
fi
else
if [ "$verbose" = "yes" ]; then
printf "External IP address %s detected with %s and TTL value of %s matches records: %s. No update needed. Exiting.\\n" "$ext_ip" "$ext_ip_method" "$new_ttl" "$records"
fi
exit
fi