#!/usr/bin/env bash
# DNS Geo-Awareness Matrix (with interception detection)
#
# Inputs:
#   - Domains/IPs:   example.com  www.youtube.com  142.250.190.14
#   - Resolvers:     @8.8.8.8  @1.1.1.1   (if any @IP are provided, ONLY those resolvers are used)
#
# Behavior:
#   - For each domain/IP:
#       * If domain: resolve via each resolver (A records), dedupe by /24, keep up to 3 per resolver.
#       * If an IP literal is provided as the "domain", use that IP directly (no DNS lookups).
#       * Build a domain-level unique IP set; probe each unique IP **once**:
#           - ICMP ping first; if timeout and hping3 present, TCP:443 fallback.
#           - Best RTT column is 12 chars (left-justified); ICMP: "87ms", TCP: "87ms (http)".
#           - If RTT > 999ms, re-probe once; if still >999ms, show "-" and exclude from stats/GeoHits.
#       * Show the fastest IP per resolver (IP, CC[2], RTT, PTR).
#   - Pre-test: checks for DNS interception and warns (but continues).
#   - Summary:
#       * Ranks resolvers by average RTT (numeric entries only).
#       * GeoHits (within 25ms of the per-domain best) are shown only if resolver count >= 3.
#
# Requirements:
#   - Required: bash 4+, ping, and ONE of dig / drill
#   - Optional: whois, geoiplookup (Country/Owner), hping3 (TCP fallback)
#
# ---------------------------------------------------------------------------
# BACKEND NOTE
# ---------------------------------------------------------------------------
# Works with EITHER ISC's dig OR NLnet Labs' drill; dig is auto-selected when
# present. dig is NOT deprecated -- it is actively maintained by ISC as part of
# BIND 9. It is ldns/drill that is maintenance-only upstream ("ldns has been in
# maintenance mode since 2020", NLnet Labs). drill is kept as a fallback because
# FreeBSD/OPNsense ship it in base while dig needs bind-tools installed.
#
# Handled differences: dig "status:" vs drill "rcode:"; dig's SERVER line
# carries "#53(...)" while drill's does not; and drill has NO timeout/retry
# options whatsoever (it blocks ~15s against a dead resolver, measured), so all
# drill calls are wrapped in timeout(1) while dig uses native +time/+tries.
# ---------------------------------------------------------------------------

set -uo pipefail

have_cmd() { command -v "$1" >/dev/null 2>&1; }

# -------------------------- Width detection & formatting --------------------------
WIDTH=80
if [ -t 1 ]; then
  if command -v tput >/dev/null 2>&1; then
    cols="$(tput cols 2>/dev/null || true)"
    [[ "$cols" =~ ^[0-9]+$ ]] && WIDTH="$cols"
  fi
  if ! [[ "$WIDTH" =~ ^[0-9]+$ ]]; then
    cols="$(stty size 2>/dev/null | awk '{print $2}')" || true
    [[ "$cols" =~ ^[0-9]+$ ]] && WIDTH="$cols"
  fi
fi
(( WIDTH < 80 )) && WIDTH=80

FIXED_SUM=0; PIPES=5; PTR_WIDTH=0; RULE_LEN=0; FMT=""

recompute_format() {
  FIXED_SUM=$((16 + 15 + 15 + 2 + 12))  # Resolver + DNS-IP + Answer-IP + CC + BestRTT
  PIPES=5
  PTR_WIDTH=$(( WIDTH - (FIXED_SUM + PIPES) ))
  (( PTR_WIDTH < 8 )) && PTR_WIDTH=8
  FMT="%-16.16s|%-15.15s|%-15.15s|%-2.2s|%-12.12s|%-${PTR_WIDTH}.${PTR_WIDTH}s\n"
  RULE_LEN=$(( FIXED_SUM + PIPES + PTR_WIDTH ))
}
recompute_format

trap '
  if [ -t 1 ] && command -v tput >/dev/null 2>&1; then
    cols="$(tput cols 2>/dev/null || true)"
    [[ "$cols" =~ ^[0-9]+$ ]] && WIDTH="$cols"
    (( WIDTH < 80 )) && WIDTH=80
  fi
  recompute_format
' WINCH

fmtline() { printf "$FMT" "$@"; }
print_rule() { printf -- "%-${RULE_LEN}.${RULE_LEN}s\n" "$(printf "%${RULE_LEN}s" "" | tr " " "-")"; }

