#!/usr/bin/env bash ##################################################################################### #### dns-smoke-tests.sh — Verify DNS infrastructure is healthy #### #### Checks resolution, zone transfers, SOA, DNSSEC, response time, DoT. #### #### #### #### Author: Phil Connor #### #### Contact: contact@mylinux.work #### #### License: MIT #### #### Version: 1.0 #### #### #### #### Usage: ./dns-smoke-tests.sh #### #### DNS_SERVER=192.168.1.1 DOMAIN=example.com ./dns-smoke-tests.sh #### #### #### #### See --help for all options. #### ##################################################################################### set -euo pipefail # ── Defaults ────────────────────────────────────────────────────────── DNS_SERVER="${DNS_SERVER:-}" DOMAIN="${DOMAIN:-example.com}" REVERSE_IP="${REVERSE_IP:-}" ZONE="${ZONE:-}" ZONE_MASTER="${ZONE_MASTER:-}" DNSSEC_DOMAIN="${DNSSEC_DOMAIN:-}" DOT_SERVER="${DOT_SERVER:-}" MAX_RESPONSE_MS="${MAX_RESPONSE_MS:-500}" TEST_RECORDS="${TEST_RECORDS:-}" OUTPUT_FORMAT="${OUTPUT_FORMAT:-text}" VERBOSE="${VERBOSE:-false}" COLOR="${COLOR:-auto}" # ── State ───────────────────────────────────────────────────────────── PASS=0; FAIL=0; SKIP=0; TOTAL=0 RESULTS=() START_TIME="" # ── Dig tool detection ─────────────────────────────────────────────── DIG_CMD="" detect_dig() { if command -v dig >/dev/null 2>&1; then DIG_CMD="dig" elif command -v drill >/dev/null 2>&1; then DIG_CMD="drill" else err "Neither dig nor drill found. Install dnsutils or ldns." exit 1 fi verbose "Using ${DIG_CMD} for DNS queries" } # ── Colors ──────────────────────────────────────────────────────────── setup_colors() { if [[ "$COLOR" == "never" ]]; then RED="" GREEN="" YELLOW="" BLUE="" BOLD="" RESET="" return fi if [[ "$COLOR" == "always" ]] || [[ -t 1 ]]; then RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[0;33m' BLUE='\033[0;34m' BOLD='\033[1m' RESET='\033[0m' else RED="" GREEN="" YELLOW="" BLUE="" BOLD="" RESET="" fi } # ── Logging ─────────────────────────────────────────────────────────── log() { echo -e "${BLUE}[INFO]${RESET} $*"; } warn() { echo -e "${YELLOW}[WARN]${RESET} $*" >&2; } err() { echo -e "${RED}[ERROR]${RESET} $*" >&2; } verbose() { if [[ "$VERBOSE" == "true" ]]; then echo -e "${BLUE}[DEBUG]${RESET} $*"; fi; } # ── Test Result Recording ───────────────────────────────────────────── record_pass() { local name="$1" detail="${2:-}" ((PASS++)) || true; ((TOTAL++)) || true RESULTS+=("PASS|${name}|${detail}") if [[ "$OUTPUT_FORMAT" == "tap" ]]; then echo "ok ${TOTAL} - ${name}${detail:+ (${detail})}" else echo -e " ${GREEN}✓${RESET} ${name}${detail:+ — ${detail}}"; fi } record_fail() { local name="$1" detail="${2:-}" ((FAIL++)) || true; ((TOTAL++)) || true RESULTS+=("FAIL|${name}|${detail}") if [[ "$OUTPUT_FORMAT" == "tap" ]]; then echo "not ok ${TOTAL} - ${name}" [[ -n "$detail" ]] && echo " # ${detail}" else echo -e " ${RED}✗${RESET} ${name}${detail:+ — ${detail}}"; fi } record_skip() { local name="$1" reason="${2:-}" ((SKIP++)) || true; ((TOTAL++)) || true RESULTS+=("SKIP|${name}|${reason}") if [[ "$OUTPUT_FORMAT" == "tap" ]]; then echo "ok ${TOTAL} - ${name} # SKIP ${reason}" else echo -e " ${YELLOW}⊘${RESET} ${name}${reason:+ — ${reason}}"; fi } # ── Helpers ─────────────────────────────────────────────────────────── has_cmd() { command -v "$1" >/dev/null 2>&1; } # Build dig command with optional @server dig_cmd() { if [[ -n "$DNS_SERVER" ]]; then "$DIG_CMD" "@${DNS_SERVER}" "$@" else "$DIG_CMD" "$@" fi } # ── Output Functions ────────────────────────────────────────────────── section_header() { local name="$1" if [[ "$OUTPUT_FORMAT" == "text" ]]; then echo "" echo -e "${BOLD}${name}${RESET}" fi } print_header() { if [[ "$OUTPUT_FORMAT" == "text" ]]; then echo "" echo -e "${BOLD}DNS Smoke Tests${RESET}" echo "Domain: ${DOMAIN}" [[ -n "$DNS_SERVER" ]] && echo "Server: ${DNS_SERVER}" || echo "Server: (system resolver)" echo "Time: $(date -u +%Y-%m-%dT%H:%M:%SZ)" fi } print_tap_header() { echo "TAP version 13" } print_summary() { local end_time; end_time=$(date +%s) local duration=$(( end_time - START_TIME )) echo "" echo -e "${BOLD}────────────────────────────────────────${RESET}" echo -e "${BOLD}Summary${RESET} ${DOMAIN} ${DNS_SERVER:-(system resolver)}" echo -e " ${GREEN}${PASS} passed${RESET} ${RED}${FAIL} failed${RESET} ${YELLOW}${SKIP} skipped${RESET} (${duration}s)" echo -e "${BOLD}────────────────────────────────────────${RESET}" if [[ $FAIL -eq 0 ]]; then echo -e "${GREEN}${BOLD}All tests passed.${RESET}" else echo -e "${RED}${BOLD}${FAIL} test(s) failed.${RESET}"; fi } print_tap_footer() { echo "1..${TOTAL}" echo "# pass ${PASS}" echo "# fail ${FAIL}" echo "# skip ${SKIP}" } # ══════════════════════════════════════════════════════════════════════ # TESTS # ══════════════════════════════════════════════════════════════════════ # ── 1. Resolver Reachable ───────────────────────────────────────────── test_resolver_reachable() { section_header "Connectivity" local output output=$(dig_cmd +short +time=5 +tries=1 "${DOMAIN}" A 2>&1) || true if [[ -n "$output" ]] && ! echo "$output" | grep -qi "timed out\|connection refused\|no servers\|SERVFAIL"; then record_pass "Resolver reachable" "${DNS_SERVER:-(system resolver)}" else record_fail "Resolver reachable" "${DNS_SERVER:-(system resolver)} — ${output:-no response}" fi } # ── 2. Forward Resolution (A) ──────────────────────────────────────── test_forward_resolution() { section_header "Resolution" local output output=$(dig_cmd +short "${DOMAIN}" A 2>/dev/null) || true if [[ -n "$output" ]]; then local first_ip first_ip=$(echo "$output" | head -1) record_pass "Forward resolution (${DOMAIN} A)" "${first_ip}" else record_fail "Forward resolution (${DOMAIN} A)" "no A record returned" fi } # ── 3. AAAA Resolution ─────────────────────────────────────────────── test_aaaa_resolution() { local output output=$(dig_cmd +short "${DOMAIN}" AAAA 2>/dev/null) || true if [[ -n "$output" ]]; then local first_ip first_ip=$(echo "$output" | head -1) record_pass "AAAA resolution (${DOMAIN})" "${first_ip}" else record_skip "AAAA resolution (${DOMAIN})" "no AAAA record" fi } # ── 4. MX Resolution ───────────────────────────────────────────────── test_mx_resolution() { local output output=$(dig_cmd +short "${DOMAIN}" MX 2>/dev/null) || true if [[ -n "$output" ]]; then local first_mx first_mx=$(echo "$output" | head -1) record_pass "MX resolution (${DOMAIN})" "${first_mx}" else record_skip "MX resolution (${DOMAIN})" "no MX record" fi } # ── 5. Reverse Lookup ──────────────────────────────────────────────── test_reverse_lookup() { if [[ -z "$REVERSE_IP" ]]; then record_skip "Reverse lookup" "REVERSE_IP not set" return fi local output output=$(dig_cmd +short -x "${REVERSE_IP}" 2>/dev/null) || true if [[ -n "$output" ]]; then record_pass "Reverse lookup (${REVERSE_IP})" "${output}" else record_fail "Reverse lookup (${REVERSE_IP})" "no PTR record returned" fi } # ── 6. Response Time ───────────────────────────────────────────────── test_response_time() { section_header "Performance" local output query_time output=$(dig_cmd "${DOMAIN}" A 2>/dev/null) || true # dig outputs "Query time: 12 msec" or ";; Query time: 12 msec" query_time=$(echo "$output" | grep -i "query time" | grep -oP '[0-9]+' | head -1) || true if [[ -z "$query_time" ]]; then # drill outputs ";; Query time: 0 msec" query_time=$(echo "$output" | grep -i "query time" | awk '{print $4}') || true fi if [[ -n "$query_time" ]]; then if [[ "$query_time" -le "$MAX_RESPONSE_MS" ]]; then record_pass "Response time" "${query_time}ms (<= ${MAX_RESPONSE_MS}ms)" else record_fail "Response time" "${query_time}ms (> ${MAX_RESPONSE_MS}ms)" fi else record_fail "Response time" "could not parse query time" fi } # ── 7. Authoritative Answer ────────────────────────────────────────── test_authoritative_answer() { section_header "Authority" local output output=$(dig_cmd "${DOMAIN}" A 2>/dev/null) || true if echo "$output" | grep -q "flags:.*aa"; then record_pass "Authoritative answer (${DOMAIN})" "AA flag set" else record_fail "Authoritative answer (${DOMAIN})" "AA flag not set — server is not authoritative" fi } # ── 8. SOA Serial ──────────────────────────────────────────────────── test_soa_serial() { local output serial output=$(dig_cmd +short "${DOMAIN}" SOA 2>/dev/null) || true if [[ -z "$output" ]]; then record_fail "SOA serial (${DOMAIN})" "no SOA record returned" return fi # SOA format: ns1.example.com. admin.example.com. 2026051201 3600 900 604800 86400 serial=$(echo "$output" | awk '{print $3}') || true if [[ -z "$serial" ]]; then record_fail "SOA serial (${DOMAIN})" "could not parse serial" elif [[ "$serial" == "0" ]]; then record_fail "SOA serial (${DOMAIN})" "serial is 0" else record_pass "SOA serial (${DOMAIN})" "${serial}" fi } # ── 9. SOA Consistency ─────────────────────────────────────────────── test_soa_consistency() { if [[ -z "$ZONE_MASTER" ]]; then record_skip "SOA consistency" "ZONE_MASTER not set" return fi local serial_local serial_master # Get serial from configured server serial_local=$(dig_cmd +short "${DOMAIN}" SOA 2>/dev/null | awk '{print $3}') || true # Get serial from master serial_master=$("$DIG_CMD" "@${ZONE_MASTER}" +short "${DOMAIN}" SOA 2>/dev/null | awk '{print $3}') || true if [[ -z "$serial_local" || -z "$serial_master" ]]; then record_fail "SOA consistency" "could not retrieve serials (local=${serial_local:-?}, master=${serial_master:-?})" return fi if [[ "$serial_local" == "$serial_master" ]]; then record_pass "SOA consistency" "serial ${serial_local} matches across servers" else record_fail "SOA consistency" "serial mismatch — local=${serial_local}, master=${serial_master}" fi } # ── 10. Zone Transfer ──────────────────────────────────────────────── test_zone_transfer() { section_header "Zone Transfer" if [[ -z "$ZONE" ]]; then record_skip "Zone transfer (AXFR)" "ZONE not set" return fi local output exit_code=0 output=$(dig_cmd AXFR "${ZONE}" 2>&1) || exit_code=$? # Check if transfer returned records local record_count record_count=$(echo "$output" | grep -c "^${ZONE}" 2>/dev/null) || record_count=0 if [[ $record_count -gt 0 ]]; then record_pass "Zone transfer (${ZONE})" "${record_count} records transferred" elif echo "$output" | grep -qi "transfer failed\|refused\|REFUSED"; then record_pass "Zone transfer (${ZONE})" "AXFR refused (expected on production)" else record_fail "Zone transfer (${ZONE})" "transfer failed — ${output:0:100}" fi } # ── 11. DNSSEC Validation ──────────────────────────────────────────── test_dnssec_validation() { section_header "DNSSEC" if [[ -z "$DNSSEC_DOMAIN" ]]; then record_skip "DNSSEC validation" "DNSSEC_DOMAIN not set" return fi local output output=$(dig_cmd +dnssec +short "${DNSSEC_DOMAIN}" A 2>/dev/null) || true # Check for AD flag in full output local full_output full_output=$(dig_cmd +dnssec "${DNSSEC_DOMAIN}" A 2>/dev/null) || true if echo "$full_output" | grep -q "flags:.*ad"; then record_pass "DNSSEC validation (${DNSSEC_DOMAIN})" "AD flag set" elif [[ -n "$output" ]]; then record_fail "DNSSEC validation (${DNSSEC_DOMAIN})" "response received but AD flag not set" else record_fail "DNSSEC validation (${DNSSEC_DOMAIN})" "no response" fi } # ── 12. DNS-over-TLS ───────────────────────────────────────────────── test_dot() { section_header "DNS-over-TLS" if [[ -z "$DOT_SERVER" ]]; then record_skip "DNS-over-TLS" "DOT_SERVER not set" return fi if ! has_cmd openssl; then record_skip "DNS-over-TLS" "openssl not installed" return fi local output exit_code=0 output=$(echo "" | openssl s_client -connect "${DOT_SERVER}:853" -servername "${DOT_SERVER}" 2>&1) || exit_code=$? if echo "$output" | grep -qi "connected\|verify return"; then # Extract certificate info if available local cn cn=$(echo "$output" | grep -oP 'CN\s*=\s*\K[^,/]+' | head -1) || true record_pass "DNS-over-TLS (${DOT_SERVER}:853)" "TLS handshake OK${cn:+ — CN=${cn}}" else record_fail "DNS-over-TLS (${DOT_SERVER}:853)" "TLS handshake failed" fi } # ── 13. Custom Record Checks ───────────────────────────────────────── test_custom_records() { if [[ -z "$TEST_RECORDS" ]]; then return; fi section_header "Custom Records" local IFS=',' for entry in $TEST_RECORDS; do local name type expected name=$(echo "$entry" | cut -d: -f1) type=$(echo "$entry" | cut -d: -f2) expected=$(echo "$entry" | cut -d: -f3-) if [[ -z "$name" || -z "$type" ]]; then record_fail "Custom record" "invalid entry: ${entry}" continue fi local output output=$(dig_cmd +short "${name}" "${type}" 2>/dev/null) || true if [[ -z "$output" ]]; then record_fail "Custom record (${name} ${type})" "no record returned" elif [[ -n "$expected" ]]; then if echo "$output" | grep -q "$expected"; then record_pass "Custom record (${name} ${type})" "${output}" else record_fail "Custom record (${name} ${type})" "expected '${expected}', got '${output}'" fi else record_pass "Custom record (${name} ${type})" "${output}" fi done } # ── 14. Recursive Resolution ───────────────────────────────────────── test_recursive_resolution() { section_header "Recursion" local output output=$(dig_cmd +short "google.com" A 2>/dev/null) || true if [[ -n "$output" ]]; then local first_ip first_ip=$(echo "$output" | head -1) record_pass "Recursive resolution (google.com)" "${first_ip}" else record_fail "Recursive resolution (google.com)" "could not resolve external domain" fi } # ══════════════════════════════════════════════════════════════════════ # MAIN # ══════════════════════════════════════════════════════════════════════ usage() { cat <