#!/usr/bin/env bash ######################################################################################### #### dns-propagation-checker.sh — Check DNS propagation across public resolvers #### #### Queries Cloudflare, Google, Quad9, OpenDNS, compares results #### #### #### #### Author: Phil Connor #### #### Contact: contact@mylinux.work #### #### License: MIT #### #### Version 1.00 #### #### #### #### Usage: #### #### ./dns-propagation-checker.sh example.com #### #### ./dns-propagation-checker.sh example.com --type MX #### #### ./dns-propagation-checker.sh example.com --watch 30 #### #### #### #### See --help for all options. #### ######################################################################################### set -euo pipefail # ── Defaults ────────────────────────────────────────────────────────── DOMAIN="" RECORD_TYPE="A" TIMEOUT=5 COLOR="auto" JSON_OUTPUT="false" WATCH_INTERVAL=0 EXPECTED="" # ── State ───────────────────────────────────────────────────────────── SCRIPT_NAME="$(basename "$0")" readonly SCRIPT_NAME # ── Built-in Resolvers ─────────────────────────────────────────────── RESOLVER_NAMES=("Cloudflare" "Google" "Quad9" "OpenDNS" "Cloudflare-2" "Google-2") RESOLVER_IPS=("1.1.1.1" "8.8.8.8" "9.9.9.9" "208.67.222.222" "1.0.0.1" "8.8.4.4") CUSTOM_RESOLVERS=() # ── Colors ──────────────────────────────────────────────────────────── setup_colors() { if [[ "$COLOR" == "never" ]]; then RED="" GREEN="" YELLOW="" BOLD="" DIM="" RESET="" return fi if [[ "$COLOR" == "always" ]] || [[ -t 1 ]]; then RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[0;33m' BOLD='\033[1m' DIM='\033[2m' RESET='\033[0m' else RED="" GREEN="" YELLOW="" BOLD="" DIM="" RESET="" fi } # ── Logging ─────────────────────────────────────────────────────────── err() { echo -e "${RED}[ERROR]${RESET} $*" >&2; } warn() { echo -e "${YELLOW}[WARN]${RESET} $*" >&2; } # ══════════════════════════════════════════════════════════════════════ # USAGE # ══════════════════════════════════════════════════════════════════════ usage() { cat <&2 exit 1 ;; *) if [[ -z "$DOMAIN" ]]; then DOMAIN="$1" else err "Unexpected argument: $1" exit 1 fi shift ;; esac done if [[ -z "$DOMAIN" ]]; then err "Domain name is required" echo "Run ${SCRIPT_NAME} --help for usage" >&2 exit 1 fi local valid_types="A AAAA MX CNAME TXT NS SOA PTR" if [[ ! " $valid_types " =~ " $RECORD_TYPE " ]]; then err "Invalid record type: $RECORD_TYPE" exit 1 fi } # ══════════════════════════════════════════════════════════════════════ # DNS QUERY # ══════════════════════════════════════════════════════════════════════ query_resolver() { local resolver_ip="$1" domain="$2" rtype="$3" timeout="$4" local output ttl_output answer ttl output=$(dig +time="$timeout" +tries=1 +short "@${resolver_ip}" "$domain" "$rtype" 2>/dev/null) || true ttl_output=$(dig +time="$timeout" +tries=1 +noall +answer "@${resolver_ip}" "$domain" "$rtype" 2>/dev/null) || true answer=$(echo "$output" | tr '\n' ' ' | sed 's/ *$//') ttl=$(echo "$ttl_output" | awk 'NR==1{print $2}') if [[ -z "$answer" ]]; then echo "FAIL||"; return fi echo "${answer}|${ttl:-?}|" } # ══════════════════════════════════════════════════════════════════════ # MAJORITY ANSWER # ══════════════════════════════════════════════════════════════════════ find_majority() { local -n answers_ref=$1 local -A counts local max_count=0 majority="" for answer in "${answers_ref[@]}"; do [[ "$answer" == "FAIL" ]] && continue counts["$answer"]=$(( ${counts["$answer"]:-0} + 1 )) if [[ ${counts["$answer"]} -gt $max_count ]]; then max_count=${counts["$answer"]}; majority="$answer" fi done echo "$majority" } # ══════════════════════════════════════════════════════════════════════ # RUN CHECK # ══════════════════════════════════════════════════════════════════════ run_check() { local all_names=("${RESOLVER_NAMES[@]}") local all_ips=("${RESOLVER_IPS[@]}") for custom in "${CUSTOM_RESOLVERS[@]}"; do all_names+=("Custom-${custom}") all_ips+=("$custom") done local total=${#all_names[@]} local answers=() ttls=() for i in $(seq 0 $(( total - 1 ))); do local result result=$(query_resolver "${all_ips[$i]}" "$DOMAIN" "$RECORD_TYPE" "$TIMEOUT") answers+=("$(echo "$result" | cut -d'|' -f1)") ttls+=("$(echo "$result" | cut -d'|' -f2)") done local majority majority=$(find_majority answers) local compare_to="${EXPECTED:-$majority}" local agree_count=0 statuses=() for i in $(seq 0 $(( total - 1 ))); do if [[ "${answers[$i]}" == "FAIL" ]]; then statuses+=("FAIL") elif [[ "${answers[$i]}" == "$compare_to" ]]; then statuses+=("MATCH"); agree_count=$((agree_count + 1)) else statuses+=("MISMATCH") fi done if [[ "$JSON_OUTPUT" == "true" ]]; then print_json all_names all_ips answers ttls statuses "$agree_count" "$total" "$majority" else print_table all_names all_ips answers ttls statuses "$agree_count" "$total" "$majority" "$compare_to" fi [[ "$agree_count" -eq "$total" ]] && return 0 || return 1 } # ══════════════════════════════════════════════════════════════════════ # OUTPUT: TABLE # ══════════════════════════════════════════════════════════════════════ print_table() { local -n names_ref=$1 ips_ref=$2 ans_ref=$3 ttl_ref=$4 stat_ref=$5 local agree="$6" total="$7" majority="$8" compare="$9" echo "" echo -e "${BOLD}DNS Propagation Check — ${DOMAIN} (${RECORD_TYPE})${RESET}" echo -e "${DIM}$(date -u '+%Y-%m-%d %H:%M:%S UTC')${RESET}" echo "" printf " ${BOLD}%-20s %-17s %-22s %-6s %s${RESET}\n" "RESOLVER" "IP" "RESULT" "TTL" "STATUS" printf " %s\n" "$(printf '%.0s─' {1..78})" local count=${#names_ref[@]} for i in $(seq 0 $(( count - 1 ))); do local color status_str case "${stat_ref[$i]}" in MATCH) color="$GREEN"; status_str="MATCH" ;; MISMATCH) color="$YELLOW"; status_str="MISMATCH" ;; FAIL) color="$RED"; status_str="FAIL" ;; esac local display_answer="${ans_ref[$i]}" if [[ ${#display_answer} -gt 20 ]]; then display_answer="${display_answer:0:17}..." fi printf " %-20s %-17s %b%-22s%b %-6s %b%s%b\n" \ "${names_ref[$i]}" \ "${ips_ref[$i]}" \ "$color" "$display_answer" "$RESET" \ "${ttl_ref[$i]}" \ "$color" "$status_str" "$RESET" done echo "" echo -e " ${BOLD}Summary${RESET}" if [[ -n "$EXPECTED" ]]; then printf " %-20s %s\n" "Expected answer:" "$EXPECTED" fi printf " %-20s %s\n" "Majority answer:" "${majority:-N/A}" printf " %-20s %s\n" "Agree:" "${agree}/${total} resolvers" if [[ "$agree" -eq "$total" ]]; then printf " %-20s " "Status:"; echo -e "${GREEN}PROPAGATION COMPLETE${RESET}" else printf " %-20s " "Status:"; echo -e "${YELLOW}PROPAGATION PENDING${RESET}" fi echo "" } # ══════════════════════════════════════════════════════════════════════ # OUTPUT: JSON # ══════════════════════════════════════════════════════════════════════ print_json() { local -n jnames=$1 jips=$2 jans=$3 jttls=$4 jstats=$5 local agree="$6" total="$7" majority="$8" local count=${#jnames[@]} propagated="false" [[ "$agree" -eq "$total" ]] && propagated="true" printf '{"domain":"%s","type":"%s","timestamp":"%s","results":[' \ "$DOMAIN" "$RECORD_TYPE" "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" for i in $(seq 0 $(( count - 1 ))); do [[ $i -gt 0 ]] && printf ',' local escaped_answer escaped_answer=$(echo "${jans[$i]}" | sed 's/"/\\"/g') printf '{"resolver":"%s","ip":"%s","answer":"%s","ttl":"%s","status":"%s"}' \ "${jnames[$i]}" "${jips[$i]}" "$escaped_answer" "${jttls[$i]}" "${jstats[$i]}" done local escaped_majority escaped_majority=$(echo "$majority" | sed 's/"/\\"/g') printf '],"summary":{"majority":"%s","agree":%d,"total":%d,"propagated":%s}}\n' \ "$escaped_majority" "$agree" "$total" "$propagated" } # ══════════════════════════════════════════════════════════════════════ # MAIN # ══════════════════════════════════════════════════════════════════════ main() { parse_args "$@" setup_colors if ! command -v dig &>/dev/null; then err "dig is required but not found. Install dnsutils (Debian/Ubuntu) or bind-utils (RHEL/CentOS)." exit 1 fi if [[ "$WATCH_INTERVAL" -gt 0 ]]; then local cycle=1 while true; do if [[ "$JSON_OUTPUT" != "true" ]]; then [[ $cycle -gt 1 ]] && echo -e "${DIM}────────────────────────────────────────────────${RESET}" echo -e "${DIM}Watch cycle ${cycle} — checking every ${WATCH_INTERVAL}s (Ctrl+C to stop)${RESET}" fi if run_check; then [[ "$JSON_OUTPUT" != "true" ]] && echo -e " ${GREEN}All resolvers agree. Propagation complete.${RESET}\n" exit 0 fi cycle=$((cycle + 1)) sleep "$WATCH_INTERVAL" done else run_check && exit 0 || exit 1 fi } main "$@"