print_limited_line() {
  local prefix="$1" text="$2"
  local plen=${#prefix}
  local maxlen=$(( WIDTH - plen ))
  (( maxlen < 8 )) && maxlen=8
  if (( ${#text} > maxlen )); then
    printf "%s%s…\n" "$prefix" "${text:0:maxlen-1}"
  else
    printf "%s%s\n" "$prefix" "$text"
  fi
}

# -------------------------- Parse args --------------------------
DEFAULT_CDN_DOMAINS=( www.cloudflare.com www.youtube.com www.cnn.com www.akamai.com )
EXTRA_RESOLVERS=()
DOMAINS=()
ASSUME_YES=0

is_ipv4() { [[ "$1" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]]; }

print_usage() {
  local msg
  IFS= read -r -d '' msg <<'USAGE' || true
Usage: dns-geo-matrix.sh [-y|--yes] [DOMAIN|IP ...] [@RESOLVER-IP ...]

  DOMAIN|IP     domain(s) or IP literal(s) to test. Default: a CDN set
                (www.cloudflare.com www.youtube.com www.cnn.com www.akamai.com)
  @RESOLVER-IP  test ONLY these resolvers, instead of the built-in public list
  -y, --yes     skip the confirmation prompt
  -h, --help    show this help

Environment:
  DNS_TOOL=dig|drill   force a query backend (default: dig if present, else drill)
USAGE
  printf '%s' "$msg"
}

for arg in "$@"; do
  case "$arg" in
    -y|--yes)  ASSUME_YES=1; continue ;;
    -h|--help) print_usage; exit 0 ;;
  esac
  if [[ "$arg" == @* ]]; then
    ip="${arg#@}"
    if is_ipv4 "$ip"; then
      EXTRA_RESOLVERS+=( "Custom-$(( ${#EXTRA_RESOLVERS[@]} + 1 )):${ip}" )
    else
      echo "WARN: ignoring invalid resolver token: $arg" >&2
    fi
  else
    DOMAINS+=( "$arg" )
  fi
done
if (( ${#DOMAINS[@]} == 0 )); then
  DOMAINS=( "${DEFAULT_CDN_DOMAINS[@]}" )
fi

have_whois()   { have_cmd whois; }
have_geoip()   { have_cmd geoiplookup; }
have_hping3()  { have_cmd hping3;    }

# -------------------------- Build resolver list --------------------------
if (( ${#EXTRA_RESOLVERS[@]} > 0 )); then
  RESOLVERS=( "${EXTRA_RESOLVERS[@]}" )
else
  RESOLVERS=(
    "Google:8.8.8.8"
    "Cloudflare:1.1.1.1"
    "Quad9:9.9.9.9"
    "Quad9-Unfiltered:9.9.9.10"
    "OpenDNS:208.67.222.222"
    "AdGuard:94.140.14.14"
    "CleanBrowsing:185.228.168.9"
    "Neustar:156.154.70.1"
    "Level3:4.2.2.2"
  )
  # stderr silenced at the GROUP level (not per-command): this runs before the
  # purpose banner, and a missing /etc/resolv.conf -- or a stripped PATH, where
  # the subshell itself emits "command not found" -- must not spew noise ahead
  # of it.
  SYS_DNS="$( { awk '/^nameserver/{print $2}' /etc/resolv.conf \
                  | grep -v -E '^(127\.0\.0\.1|::1)$' \
                  | head -n1; } 2>/dev/null || true )"
  if [[ -n "${SYS_DNS:-}" ]]; then
    RESOLVERS+=( "SystemDefault:${SYS_DNS}" )
  fi
fi

join_resolvers() {
  local s="" name ip
  for entry in "${RESOLVERS[@]}"; do
    name="${entry%%:*}"; ip="${entry#*:}"
    [[ -n "$s" ]] && s+=", "
    s+="${name}(${ip})"
  done
  printf "%s" "$s"
}

# -------------------------- Purpose + confirmation --------------------------
# Deliberately placed BEFORE dependency detection: the reader should learn what
# this script is for before being told which packages are missing.
print_purpose() {
  local msg
  IFS= read -r -d '' msg <<'PURPOSE' || true

DNS Geo-Awareness Matrix
========================

WHAT THIS DOES
  Asks each resolver below to resolve each test domain, then measures how far
  away the address it hands back actually is. A resolver that returns a nearby
  CDN node is geo-aware; one that returns a distant node is not.

  It also checks whether your DNS is being intercepted on-path:
    1. TEST-NET-3 probe -- 203.0.113.1 (RFC 5737) must never route anywhere.
       If it answers a DNS query, something is intercepting port 53.
    2. CHAOS identity   -- asks each responder to name itself. Resolvers run by
       DIFFERENT operators reporting the SAME identity are being funnelled
       into one box.
    3. Egress + ECS     -- asks an authoritative server which address each
       resolver's queries actually arrive from, and whether the resolver is
       disclosing your network prefix via EDNS Client Subnet.

WHAT IT TOUCHES
  Read-only. It sends DNS queries to the resolvers below, ICMP pings (and
  optional TCP/443 probes) to the addresses they return, and whois lookups for
  country/owner. It changes no config and needs no privileges.
PURPOSE
  printf '%s\n' "$msg"
  print_limited_line "  Domains under test:   " "${DOMAINS[*]}"
  print_limited_line "  Resolvers under test: " "$(join_resolvers)"
  echo
  echo "  Runtime: roughly 10-30s, depending on how many domains you pass."
  echo
}

confirm_run() {
  print_purpose
  if [ "$ASSUME_YES" = "1" ]; then return 0; fi
  # Never block when stdin is not a terminal (cron, pipes, `curl ... | bash`):
  # a read() there would consume the piped script body instead of user input.
  if [ ! -t 0 ]; then
    echo "(stdin is not a terminal -- proceeding automatically; pass -y to silence)"
    echo
    return 0
  fi
  printf 'Run these checks now? [y/N] '
  local ans=""
  read -r ans || true
  # Strip CR/whitespace: PuTTY, serial consoles and some pty wrappers deliver
  # CRLF, which would leave ans="y\r" and fall through to the abort branch.
  ans="${ans//[$'\r\n\t ']/}"
  case "$ans" in
    [yY]|[yY][eE][sS]) echo ;;
    *) echo "Aborted."; exit 0 ;;
  esac
}

confirm_run

if [ -z "${BASH_VERSINFO:-}" ] || [ "${BASH_VERSINFO[0]}" -lt 4 ]; then
  echo "ERROR: this script requires bash 4+ (associative arrays). Found: ${BASH_VERSION:-unknown}" >&2
  exit 1
fi

# -------------------------- Install help --------------------------
print_install_help() {
  # Uses only bash builtins (read/printf). This is the failure path taken when
  # no DNS tool exists, so it must not itself depend on external binaries.
  local msg
  IFS= read -r -d '' msg <<'HELP' || true
ERROR: no supported DNS query tool found. This script needs 'dig' or 'drill'.

Install ONE of the following. dig is preferred (actively maintained by ISC);
drill is a working fallback but its upstream (ldns) is maintenance-only.

  Platform                dig (preferred)             drill (fallback)
  ----------------------  --------------------------  --------------------------
  Debian / Ubuntu / Mint  apt install bind9-dnsutils  apt install ldnsutils
  Fedora                  dnf install bind-utils      dnf install ldns-utils
  RHEL / Rocky / Alma     dnf install bind-utils      dnf install ldns-utils [EPEL]
  openSUSE                zypper install bind-utils   zypper install ldns
  Arch / Manjaro          pacman -S bind              pacman -S ldns
  Alpine                  apk add bind-tools          apk add drill
  Gentoo                  emerge net-dns/bind         emerge net-libs/ldns
  Void                    xbps-install bind-utils     xbps-install ldns
  FreeBSD / OPNsense      pkg install bind-tools      (drill is in base system)
  macOS (Homebrew)        brew install bind           brew install ldns
HELP
  printf '%s' "$msg" >&2
}

# -------------------------- Backend detection --------------------------
# Override with DNS_TOOL=drill (or DNS_TOOL=dig) to force a backend -- useful
# for verifying the fallback path on a host that has both installed.
DNS_BACKEND=""
BACKEND_DESC=""
if [ -n "${DNS_TOOL:-}" ]; then
  if ! have_cmd "$DNS_TOOL"; then
    echo "ERROR: DNS_TOOL=$DNS_TOOL requested but '$DNS_TOOL' is not installed." >&2
    exit 1
  fi
  case "$DNS_TOOL" in
    dig|drill) ;;
    *) echo "ERROR: DNS_TOOL must be 'dig' or 'drill' (got '$DNS_TOOL')." >&2; exit 1 ;;
  esac
fi
if [ "${DNS_TOOL:-}" = "drill" ]; then
  DNS_BACKEND="drill"
  BACKEND_DESC="drill $(drill -v 2>&1 | sed -n 's/.*drill version \([0-9.]*\).*/\1/p' | head -1) (ldns) [forced]"
elif have_cmd dig; then
  DNS_BACKEND="dig"
  BACKEND_DESC="dig $(dig -v 2>&1 | head -1 | awk '{print $2}')"
elif have_cmd drill; then
  DNS_BACKEND="drill"
  BACKEND_DESC="drill $(drill -v 2>&1 | sed -n 's/.*drill version \([0-9.]*\).*/\1/p' | head -1) (ldns, maintenance-only upstream)"
else
  print_install_help
  exit 1
fi

if ! have_cmd ping; then
  echo "ERROR: Missing required command: ping" >&2
  exit 1
fi

TIMEOUT_CMD=""
if have_cmd timeout; then TIMEOUT_CMD="timeout"
elif have_cmd gtimeout; then TIMEOUT_CMD="gtimeout"; fi
_tmo() { local s="$1"; shift; if [ -n "$TIMEOUT_CMD" ]; then "$TIMEOUT_CMD" "$s" "$@"; else "$@"; fi; }

if [ "$DNS_BACKEND" = "drill" ] && [ -z "$TIMEOUT_CMD" ]; then
  echo "WARN: drill backend without timeout(1) -- unreachable resolvers will block ~15s each." >&2
fi

# ping -W units differ by OS: Linux = SECONDS, FreeBSD/macOS = MILLISECONDS.
# The previous version hardcoded "-W 1", which on FreeBSD/OPNsense meant a 1ms
# deadline and produced "packets out of wait time" on every probe.
case "$(uname -s 2>/dev/null)" in
  Linux)  PING_WAIT_OPT=(-W 1) ;;
  *)      PING_WAIT_OPT=(-W 1000) ;;
esac

echo "Backend: $BACKEND_DESC"
echo

# -------------------------- Backend-neutral query layer --------------------------

# dnsq_short_a <resolver-ip> <name> -> A records, one per line
dnsq_short_a() {
  local res="$1" name="$2"
  case "$DNS_BACKEND" in
    dig)   _tmo 8 dig +short +time=2 +tries=1 A "$name" @"$res" 2>/dev/null ;;
    drill) _tmo 8 drill -Q A "$name" @"$res" 2>/dev/null ;;
  esac
}

