#!/usr/bin/env bash
# dns-client-verify.sh — client-side DNSSEC validation verification
#
# Proves whether the resolver you are pointed at actually validates DNSSEC:
#   1. AD flag is set on correctly-signed domains
#   2. Deliberately-broken signatures return SERVFAIL
#   3. Setting CD (checking-disabled) turns that SERVFAIL into NOERROR
#      (this is the test that proves the *resolver* is validating, rather
#       than the domain simply being broken for everyone)
#   4. Denial of existence is authenticated too (signed NXDOMAIN, NSEC/NSEC3)
#   5. Chain of trust from the name up to the root can be walked
#
# Also reported, but NOT part of the pass/fail verdict: Extended DNS Error
# reasons (RFC 8914), query egress + EDNS Client Subnet disclosure, and
# QNAME minimisation (RFC 9156).
#
# ---------------------------------------------------------------------------
# BACKEND NOTE
# ---------------------------------------------------------------------------
# This script works with EITHER ISC's dig/delv OR NLnet Labs' drill.
#
# dig is preferred and is auto-selected when present. Despite a common
# misconception, dig is NOT deprecated -- it is actively maintained by ISC as
# part of BIND 9. It is ldns/drill that is in maintenance mode: NLnet Labs has
# stated "ldns has been in maintenance mode since 2020 ... we only perform
# basic maintenance and bug fixes", and points to `dnsi` as drill's successor.
#
# drill is retained as a fallback purely because FreeBSD (and therefore
# OPNsense/pfSense) ships drill in the base system while dig requires
# installing bind-tools. On a stock firewall, drill may be all you have.
#
# Known behavioural differences handled below:
#   - dig prints "status: NOERROR"; drill prints "rcode: NOERROR"
#   - dig prints ";; SERVER: 1.1.1.1#53(1.1.1.1) (UDP)"; drill prints
#     ";; SERVER: 1.1.1.1" with no port or protocol
#   - drill has NO query timeout/retry options at all. Against an unreachable
#     resolver it blocks for ~15s (measured). Every drill call is therefore
#     wrapped in timeout(1); dig uses its native +time/+tries instead.
#   - chain of trust: dig backend uses delv (ISC's validating stub, and the
#     modern replacement for the removed `dig +sigchase`); drill uses -S.
# ---------------------------------------------------------------------------

set -uo pipefail

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

# -------------------- CLI --------------------
RESOLVER=""   # stored WITHOUT the leading '@'
ASSUME_YES=0

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

  --resolver   query a specific resolver instead of /etc/resolv.conf
               e.g. --resolver 192.168.7.1   or   --resolver @1.1.1.1
  -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"
}

while [ $# -gt 0 ]; do
  case "$1" in
    --resolver)   shift; RESOLVER="${1:-}" ;;
    --resolver=*) RESOLVER="${1#*=}" ;;
    -y|--yes)     ASSUME_YES=1 ;;
    -h|--help)    print_usage; exit 0 ;;
    *) echo "Unknown arg: $1" >&2; echo >&2; print_usage >&2; exit 2 ;;
  esac
  shift
done
RESOLVER="${RESOLVER#@}"

# -------------------- Test sets --------------------
# sigok/sigfail are a MATCHED PAIR from one operator: sigok must validate and
# sigfail must not. Without the sigok control a SERVFAIL from sigfail is
# ambiguous -- it could equally mean that test zone is simply unreachable.
GOOD_DOMAINS=(cloudflare.com dnssec-tools.org sigok.verteiltesysteme.net)
BAD_DOMAINS=(sigfail.verteiltesysteme.net dnssec-failed.org)

# Zones for authenticated denial of existence (NSEC/NSEC3): a nonexistent name
# in a signed zone must return NXDOMAIN *and* the AD flag. This exercises a
# completely different validation path from a positive answer.
# NOTE: cloudflare.com is deliberately excluded -- Cloudflare serves "black
# lies", answering a nonexistent name with NOERROR/NODATA plus a signed SOA
# instead of NXDOMAIN, so it fails this test for the wrong reason. Verified.
DENIAL_ZONES=(dnssec-tools.org isc.org)

# -------------------- 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 Client DNSSEC Verification
==============================

