#!/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} " shift; shift else tmp_xml="${tmp_xml} " shift; while [ ! -z "$1" ]; do if [ "$1" != "struct" ]; then tmp_xml="${tmp_xml} ${1} <${2}>${3} " 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