# dnsq_ptr <ip> -> reverse name (trailing dot stripped)
dnsq_ptr() {
  local ip="$1" out
  case "$DNS_BACKEND" in
    dig)   out="$(_tmo 8 dig +short +time=2 +tries=1 -x "$ip" 2>/dev/null)" ;;
    drill) out="$(_tmo 8 drill -Q -x "$ip" 2>/dev/null)" ;;
  esac
  # Strip diagnostic lines: even in +short/-Q mode both tools can emit
  # ";; Warning: ..." on stdout for a malformed reply.
  printf '%s\n' "$out" | grep -v '^;' | sed 's/\.$//' | head -n1
}

# dnsq_answers <resolver-ip> -> 0 if the address responded to DNS at all.
# Both backends emit ";; SERVER:" only when a response was actually received.
dnsq_answers() {
  local res="$1" out
  case "$DNS_BACKEND" in
    dig)   out="$(_tmo 6 dig @"$res" example.com A +time=2 +tries=1 +stats 2>/dev/null)" ;;
    drill) out="$(_tmo 6 drill example.com @"$res" A 2>/dev/null)" ;;
  esac
  printf '%s\n' "$out" | grep -q '^;; SERVER:'
}

# dnsq_chaos_id <resolver-ip> -> the responder's self-reported identity, or ""
# Tries id.server first (RFC 5001 style), then the older hostname.bind.
dnsq_chaos_id() {
  local res="$1" probe out
  for probe in id.server hostname.bind; do
    case "$DNS_BACKEND" in
      dig)   out="$(_tmo 6 dig +short +time=2 +tries=1 @"$res" CH TXT "$probe" 2>/dev/null)" ;;
      drill) out="$(_tmo 6 drill -Q "$probe" TXT CH @"$res" 2>/dev/null)" ;;
    esac
    # Drop ";; Warning: Message parser reports malformed message packet." and
    # friends -- AdGuard returns exactly that for a CHAOS query, and without
    # this filter the warning text was being displayed as the server identity.
    out="$(printf '%s\n' "$out" | grep -v '^;' | head -n1 | tr -d '"' | sed 's/[[:space:]]*$//')"
    if [[ -n "$out" ]]; then printf '%s\n' "$out"; return 0; fi
  done
  return 0
}