WHAT THIS DOES
  Queries a DNS resolver and proves whether it actually validates DNSSEC,
  using five checks:

    1. AD flag     -- correctly-signed domains come back authenticated
    2. SERVFAIL    -- deliberately-broken signatures are refused
    3. CD toggle   -- setting checking-disabled turns that SERVFAIL into
                      NOERROR, which proves YOUR resolver did the rejecting
                      rather than the domain being broken for everyone
    4. NXDOMAIN    -- denial of existence is authenticated too (NSEC/NSEC3)
    5. Trust chain -- the signature chain up to the root can be walked

  It also reports, WITHOUT affecting the pass/fail verdict:
    - why a check failed, via Extended DNS Errors (RFC 8914)
    - where your queries egress, and whether your network prefix is leaked
      to authoritative servers via EDNS Client Subnet
    - whether QNAME minimisation (RFC 9156) is on

WHAT IT TOUCHES
  Read-only. It sends DNS queries and nothing else: it changes no config,
  writes no files, and needs no privileges.
PURPOSE
  printf '%s\n' "$msg"
  echo "  Resolver under test:  ${RESOLVER:-system default (/etc/resolv.conf)}"
  echo "  Signed test domains:  ${GOOD_DOMAINS[*]}"
  echo "  Bogus  test domains:  ${BAD_DOMAINS[*]}"
  echo "  Denial-proof zones:   ${DENIAL_ZONES[*]}"
  echo
  echo "  Runtime: a few seconds."
  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

# -------------------- 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 "$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 dig; then
  DNS_BACKEND="dig"
  BACKEND_DESC="dig $(dig -v 2>&1 | head -1 | awk '{print $2}')"
  if have delv; then
    BACKEND_DESC="$BACKEND_DESC + delv (chain of trust)"
  else
    BACKEND_DESC="$BACKEND_DESC (no delv; chain of trust limited)"
  fi
elif have drill; then
  DNS_BACKEND="drill"
  BACKEND_DESC="drill $(drill -v 2>&1 | sed -n 's/.*drill version \([0-9.]*\).*/\1/p' | head -1) (ldns)"
else
  print_install_help
  exit 1
fi

# timeout(1) wrapper -- mandatory for drill, harmless for dig
TIMEOUT_CMD=""
if have timeout; then TIMEOUT_CMD="timeout"
elif have 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) -- an unreachable resolver will block ~15s per query." >&2
fi

# DNSSEC trust anchor for 'drill -S' (unbound ships one on most platforms)
DNS_ANCHOR=""
for f in /var/unbound/root.key /etc/unbound/root.key /var/lib/unbound/root.key /usr/local/etc/unbound/root.key; do
  [ -r "$f" ] && { DNS_ANCHOR="$f"; break; }
done

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

# dnsq_header <name> [type] [cd]  -> full response text (header/comments)
# Emits DNSSEC-OK queries. Pass cd=cd to set the checking-disabled bit.
dnsq_header() {
  local name="$1" type="${2:-A}" cd="${3:-}"
  case "$DNS_BACKEND" in
    dig)
      local args=(+dnssec +noall +comments +time=2 +tries=1)
      [ "$cd" = "cd" ] && args+=(+cdflag)
      [ -n "$RESOLVER" ] && args+=("@$RESOLVER")
      _tmo 10 dig "${args[@]}" "$name" "$type" 2>/dev/null
      ;;
    drill)
      local args=(-D)
      [ "$cd" = "cd" ] && args+=(-o CD)
      _tmo 10 drill "${args[@]}" "$name" ${RESOLVER:+@"$RESOLVER"} "$type" 2>/dev/null
      ;;
  esac
}

# dnsq_status <text> -> NOERROR | SERVFAIL | NXDOMAIN | ...
# dig says "status:", drill says "rcode:". Two -e exprs keep this portable to
# BSD sed (GNU-only "\|" alternation is deliberately avoided).
dnsq_status() {
  printf '%s\n' "$1" \
    | sed -n -e 's/.*status: \([A-Z]*\).*/\1/p' -e 's/.*rcode: \([A-Z]*\).*/\1/p' \
    | head -n1
}

