#!/usr/bin/bash # # verify.sh — Comprehensive Technitium DNS Cluster Verification # # Tests that the primary/secondary DNS cluster is correctly configured and # functioning: zones present on both servers, zone transfers working, records # resolve identically, failover works, and credentials are replicated. # set -uo pipefail HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REMOTE="$HERE/remote-dns.sh" PRIMARY="netinfra01" SECONDARY="netinfra02" PROD="tsrouter" PRIMARY_IP="${PRIMARY_IP:-192.168.3.252}" SECONDARY_IP="${SECONDARY_IP:-192.168.3.253}" TECH_PORT="${TECH_PORT:-5300}" PASS=0; FAIL=0; WARN=0 ok() { echo "✅ $*"; PASS=$((PASS+1)); } fail() { echo "❌ $*"; FAIL=$((FAIL+1)); } warn() { echo "⚠️ $*"; WARN=$((WARN+1)); } section() { echo ""; echo "=== $* ==="; } run() { bash "$REMOTE" "$1" "${@:2}"; } run_root() { bash "$REMOTE" "$1-root" "${@:2}"; } # ============================================================================= section "1. Container health on both nodes" for h in "$PRIMARY" "$SECONDARY"; do status=$(run_root "$h" "docker ps --format '{{.Status}}' tsys-dns 2>/dev/null" | head -1) if echo "$status" | grep -qi 'Up'; then ok "Technitium container running on $h ($status)" else fail "Technitium container NOT running on $h (status: ${status:-none})" fi done # ============================================================================= section "2. Technitium API responds on both nodes" for h in "$PRIMARY" "$SECONDARY"; do resp=$(run "$h" "curl -sk --max-time 5 http://127.0.0.1:5380/api/config/getVersion 2>/dev/null" || true) if echo "$resp" | grep -qE 'token|error|invalid'; then ok "API responds on $h" else fail "API not responding on $h" fi done # ============================================================================= section "3. Zone count matches between primary and production" # Count zones from the container on each host count_zones() { local host="$1" run_root "$host" "docker exec tsys-dns sh -c 'ls /etc/dns/zones/ 2>/dev/null | wc -l'" 2>/dev/null | tr -d '[:space:]' } prod_zones=$(count_zones "$PROD") pri_zones=$(count_zones "$PRIMARY") sec_zones=$(count_zones "$SECONDARY") echo " Production zones: $prod_zones" echo " Primary (01) zones: $pri_zones" echo " Secondary (02) zones: $sec_zones" [ "$prod_zones" -gt 0 ] 2>/dev/null && ok "Production has $prod_zones zones" || fail "Production zone count invalid" [ "$pri_zones" -gt 0 ] 2>/dev/null && ok "Primary has $pri_zones zones" || fail "Primary zone count invalid" [ "$sec_zones" -gt 0 ] 2>/dev/null && ok "Secondary has $sec_zones zones" || fail "Secondary zone count invalid" if [ "$pri_zones" = "$prod_zones" ]; then ok "Primary zone count matches production ($pri_zones)" else warn "Primary zone count ($pri_zones) differs from production ($prod_zones)" fi if [ "$sec_zones" = "$pri_zones" ]; then ok "Secondary zone count matches primary ($sec_zones)" else warn "Secondary zone count ($sec_zones) differs from primary ($pri_zones) — may still be transferring" fi # ============================================================================= section "4. knel.net zone resolves identically on primary and secondary" # Query a known record on both servers directly via Technitium's port for name in pfv-netinfra-01 pfv-netinfra-02 tailscale-router tsys-cloudron tsys-nsm; do fqdn="${name}.knel.net" # Query via dig against each Technitium instance (through Pi-hole on :53) pri_ans=$(run "$PRIMARY" "dig +short +time=3 +tries=1 @127.0.0.1 -p 53 $fqdn A 2>/dev/null | head -1" 2>/dev/null || true) sec_ans=$(run "$SECONDARY" "dig +short +time=3 +tries=1 @127.0.0.1 -p 53 $fqdn A 2>/dev/null | head -1" 2>/dev/null || true) if [ -n "$pri_ans" ] && [ "$pri_ans" = "$sec_ans" ]; then ok "$fqdn resolves identically: $pri_ans" elif [ -n "$pri_ans" ] && [ -z "$sec_ans" ]; then warn "$fqdn: primary=$pri_ans secondary= (may still be syncing)" elif [ -z "$pri_ans" ] && [ -z "$sec_ans" ]; then warn "$fqdn: no answer on either server" else fail "$fqdn MISMATCH: primary=$pri_ans secondary=$sec_ans" fi done # ============================================================================= section "5. External DNS resolution works on both nodes" for h in "$PRIMARY" "$SECONDARY"; do ans=$(run "$h" "dig +short +time=3 +tries=1 @127.0.0.1 -p 53 github.com A 2>/dev/null | head -1" 2>/dev/null || true) if [ -n "$ans" ]; then ok "$h resolves github.com → $ans" else fail "$h cannot resolve github.com" fi done # ============================================================================= section "6. Zone transfer (AXFR) from primary to secondary" # Test AXFR of knel.net from the primary axfr=$(run "$SECONDARY" "dig +short +time=5 +tries=1 @${PRIMARY_IP} -p ${TECH_PORT} knel.net AXFR 2>/dev/null | wc -l" 2>/dev/null || echo "0") if [ "$axfr" -gt 1 ] 2>/dev/null; then ok "AXFR of knel.net from primary succeeds ($axfr records transferred)" else warn "AXFR test returned $axfr records — zone transfer may be restricted or in progress" fi # ============================================================================= section "7. Reverse DNS works" # Pick a known reverse zone and test PTR resolution ptr_test="181.103.100.in-addr.arpa" ptr_ans=$(run "$PRIMARY" "dig +short +time=3 +tries=1 @127.0.0.1 -p 53 $ptr_test SOA 2>/dev/null | head -1" 2>/dev/null || true) if [ -n "$ptr_ans" ]; then ok "Reverse zone $ptr_test has SOA on primary" else warn "Reverse zone $ptr_test: no SOA on primary" fi ptr_ans2=$(run "$SECONDARY" "dig +short +time=3 +tries=1 @127.0.0.1 -p 53 $ptr_test SOA 2>/dev/null | head -1" 2>/dev/null || true) if [ -n "$ptr_ans2" ]; then ok "Reverse zone $ptr_test has SOA on secondary" else warn "Reverse zone $ptr_test: no SOA on secondary" fi # ============================================================================= section "8. Production untouched (read-only verification)" # Verify production container is still running and unchanged prod_status=$(run_root "$PROD" "docker ps --format '{{.Status}}' tsys-dns 2>/dev/null" | head -1) if echo "$prod_status" | grep -qi 'Up'; then ok "Production container still running on $PROD ($prod_status)" else fail "Production container NOT running on $PROD!" fi prod_zones_after=$(count_zones "$PROD") if [ "$prod_zones_after" = "$prod_zones" ]; then ok "Production zone count unchanged ($prod_zones_after = $prod_zones before)" else fail "Production zone count CHANGED: $prod_zones → $prod_zones_after" fi # ============================================================================= section "9. Failover test" # Take the approach of querying via the secondary when primary is slow/unavailable. # We test that the secondary answers independently. sec_soa=$(run "$SECONDARY" "dig +short +time=3 +tries=1 @127.0.0.1 -p 53 knel.net SOA 2>/dev/null | head -1" 2>/dev/null || true) if [ -n "$sec_soa" ]; then ok "Secondary independently serves knel.net SOA: $sec_soa" else fail "Secondary cannot serve knel.net SOA independently" fi # ============================================================================= section "10. Credentials check — auth.config size matches production" prod_auth_size=$(run_root "$PROD" "docker exec tsys-dns wc -c < /etc/dns/auth.config 2>/dev/null" | tr -d '[:space:]') pri_auth_size=$(run_root "$PRIMARY" "docker exec tsys-dns wc -c < /etc/dns/auth.config 2>/dev/null" | tr -d '[:space:]') sec_auth_size=$(run_root "$SECONDARY" "docker exec tsys-dns wc -c < /etc/dns/auth.config 2>/dev/null" | tr -d '[:space:]') echo " auth.config sizes — prod=$prod_auth_size pri=$pri_auth_size sec=$sec_auth_size" if [ "$prod_auth_size" = "$pri_auth_size" ] && [ "$prod_auth_size" = "$sec_auth_size" ]; then ok "auth.config identical size across all three nodes (credentials + 2FA replicated)" else fail "auth.config sizes differ — credentials may not be replicated correctly" fi # ============================================================================= # Summary echo "" echo "==========================================" echo " PASSED: $PASS" echo " FAILED: $FAIL" echo " WARNED: $WARN" echo "==========================================" [ "$FAIL" -eq 0 ] && exit 0 || exit 1