# dnsq_egress <resolver-ip> -> "EGRESS_IP|ECS_PREFIX" (either half may be empty)
# Google's authoritative for o-o.myaddr.l.google.com reports the address IT saw
# the query arrive from -- i.e. THIS resolver's egress IP, not your client IP --
# plus any EDNS Client Subnet the resolver forwarded on your behalf. Two
# resolvers sharing an egress IP is corroborating evidence for the CHAOS
# funnelling check below; a disclosed client subnet is a privacy finding in its
# own right (Google forwards it, Cloudflare and Quad9 do not -- measured).
dnsq_egress() {
  local res="$1" out ip ecs
  case "$DNS_BACKEND" in
    dig)   out="$(_tmo 10 dig +short +time=2 +tries=1 @"$res" TXT o-o.myaddr.l.google.com 2>/dev/null)" ;;
    drill) out="$(_tmo 10 drill -Q o-o.myaddr.l.google.com TXT @"$res" 2>/dev/null)" ;;
  esac
  out="$(printf '%s\n' "$out" | grep -v '^;' | sed 's/^"//; s/"$//')"
  # Accepts IPv4 AND IPv6: several resolvers (Quad9, Neustar, and a Cloudflare-
  # forwarding Unbound) egress to Google's authoritative over v6, and an
  # IPv4-only pattern silently dropped them as "no egress". Measured.
  ip="$(printf '%s\n' "$out"  | grep -vE '^edns0-client-subnet' | grep -E '^[0-9a-fA-F:.]+$' | head -n1)"
  ecs="$(printf '%s\n' "$out" | sed -n 's/^edns0-client-subnet //p' | head -n1)"
  printf '%s|%s\n' "$ip" "$ecs"
}

# -------------------------- Interception detection --------------------------
#
# RATIONALE (kept deliberately, so this is not "simplified" back later):
#
# The previous version tried to detect redirection by comparing the ";; SERVER:"
# line of a response against the resolver it had been told to query. That test
# could NEVER fire. dig and drill both simply echo back the address you asked
# them to query -- even when an on-path NAT rule has silently redirected the
# packet to a completely different box. Verified against dig 9.20: querying
# @1.1.1.1 always parses back "1.1.1.1", so `actual != intended` was never true.
# The check was pure decoration and is removed.
#
# Two checks that DO work replace it:
#
#   1. TEST-NET-3 probe. 203.0.113.1 is RFC 5737 documentation space and must
#      never route anywhere. If it answers a DNS query at all, something on-path
#      is intercepting port 53 and answering on its behalf. This is definitive.
#
#   2. CHAOS-class identity fingerprinting. `id.server` / `hostname.bind` in
#      class CH asks the responder to identify itself. Two resolvers belonging
#      to DIFFERENT operators returning the SAME fingerprint means both are
#      being funnelled into one box.
#
# Known limits, surfaced in the output rather than hidden:
#   - Not every operator publishes an identity. Measured: Google (8.8.8.8),
#     OpenDNS and AdGuard return nothing at all, so they cannot be fingerprinted
#     and are reported as "no-id" rather than as a failure.
#   - Resolvers from the SAME operator legitimately share a fingerprint. Quad9's
#     9.9.9.9 and 9.9.9.10 both answer "res701.qlax1". The operator table below
#     prevents that from being misreported as interception.