# dnsq_has_flag <text> <flag> -> 0 if header flag present
# Extracts the field between "flags: " and the first ';' then word-matches, so
# it tolerates dig's "ad;" and drill's "ad ;" spacing alike.
dnsq_has_flag() {
  local flags
  flags="$(printf '%s\n' "$1" | sed -n 's/^;; flags: \([^;]*\);.*/\1/p' | head -n1)"
  case " $flags " in *" $2 "*) return 0 ;; *) return 1 ;; esac
}

# dnsq_ede <text> -> Extended DNS Error payloads (RFC 8914), one per line.
# Both backends surface these in the OPT pseudosection as
# "; EDE: <n> (<name>): <extra-text>", already present in dnsq_header() output
# -- it was simply discarded before. EDE says WHY validation failed, turning a
# bare FAIL into a diagnosis.
#
# Backend difference: drill 1.8.3 renders the EXTRA-TEXT field as raw hex byte
# pairs AND THEN the decoded string in parentheses, e.g.
#   ; EDE: 6 (DNSSEC Bogus): 66 61 69 ... (failed to verify sigfail...)
# whereas dig prints only the decoded string. The second sed strips runs of hex
# pairs so both backends produce identical, readable output.
dnsq_ede() {
  printf '%s\n' "$1" \
    | sed -n 's/^; EDE: \(.*\)$/\1/p' \
    | sed 's/\([0-9a-f][0-9a-f] \)\{4,\}//g'
}

# print_ede <text> -> emit indented "Reason (EDE):" lines when any are present
print_ede() {
  local ede line
  ede="$(dnsq_ede "$1")"
  [ -z "$ede" ] && return 0
  while IFS= read -r line; do
    [ -n "$line" ] && printf '  Reason (EDE): %s\n' "$line"
  done <<< "$ede"
}

# dnsq_txt <name> [resolver] -> TXT strings, one per line, quotes stripped
dnsq_txt() {
  local name="$1" res="${2:-$RESOLVER}" out
  case "$DNS_BACKEND" in
    dig)   out="$(_tmo 15 dig +short +time=3 +tries=2 ${res:+@"$res"} TXT "$name" 2>/dev/null)" ;;
    drill) out="$(_tmo 15 drill -Q "$name" TXT ${res:+@"$res"} 2>/dev/null)" ;;
  esac
  printf '%s\n' "$out" | grep -v '^;' | sed 's/^"//; s/"$//'
}

# dnsq_a <name> [resolver] -> first A record
dnsq_a() {
  local name="$1" res="${2:-$RESOLVER}" out
  case "$DNS_BACKEND" in
    dig)   out="$(_tmo 15 dig +short +time=3 +tries=2 ${res:+@"$res"} A "$name" 2>/dev/null)" ;;
    drill) out="$(_tmo 15 drill -Q A "$name" ${res:+@"$res"} 2>/dev/null)" ;;
  esac
  printf '%s\n' "$out" | grep -E '^[0-9]+\.' | head -n1
}

# dnsq_which_server -> the ";; SERVER:" line identifying who actually answered.
# NOTE: this deliberately does NOT reuse dnsq_header(). dig's "+noall +comments"
# suppresses the stats section, and the SERVER line lives in stats -- so the
# header-only query used elsewhere never contains it.
dnsq_which_server() {
  local out
  case "$DNS_BACKEND" in
    dig)   out="$(_tmo 10 dig +dnssec +time=2 +tries=1 ${RESOLVER:+@"$RESOLVER"} cloudflare.com A 2>/dev/null)" ;;
    drill) out="$(_tmo 10 drill -D cloudflare.com ${RESOLVER:+@"$RESOLVER"} A 2>/dev/null)" ;;
  esac
  printf '%s\n' "$out" | sed -n '/^;; SERVER:/p' | head -n1
}

# dnsq_chain <name> -> chain-of-trust walk
dnsq_chain() {
  local name="$1"
  case "$DNS_BACKEND" in
    dig)
      if have delv; then
        _tmo 30 delv ${RESOLVER:+@"$RESOLVER"} +vtrace "$name" 2>&1
      else
        echo "delv not installed -- install bind-tools/bind9-dnsutils for chain-of-trust output."
      fi
      ;;
    drill)
      _tmo 30 drill -S ${DNS_ANCHOR:+-k "$DNS_ANCHOR"} "$name" ${RESOLVER:+@"$RESOLVER"} 2>&1
      ;;
  esac
}

