#!/bin/sh # Monitoring script to check the expiration of domain names via RDAP and WHOIS. # # Copyright: chl-dev@bugness.org 2025 https://code.bugness.org/ # Licence: WTFPL # Stop at first uncaught error set -e PROGPATH=$( echo $0 | sed -e 's,[\\/][^\\/][^\\/]*$,,' ) REVISION="0.2" # Default values IANA_URL_DOMAINS="https://data.iana.org/rdap/dns.json" IANA_CACHE_FILE="/tmp/.check_expiration_domain_name.cache.json" IANA_CACHE_DURATION=$(( 86400 * 30 )) THRESHOLD_WARNING="2592000" # 30 days THRESHOLD_CRITICAL="691200" # 8 days TIMESTAMP_NOW="$( date +%s )" OPTION_RDAP_QUERYING="yes" OPTION_WHOIS_QUERYING="yes" # Include check_range() # Not needed at the moment #. $PROGPATH/utils.sh STATE_OK=0 STATE_WARNING=1 STATE_CRITICAL=2 STATE_UNKNOWN=3 STATE_DEPENDENT=4 # Output OUTPUT_EXIT_STATUS=0 OUTPUT_DETAIL_WARNING="" OUTPUT_DETAIL_CRITICAL="" OUTPUT_PERFDATA="" # # Help function # usage() { cat </dev/null 2>&1; then wget -q -O - "$1" elif which "curl" >/dev/null 2>&1 ; then curl -s --fail "$1" else echo "No wget/curl/whatever available to make HTTP queries." >&2 return 1 fi } # # Echo the root IANA database # cat_iana_dns() { local PREVIOUS_NOCLOBBER_STATE_CMD="$( set +o | grep noclobber )" # Check that our cache file exists, has not been emptily created by mistake, # and is sufficiently recent. We'll also check that the file belongs to us # afterwards. if [ ! -f "$IANA_CACHE_FILE" ] || [ ! -s "$IANA_CACHE_FILE" ] || [ "$( stat -c %Y "$IANA_CACHE_FILE" )" -lt "$(( $( date +%s ) - $IANA_CACHE_DURATION ))" ]; then rm -f "$IANA_CACHE_FILE" # For security, we activate the 'noclobber' : if a smartass raced us in # creating the file (or a symlink to a dangerous file), the '>' will simply # fail. set -C if ! ERROR_FETCH="$( fetch_with_curl_wget_or_whatever "$IANA_URL_DOMAINS" 2>&1 >"$IANA_CACHE_FILE" )"; then echo "failed to retrieve IANA database ($ERROR_FETCH)" $PREVIOUS_NOCLOBBER_STATE_CMD return 1 fi $PREVIOUS_NOCLOBBER_STATE_CMD fi # Check that the file belongs to us if [ ! -O "$IANA_CACHE_FILE" ]; then echo "Cache file ($IANA_CACHE_FILE) belongs to another user: stopping for security risk." return 1 fi # Output cat "$IANA_CACHE_FILE" } # # Get expiration date from RDAP # (output the date as seconds since Unix epoch) # (error when exit status is != 0 or empty output) # get_expiration_date_rdap() { local DOMAIN="$1" local ROOT="$1" # We truncate and loop until we find a suitable RDAP server (wikipedia.co.uk -> co.uk -> uk) while true; do # Truncate the first part of the domain name ROOT="$( echo "$ROOT" | sed 's/[^.]\+.\?//' )" test -n "$ROOT" || break # Search for the RDAP servers if ! LIST_SERVERS="$( cat_iana_dns )" || ! LIST_SERVERS="$( echo "$LIST_SERVERS" | jq -r ".services[] | select(.[][] == \"$ROOT\") | .[1][]" )"; then echo "Error getting server list: $LIST_SERVERS" return 1 fi # Try every server (usually only one ?) for SERVER in $LIST_SERVERS; do # Query the server # (small regexp to avoid some double '/' in the URL) if OUTPUT="$( fetch_with_curl_wget_or_whatever "$( echo "$SERVER" | sed 's#/$##' )/domain/$DOMAIN" 2>&1 )"; then EXPIRATION_DATE="$( echo "$OUTPUT" | jq -r '.events[] | select(."eventAction" == "expiration") | .eventDate' )" # Small protection against injection if [ -n "$( echo "$EXPIRATION_DATE" | LANG=C sed -n '/^[a-zA-Z0-9:.\-]\{1,64\}$/p' )" ]; then # We directly output the date as seconds from epoch. date --date="$EXPIRATION_DATE" +%s return fi fi done # If we have a non-empty list of servers but didn't manage to get an # expiration date, we return an error. if [ -n "$LIST_SERVERS" ]; then echo "All servers ($LIST_SERVERS) failed for $DOMAIN / $ROOT (last output: $OUTPUT)" return 1 fi done echo "No RDAP server for $DOMAIN" return 1 } # # Get expiration date from WHOIS # (output the date as seconds since Unix epoch) # (error when exit status is != 0 or empty output) # get_expiration_date_whois() { local DOMAIN="$1" local EXPIRATION_DATE # WHOIS query outside of the sed pipe to catch erroneous exit value. if ! WHOIS_OUTPUT="$( whois "$DOMAIN" )"; then return 1 fi # We put each format on a dedicated line # + filter only the first line # Note : some sed implementations seems not to offer # case-insensitive option, so we try not to # use it for the moment. # 1 - kernel.org, icann.org # 2 - coca-cola.com (we try to ignore lines starting with space...) # 3 - facebook.com (...but if there's nothing else, take it anyway) # 4 - some databases with simple display (.se, .si) # 5 - .it # 6 - .ru # 7 - .jp # Add your own filter here :) EXPIRATION_DATE="$( echo "$WHOIS_OUTPUT" | sed -n \ -e 's/^Registry Expiry Date:[[:space:]]\+//p' \ -e '/^[^[:space:]]/s/.*\(xpiry\|xpiration\) Date:[[:space:]]\+//p' \ -e 's/.*\(xpiry\|xpiration\) Date:[[:space:]]\+//p' \ -e 's/^expires\?:[[:space:]]*//p' \ -e 's/^Expire Date:[[:space:]]*//p' \ -e 's/^paid-till:[[:space:]]*//p' \ -e 's/^\[Expires on\][[:space:]]*//p' \ | sed 'q' )" # Small protection against injection if [ -n "$( echo "$EXPIRATION_DATE" | LANG=C sed -n '/^[a-zA-Z0-9:.\-]\{1,64\}$/p' )" ]; then date --date="$EXPIRATION_DATE" +%s fi } # # Check if arg is an integer # (copied from jilles @ http://stackoverflow.com/questions/806906/how-do-i-test-if-a-variable-is-a-number-in-bash ) # is_int() { case "$1" in ''|*[!0-9]*) return 1;; *) return 0;; esac } # Some early checks #if ! which jq >/dev/null 2>&1; then # echo "UNKNOWN: 'jq' command not found." # exit 1 #fi # # Parameters management # while [ "$#" -gt 0 ]; do while getopts hw:c:iIrR f; do case "$f" in 'h') usage exit ;; 'w') THRESHOLD_WARNING="$( convert_to_seconds "$OPTARG" )" ;; 'c') THRESHOLD_CRITICAL="$( convert_to_seconds "$OPTARG" )" ;; 'i') OPTION_WHOIS_QUERYING="yes" ;; 'I') OPTION_WHOIS_QUERYING="" ;; 'r') OPTION_RDAP_QUERYING="yes" ;; 'R') OPTION_RDAP_QUERYING="" ;; \?) usage exit 1 ;; esac done shift $( expr $OPTIND - 1 ) # Little checks if ! is_int "$THRESHOLD_WARNING" || ! is_int "$THRESHOLD_CRITICAL"; then echo "UNKNOWN invalid parameter : one of the threshold is not an integer." exit $STATE_UNKNOWN fi # End of the options, we get the domain name if [ -z "$1" ]; then # No more options but no domain specified ? Weird but well... break fi DOMAIN="$1" shift # Sleeping a bit to add some throttle if we already looped : we try not to # flood the rdap/whois servers. test -n "$EXPIRATION_DATE" && sleep 2 EXPIRATION_DATE="" # Get the expiration date via RDAP if [ -n "$OPTION_RDAP_QUERYING" ]; then if OUTPUT_RDAP="$( get_expiration_date_rdap "$DOMAIN" )" && [ -n "$OUTPUT_RDAP" ]; then EXPIRATION_DATE="$OUTPUT_RDAP" fi fi # If RDAP failed to get an expiration date, we try via WHOIS. if [ -z "$EXPIRATION_DATE" ] && [ -n "$OPTION_WHOIS_QUERYING" ]; then if OUTPUT_WHOIS="$( get_expiration_date_whois "$DOMAIN" )" && [ -n "$OUTPUT_WHOIS" ]; then EXPIRATION_DATE="$OUTPUT_WHOIS" fi fi if [ -z "$EXPIRATION_DATE" ]; then # We couldn't get the date :-( if [ "$OUTPUT_EXIT_STATUS" -eq "$STATE_OK" ]; then OUTPUT_EXIT_STATUS="$STATE_UNKNOWN" fi OUTPUT_DETAIL_UNKNOWN="$OUTPUT_DETAIL_UNKNOWN $DOMAIN:could_not_get_date (output_rdap: $OUTPUT_RDAP)(output_whois: $OUTPUT_WHOIS)" break fi # Dispatch in the OK/Warning/Critical boxes OUTPUT_DOMAIN_DETAIL="$DOMAIN:$( date --date=@$EXPIRATION_DATE +%FT%T%z)" if [ "$EXPIRATION_DATE" -le "$( expr "$TIMESTAMP_NOW" + "$THRESHOLD_CRITICAL" )" ]; then # Domain is critical OUTPUT_EXIT_STATUS="$STATE_CRITICAL" OUTPUT_DETAIL_CRITICAL="$OUTPUT_DETAIL_CRITICAL $OUTPUT_DOMAIN_DETAIL" elif [ "$EXPIRATION_DATE" -le "$( expr "$TIMESTAMP_NOW" + "$THRESHOLD_WARNING" )" ]; then # Domain is warning OUTPUT_DETAIL_WARNING="$OUTPUT_DETAIL_WARNING $OUTPUT_DOMAIN_DETAIL" # we don't change if the status is already Critical # (but we take precedence over Unknown) if [ "$OUTPUT_EXIT_STATUS" -ne "$STATE_CRITICAL" ]; then OUTPUT_EXIT_STATUS="$STATE_WARNING" fi else # Domain is Ok OUTPUT_DETAIL_OK="$OUTPUT_DETAIL_OK $OUTPUT_DOMAIN_DETAIL" fi done # final output case "$OUTPUT_EXIT_STATUS" in "$STATE_OK") echo "OK $OUTPUT_DETAIL_OK" ;; "$STATE_WARNING") echo "WARNING $OUTPUT_DETAIL_WARNING" ;; "$STATE_CRITICAL") echo "CRITICAL $OUTPUT_DETAIL_CRITICAL" ;; "$STATE_UNKNOWN") echo "UNKNOWN $OUTPUT_DETAIL_UNKNOWN" ;; *) echo "WTF" ;; esac exit "$OUTPUT_EXIT_STATUS"