resolver_operator() {
  case "$1" in
    1.1.1.1|1.0.0.1|1.1.1.2|1.0.0.2|1.1.1.3|1.0.0.3)  echo "Cloudflare" ;;
    8.8.8.8|8.8.4.4)                                   echo "Google" ;;
    9.9.9.9|9.9.9.10|9.9.9.11|149.112.112.112)         echo "Quad9" ;;
    208.67.222.222|208.67.220.220)                     echo "OpenDNS" ;;
    94.140.14.14|94.140.15.15|94.140.14.15)            echo "AdGuard" ;;
    185.228.168.9|185.228.169.9)                       echo "CleanBrowsing" ;;
    156.154.70.1|156.154.71.1)                         echo "Neustar" ;;
    4.2.2.1|4.2.2.2|4.2.2.3|4.2.2.4)                   echo "Level3" ;;
    *)                                                 echo "ip:$1" ;;
  esac
}

warn_flags=()
declare -A res_id res_noid shared_id res_egress res_ecs shared_egress

# Check 1: TEST-NET-3 must not answer
BOGUS_IP="203.0.113.1"
if dnsq_answers "$BOGUS_IP"; then
  warn_flags+=("TEST-NET-3 address ${BOGUS_IP}:53 answered a DNS query -- port 53 is being intercepted on-path")
fi

# Check 2: CHAOS identity fingerprints
declare -A fp_operators
for entry in "${RESOLVERS[@]}"; do
  name="${entry%%:*}"; dnsip="${entry#*:}"
  # Egress is collected for EVERY resolver, including ones with no published
  # identity -- so it must be fetched before the no-id `continue` below.
  eg="$(dnsq_egress "$dnsip")"
  res_egress["$name"]="${eg%%|*}"
  res_ecs["$name"]="${eg#*|}"
  id="$(dnsq_chaos_id "$dnsip")"
  if [[ -z "$id" ]]; then
    res_id["$name"]="no-id"
    res_noid["$name"]=1
    continue
  fi
  res_id["$name"]="$id"
  op="$(resolver_operator "$dnsip")"
  key="$id"
  if [[ -n "${fp_operators[$key]:-}" ]]; then
    if [[ " ${fp_operators[$key]} " != *" $op "* ]]; then
      fp_operators["$key"]="${fp_operators[$key]} $op"
    fi
  else
    fp_operators["$key"]="$op"
  fi
done

for key in "${!fp_operators[@]}"; do
  ops="${fp_operators[$key]}"
  opcount="$(wc -w <<<"$ops" | tr -d ' ')"
  if (( opcount > 1 )); then
    warn_flags+=("resolvers from different operators (${ops// /, }) all identify as \"${key}\" -- traffic is being funnelled to one server")
    for entry in "${RESOLVERS[@]}"; do
      n="${entry%%:*}"
      [[ "${res_id[$n]:-}" == "$key" ]] && shared_id["$n"]=1
    done
  fi
done

# Egress collisions across operators: same reasoning as the CHAOS check above.
declare -A eg_operators
for entry in "${RESOLVERS[@]}"; do
  name="${entry%%:*}"; dnsip="${entry#*:}"
  eg="${res_egress[$name]:-}"
  [[ -z "$eg" ]] && continue
  op="$(resolver_operator "$dnsip")"
  if [[ -n "${eg_operators[$eg]:-}" ]]; then
    [[ " ${eg_operators[$eg]} " != *" $op "* ]] && eg_operators["$eg"]="${eg_operators[$eg]} $op"
  else
    eg_operators["$eg"]="$op"
  fi
done
for key in "${!eg_operators[@]}"; do
  ops="${eg_operators[$key]}"
  if (( $(wc -w <<<"$ops" | tr -d ' ') > 1 )); then
    warn_flags+=("resolvers from different operators (${ops// /, }) all egress from ${key} -- queries are leaving via one path")
    for entry in "${RESOLVERS[@]}"; do
      n="${entry%%:*}"
      [[ "${res_egress[$n]:-}" == "$key" ]] && shared_egress["$n"]=1
    done
  fi
done