show_resolvers() {
  echo "Resolvers in /etc/resolv.conf:"
  if [ -f /etc/resolv.conf ]; then
    grep -E '^[[:space:]]*nameserver[[:space:]]+' /etc/resolv.conf || echo "  (none listed)"
  else
    echo "  (no /etc/resolv.conf)"
  fi
  [ -n "$RESOLVER" ] && echo "Target resolver override: @$RESOLVER"
}

# -------------------- Main --------------------
echo "=== DNS client verification ==="
date -u
echo "Backend: $BACKEND_DESC"
[ "$DNS_BACKEND" = "drill" ] && \
  echo "         (dig not found; drill is upstream maintenance-only -- see header notes)"
echo
show_resolvers
echo

ad_ok=0
echo "== DNSSEC: AD flag present on signed domains =="
for d in "${GOOD_DOMAINS[@]}"; do
  echo "--- $d ---"
  hdr="$(dnsq_header "$d" A)"
  printf '%s\n' "$hdr" | awk '/HEADER|flags:|SERVER:/{print}'
  if dnsq_has_flag "$hdr" ad; then
    echo "Result: OK (AD flag present)"
    ad_ok=$((ad_ok+1))
  else
    echo "Result: FAIL (no AD flag)"
    print_ede "$hdr"
  fi
  echo
done

sf_ok=0
echo "== DNSSEC: bogus signature should SERVFAIL =="
for d in "${BAD_DOMAINS[@]}"; do
  echo "--- $d ---"
  hdr="$(dnsq_header "$d" A)"
  # EDE filtered out of the raw dump: print_ede re-emits it below as a labelled
  # "Reason (EDE)" line, and printing it twice just adds noise.
  printf '%s\n' "$hdr" | grep -v '^; EDE:' | sed -n '1,8p'
  print_ede "$hdr"
  if [ "$(dnsq_status "$hdr")" = "SERVFAIL" ]; then
    echo "Result: OK (SERVFAIL as expected)"
    sf_ok=$((sf_ok+1))
  else
    echo "Result: FAIL (did not SERVFAIL)"
  fi
  echo
done

cd_ok=0
echo "== DNSSEC: CD flag behaviour (proves the resolver itself validates) =="
for d in "${BAD_DOMAINS[@]}"; do
  echo "--- $d ---"
  s1="$(dnsq_status "$(dnsq_header "$d" A)")"
  s2="$(dnsq_status "$(dnsq_header "$d" A cd)")"
  echo "Without CD -> status: ${s1:-UNKNOWN}"
  echo "With    CD -> status: ${s2:-UNKNOWN}"
  if [ "$s1" = "SERVFAIL" ] && [ "$s2" = "NOERROR" ]; then
    echo "Result: OK (resolver enforces DNSSEC; CD reveals the raw unvalidated data)"
    cd_ok=$((cd_ok+1))
  else
    echo "Result: FAIL (unexpected statuses; resolver may not validate, or ignores CD)"
  fi
  echo
done

nd_ok=0
echo "== DNSSEC: authenticated denial of existence (signed NXDOMAIN) =="
for z in "${DENIAL_ZONES[@]}"; do
  # Random label so the answer cannot be served from cache.
  probe="nx-$$-${RANDOM}.${z}"
  echo "--- $probe ---"
  hdr="$(dnsq_header "$probe" A)"
  st="$(dnsq_status "$hdr")"
  if dnsq_has_flag "$hdr" ad; then adf="AD present"; else adf="AD MISSING"; fi
  echo "status: ${st:-UNKNOWN}   ($adf)"
  print_ede "$hdr"
  if [ "$st" = "NXDOMAIN" ] && dnsq_has_flag "$hdr" ad; then
    echo "Result: OK (NSEC/NSEC3 denial proof validated)"
    nd_ok=$((nd_ok+1))
  else
    echo "Result: FAIL (denial of existence was not authenticated)"
  fi
  echo
done

echo "== Resolver that answered =="
srv="$(dnsq_which_server)"
echo "${srv:-  (no SERVER line returned)}"
echo

echo "== Egress: where your queries leave from (informational) =="
# Google's authoritative for this name reports the address IT saw the query
# arrive from -- your RESOLVER's egress IP, not your client IP. The same answer
# reports any EDNS Client Subnet the resolver forwarded on your behalf, which
# is a privacy disclosure worth knowing about.
egress_txt="$(dnsq_txt o-o.myaddr.l.google.com)"
# Accepts IPv4 AND IPv6 -- several resolvers egress to the authoritative over
# v6, and an IPv4-only pattern silently reported them as having no egress.
egress="$(printf '%s\n' "$egress_txt" | grep -vE '^edns0-client-subnet' | grep -E '^[0-9a-fA-F:.]+$' | head -n1)"
ecs="$(printf '%s\n' "$egress_txt" | sed -n 's/^edns0-client-subnet //p' | head -n1)"
# Second opinion from a different operator if Google gave nothing.
[ -z "$egress" ] && egress="$(dnsq_a whoami.akamai.net)"
echo "Egress IP seen by authoritative: ${egress:-UNKNOWN}"
if [ -n "$ecs" ]; then
  echo "EDNS Client Subnet disclosed:    $ecs"
  echo "  ^ your network prefix is being sent on to authoritative servers."
else
  echo "EDNS Client Subnet disclosed:    none"
fi
echo

echo "== Privacy: QNAME minimisation, RFC 9156 (informational) =="
qmin="$(dnsq_txt qnamemintest.internet.nl)"
if printf '%s' "$qmin" | grep -qi 'HOORAY'; then
  echo "Result: ENABLED"
elif printf '%s' "$qmin" | grep -qi 'NO - '; then
  echo "Result: DISABLED -- the full query name is sent to every server in the chain"
else
  echo "Result: UNKNOWN (no usable answer from qnamemintest.internet.nl)"
fi
printf '%s\n' "$qmin" | grep -iE 'HOORAY|NO - ' | sed 's/^/  /' | head -n1
echo

echo "== DNSSEC chain of trust =="
target="cloudflare.com"
echo "--- Trust path for $target ---"
chain="$(dnsq_chain "$target")"
if [ -n "$chain" ]; then
  printf '%s\n' "$chain"
else
  echo "WARN: chain-of-trust walk produced no output (transient?)."
fi
echo

echo "=== Summary ==="
echo "AD flag OK on   $ad_ok/${#GOOD_DOMAINS[@]} signed domains."
echo "SERVFAIL OK on  $sf_ok/${#BAD_DOMAINS[@]} bogus domains."
echo "CD behaviour OK on $cd_ok/${#BAD_DOMAINS[@]} bogus domains."
echo "Signed NXDOMAIN OK on $nd_ok/${#DENIAL_ZONES[@]} zones."
if [ "$ad_ok" -eq "${#GOOD_DOMAINS[@]}" ] && [ "$sf_ok" -ge 1 ] \
   && [ "$cd_ok" -ge 1 ] && [ "$nd_ok" -ge 1 ]; then
  echo "Verdict: DNSSEC validation is ACTIVE and enforced by your resolver."
  verdict=0
else
  echo "Verdict: One or more DNSSEC checks FAILED -- investigate resolver settings."
  verdict=1
fi

cat <<'NOTE'

Notes:
- These are client-side tests. They prove validation (AD flag, SERVFAIL on bad
  signatures, CD toggle, authenticated denial) but do NOT prove upstream
  DoT/DoH -- check that on the resolver/firewall itself.
- The egress and QNAME-minimisation sections are informational and do NOT
  affect the verdict.
- "Reason (EDE)" lines work on BOTH backends, but not every resolver emits
  Extended DNS Errors: Cloudflare and Quad9 do, while a stock Unbound needs
  "ede: yes" in its config before it will.
- Compare resolvers with --resolver, e.g.:
    ./dns-client-verify.sh --resolver 192.168.7.1
    ./dns-client-verify.sh --resolver @1.1.1.1
NOTE

exit "$verdict"