if ((${#warn_flags[@]})); then
  echo "! Detected possible DNS interception BEFORE tests:"
  for w in "${warn_flags[@]}"; do echo "  - $w"; done
  echo "! Results below may be tainted."
else
  echo "+ No interception indications detected by the identity checks."
fi

# Show the fingerprints we did collect, with an honest note about coverage.
noid_list=""
for entry in "${RESOLVERS[@]}"; do
  n="${entry%%:*}"
  [[ -n "${res_noid[$n]:-}" ]] && noid_list+="${noid_list:+, }$n"
done
if [[ -n "$noid_list" ]]; then
  print_limited_line "  note: no published identity (cannot be fingerprinted): " "$noid_list"
fi
echo

# -------------------------- Helpers (RAM caches only) --------------------------
declare -A PTR_CACHE COUNTRY_CACHE OWNER_CACHE

resolve_ips_v4() {
  local resolver_ip="$1" name_or_ip="$2"
  if is_ipv4 "$name_or_ip"; then
    printf "%s\n" "$name_or_ip"
    return
  fi
  dnsq_short_a "$resolver_ip" "$name_or_ip" | grep -E '^[0-9]+\.' || true
}

dedupe_by24_and_limit3() { awk -F. '!seen[$1"."$2"."$3]++' | head -n 3; }

ptr_name() {
  local ip="$1"
  local cached="${PTR_CACHE[$ip]:-}"
  if [[ -n "$cached" ]]; then echo "$cached"; return; fi
  local p
  p="$(dnsq_ptr "$ip")"
  [[ -z "$p" ]] && p="-"
  PTR_CACHE["$ip"]="$p"
  echo "$p"
}

# whois portability:
#   - FreeBSD's whois(1) has NO -H flag (that is GNU/Debian-specific). Passing it
#     makes FreeBSD whois abort with "illegal option -- H" and emit nothing,
#     which is why Country/Owner silently read UNKNOWN on OPNsense. The anchored
#     field regexes below make the legal disclaimer harmless, so -H is not used.
#   - awk's IGNORECASE is a GNU extension that BSD awk does not implement (so the
#     old case-insensitive field match never fired on FreeBSD either). POSIX
#     tolower() is used instead, which works on both.
whois_q() { _tmo 8 whois "$1" 2>/dev/null; }

# RIR self-reference blocks appear BEFORE the real netblock owner in whois
# output (e.g. "organisation: ARIN" at line 8 vs "OrgName: Cloudflare, Inc."
# at line 34), so a naive first-match grabs the registry. These are skipped.
_is_registry() {
  case "$(printf '%s' "$1" | tr '[:lower:]' '[:upper:]')" in
    ARIN|RIPE|"RIPE NCC"|RIPE-NCC|APNIC|LACNIC|AFRINIC|IANA|ICANN) return 0 ;;
    *) return 1 ;;
  esac
}

get_country(){
  local ip="$1"
  local cached="${COUNTRY_CACHE[$ip]:-}"
  if [[ -n "$cached" ]]; then echo "$cached"; return; fi
  local c=""
  if have_whois; then
    c="$(whois_q "$ip" | awk 'tolower($0) ~ /^country[[:space:]]*:/ {print $2; exit}')" || true
  fi
  if [[ -z "$c" ]] && have_geoip; then
    c="$(geoiplookup "$ip" 2>/dev/null | sed -n '1s/.*: //p')" || true
  fi
  [[ -z "$c" ]] && c="UNKNOWN"
  COUNTRY_CACHE["$ip"]="$c"
  echo "$c"
}

get_owner(){
  local ip="$1"
  local cached="${OWNER_CACHE[$ip]:-}"
  if [[ -n "$cached" ]]; then echo "$cached"; return; fi
  if ! have_whois; then echo "UNKNOWN"; return; fi
  local raw o="" v
  raw="$(whois_q "$ip")"
  # Pass 1: authoritative owner fields. Pass 2: fall back to netname/descr.
  for pat in '^(orgname|org-name|owner|organisation)[[:space:]]*:' '^(netname|descr)[[:space:]]*:'; do
    while IFS= read -r v; do
      [[ -z "$v" ]] && continue
      _is_registry "$v" && continue
      o="$v"; break
    done < <(printf '%s\n' "$raw" | awk -v pat="$pat" '
        tolower($0) ~ pat { sub(/^[^:]+:[[:space:]]*/,""); sub(/[[:space:]]+$/,""); print }')
    [[ -n "$o" ]] && break
  done
  [[ -z "$o" ]] && o="UNKNOWN"
  OWNER_CACHE["$ip"]="$o"
  echo "$o"
}

get_icmp_avg_ms(){
  local ip="$1" avg="" tmp
  tmp="$(mktemp "${TMPDIR:-/tmp}/dnsgeo.XXXXXX")" || return 1
  if _tmo 6 ping -c 3 -i 0.2 "${PING_WAIT_OPT[@]}" "$ip" >"$tmp" 2>/dev/null; then
    avg="$(awk -F'/' '/^rtt/ {print $(NF-2)}' "$tmp")" || true
    [[ -z "$avg" ]] && avg="$(awk -F'/' '/round-trip/ {print $(NF-2)}' "$tmp")" || true
  fi
  rm -f "$tmp" || true
  [[ -n "$avg" ]] && echo "$avg" || echo "timeout"
}

get_tcp443_avg_ms(){
  local ip="$1"
  if ! have_hping3; then echo "no-tcp"; return; fi
  local out
  out="$(_tmo 8 hping3 -S -p 443 -c 3 "$ip" 2>/dev/null \
         | grep -oE '(time|rtt)=[0-9.]+ ms' \
         | grep -oE '[0-9.]+' )"
  [[ -z "$out" ]] && { echo "timeout"; return; }
  awk 'BEGIN{sum=0; n=0} {sum+=$1; n++} END{if(n>0) printf("%.3f", sum/n); else print "timeout"}' <<<"$out"
}

reprobe_if_too_high(){
  local ip="$1" method="$2" val="$3"
  if [[ "$val" =~ ^[0-9]+(\.[0-9]+)?$ ]]; then
    awk -v x="$val" 'BEGIN{exit (x>999)?0:1}'; hi=$?
    if (( hi == 0 )); then
      local again=""
      if [[ "$method" == "ICMP" ]]; then
        again="$(get_icmp_avg_ms "$ip")"
      else
        again="$(get_tcp443_avg_ms "$ip")"
      fi
      if [[ "$again" =~ ^[0-9]+(\.[0-9]+)?$ ]]; then
        awk -v y="$again" 'BEGIN{exit (y>999)?0:1}'; hi2=$?
        if (( hi2 == 0 )); then
          echo "-"
        else
          printf "%.0f" "$again"
        fi
      else
        echo "-"
      fi
    else
      printf "%.0f" "$val"
    fi
  else
    echo "$val"
  fi
}

probe_ip(){
  local ip="$1"
  local icmp tcp
  icmp="$(get_icmp_avg_ms "$ip")"
  if [[ "$icmp" != "timeout" ]]; then
    local adj="$(reprobe_if_too_high "$ip" "ICMP" "$icmp")"
    echo "ICMP|$adj"; return 0
  fi
  tcp="$(get_tcp443_avg_ms "$ip")"
  if [[ "$tcp" != "timeout" && "$tcp" != "no-tcp" ]]; then
    local adj="$(reprobe_if_too_high "$ip" "TCP443" "$tcp")"
    echo "TCP443|$adj"; return 0
  fi
  echo "NONE|$tcp"
  return 0
}

# -------------------------- Main matrix --------------------------
declare -A rtt_sum rtt_count geo_hits

for domain in "${DOMAINS[@]}"; do
  echo "=== ${domain} ==="
  fmtline "Resolver" "DNS-IP" "Answer-IP" "CC" "Best RTT" "PTR (reverse DNS)"
  print_rule

  declare -A RES_IPS SEEN_IP IP_RESULT
  RES_IPS=(); SEEN_IP=(); IP_RESULT=()
  DOMAIN_UNIQUE_IPS=()

  for entry in "${RESOLVERS[@]}"; do
    name="${entry%%:*}"; dnsip="${entry#*:}"
    ips_raw="$(resolve_ips_v4 "$dnsip" "$domain" || true)"
    ips="$(sed '/^$/d' <<<"$ips_raw" | dedupe_by24_and_limit3 || true)"
    RES_IPS["$name"]="$ips"
    while IFS= read -r ip; do
      [[ -z "$ip" ]] && continue
      if [[ -z "${SEEN_IP["$ip"]:-}" ]]; then
        SEEN_IP["$ip"]=1
        DOMAIN_UNIQUE_IPS+=("$ip")
      fi
    done <<< "$ips"
  done

  for ip in "${DOMAIN_UNIQUE_IPS[@]}"; do
    IP_RESULT["$ip"]="$(probe_ip "$ip")"
  done

  domain_best=""
  declare -A fast_rtt_by_resolver
  fast_rtt_by_resolver=()

  for entry in "${RESOLVERS[@]}"; do
    name="${entry%%:*}"; dnsip="${entry#*:}"; ips="${RES_IPS["$name"]}"

    if [[ -z "$ips" ]]; then
      fmtline "$name" "$dnsip" "<no answer>" "--" "timeout" "-"
      continue
    fi

    best_ip=""; best_val=""; best_method=""
    while IFS= read -r ip; do
      [[ -z "$ip" ]] && continue
      method="${IP_RESULT["$ip"]%%|*}"
      value="${IP_RESULT["$ip"]#*|}"
      if [[ "$value" =~ ^[0-9]+(\.[0-9]+)?$ ]]; then
        if [[ -z "$best_val" || $(awk -v a="$value" -v b="$best_val" 'BEGIN{print (a<b)?1:0}') -eq 1 ]]; then
          best_val="$value"; best_ip="$ip"; best_method="$method"
        fi
      fi
    done <<< "$ips"

    if [[ -n "$best_ip" ]]; then
      cc="$(get_country "$best_ip")"; cc="${cc:0:2}"
      ptr="$(ptr_name "$best_ip")"
      rtt_disp="$(printf "%.0f" "$best_val")ms"
      [[ "$best_method" == "TCP443" ]] && rtt_disp="${rtt_disp} (http)"
      fmtline "$name" "$dnsip" "$best_ip" "$cc" "$rtt_disp" "$ptr"

      rtt_int="${best_val%.*}"
      rtt_sum["$name"]=$(( ${rtt_sum["$name"]:-0} + rtt_int ))
      rtt_count["$name"]=$(( ${rtt_count["$name"]:-0} + 1 ))
      fast_rtt_by_resolver["$name"]="$rtt_int"
      if [[ -z "$domain_best" || $rtt_int -lt $domain_best ]]; then domain_best="$rtt_int"; fi
    else
      one_ip="$(head -n1 <<<"$ips")"
      cc="$(get_country "$one_ip")"; cc="${cc:0:2}"
      ptr="$(ptr_name "$one_ip")"
      status="${IP_RESULT["$one_ip"]#*|}"
      fmtline "$name" "$dnsip" "$one_ip" "$cc" "$status" "$ptr"
    fi
  done

  if [[ -n "$domain_best" ]]; then
    for entry in "${RESOLVERS[@]}"; do
      name="${entry%%:*}"
      val="${fast_rtt_by_resolver["$name"]:-}"
      [[ -z "$val" ]] && continue
      diff=$(( val - domain_best )); (( diff<0 )) && diff=$(( -diff ))
      (( diff <= 25 )) && geo_hits["$name"]=$(( ${geo_hits["$name"]:-0} + 1 ))
    done
  fi

  echo
done

# -------------------------- Summary --------------------------
echo "=== Interception Summary ==="
if ((${#warn_flags[@]})); then
  for w in "${warn_flags[@]}"; do
    if [[ "$w" =~ ([0-9]+\.[0-9]+\.[0-9]+\.[0-9]+) ]]; then
      ip="${BASH_REMATCH[1]}"
      owner="$(get_owner "$ip")"; country="$(get_country "$ip")"
      echo "  - ${w}  [owner: ${owner:-UNKNOWN}; country: ${country:-UNKNOWN}]"
    else
      echo "  - ${w}"
    fi
  done
else
  echo "No interception indications detected by the identity checks."
fi
echo

echo "=== Resolver identity & egress ==="
# ECS is shown as a yes/- flag to keep this readable at 80 columns; the actual
# prefixes are listed underneath, since only the leaking resolvers need them.
printf "  %-16.16s %-15.15s %-30.30s %-4.4s %s\n" "Resolver" "DNS-IP" "Egress-IP" "ECS" "Identity"
printf -- "  %s\n" "$(printf '%*s' $(( WIDTH > 4 ? WIDTH - 4 : 76 )) "" | tr ' ' '-')"
for entry in "${RESOLVERS[@]}"; do
  name="${entry%%:*}"; dnsip="${entry#*:}"
  [[ -n "${res_ecs[$name]:-}" ]] && ecsflag="yes" || ecsflag="-"
  print_limited_line \
    "$(printf '  %-16.16s %-15.15s %-30.30s %-4.4s ' "$name" "$dnsip" "${res_egress[$name]:--}" "$ecsflag")" \
    "${res_id[$name]:-no-id}"
done
ecs_list=""
for entry in "${RESOLVERS[@]}"; do
  name="${entry%%:*}"
  [[ -n "${res_ecs[$name]:-}" ]] && ecs_list+="${ecs_list:+, }${name} ${res_ecs[$name]}"
done
if [[ -n "$ecs_list" ]]; then
  print_limited_line "  ECS prefixes disclosed: " "$ecs_list"
else
  echo "  ECS: no resolver disclosed your network prefix."
fi
echo "  Identity \"no-id\" = the operator publishes no id.server/hostname.bind record."
echo "  Egress \"-\" = the resolver did not answer the egress probe."
echo

echo "=== Geo-Awareness Ranking (lower avg RTT & more hits is better) ==="
resolver_count="${#RESOLVERS[@]}"
SUM_FIXED=$((16 + 15 + 12))
SUM_PIPES=3
if (( resolver_count >= 3 )); then
  SUM_FIXED=$((SUM_FIXED + 8))
  SUM_PIPES=$((SUM_PIPES + 1))
fi
NOTES_WIDTH=$(( WIDTH - (SUM_FIXED + SUM_PIPES) ))
(( NOTES_WIDTH < 8 )) && NOTES_WIDTH=8

if (( resolver_count >= 3 )); then
  SUMFMT="%-16.16s|%-15.15s|%-12.12s|%-8.8s|%-${NOTES_WIDTH}.${NOTES_WIDTH}s\n"
  printf "$SUMFMT" "Resolver" "DNS-IP" "AvgRTT" "GeoHits" "Notes"
else
  SUMFMT="%-16.16s|%-15.15s|%-12.12s|%-${NOTES_WIDTH}.${NOTES_WIDTH}s\n"
  printf "$SUMFMT" "Resolver" "DNS-IP" "AvgRTT" "Notes"
fi
printf -- "%-${WIDTH}.${WIDTH}s\n" "$(printf "%${WIDTH}s" "" | tr ' ' '-')"

for entry in "${RESOLVERS[@]}"; do
  name="${entry%%:*}"; dnsip="${entry#*:}"
  count=${rtt_count["$name"]:-0}; sum=${rtt_sum["$name"]:-0}
  avg="n/a"; ((count>0)) && avg=$(( sum / count ))
  note=""
  (( ${shared_id["$name"]:-0} == 1 ))     && note+="shared-identity "
  (( ${shared_egress["$name"]:-0} == 1 )) && note+="shared-egress "
  [[ -n "${res_ecs[$name]:-}" ]]          && note+="ecs-leak "
  (( ${res_noid["$name"]:-0} == 1 ))  && note+="no-id "

  if (( resolver_count >= 3 )); then
    hits=${geo_hits["$name"]:-0}
    printf "$SUMFMT" "$name" "$dnsip" "$avg" "$hits" "$note"
  else
    printf "$SUMFMT" "$name" "$dnsip" "$avg" "$note"
  fi
done

if ((${#warn_flags[@]})); then
  echo
  echo "! Results may be tainted due to DNS interception."
fi

echo
echo "Done."
