#!/bin/ksh
#
# probe-dns [-n] [-o reed|text|json] [-f config] <domain>
#
# Configuration: what -f names, else /etc/probe-dns/<domain>.conf when it is there, else
# /etc/probe-dns.conf, else the defaults below. One file per zone is how zones that do not
# hold the same policy are watched by one command.
#
# One verdict for a DNS zone: delegation, SOA serial, DNSSEC and the expiration of its
# signatures, CAA, secondaries, recursion. Read-only and unprivileged: the config file, the
# verdict cache and the network are all it touches.
#
# Retry, config key retries, built-in default 2 attempts. A check that ends without having
# measured anything -- the path that words the verdict "could not test" and gives the probe
# rc 2 -- is run again, up to that many attempts, retry_pause_s apart, inside the deadline of
# that same check. A query lost on the wire, or a server slow under the load of the other
# zones of the same pass, says nothing about the zone, and reading it as a fault of the zone
# is the one verdict a probe must not give. Measured 2026-09-16, 29 zones run back to back:
# one came back rc 2, "no result for soa", and rc 0 with its six checks green in 1.09 s when
# run alone a minute later.
# A check that ends fail is NOT retried. It measured, the zone answered, and a second draw
# would hide a real fault behind the luck of the next packet, at the price of another round
# of queries on every pass.
#
# Verdict cache, config key cache_ttl_min, built-in default 0 which is no cache at all. One
# file per zone, ${CACHEDIR}/<zone>, its mtime the date of the verdict [verifie:openbsd/src
# usr.bin/stat/stat.1, "m" is st_mtime and defaults to the D output form, so seconds]. Younger
# than cache_ttl_min minutes: the statuses and the details come back from the file, are
# rendered in the format asked for, and the probe exits on the status they carry, without one
# query. Older, absent or written for another check list: the checks run. Only a run where
# every check concluded, ok or fail, is written -- a run that could not test says nothing
# about the zone, and keeping it would replay one lost query for the whole TTL, reed asking
# for two consecutive failures before it alerts [roles/sys/files/hn-check-openbsd.sh, same
# rule]. The directory is created when missing; a write that fails leaves no cache, which is
# not an error, the probe running as _reed under a /var/cache owned by root. -n leaves the
# file alone in both directions, which is what a run by hand wants.
#
# dig(1) of the base system, every option used [verifie:openbsd/src usr.bin/dig/dig.1 v1.8,
# man.openbsd.org/dig.1]:
#   @server        "The name or IP address of the name server to query. When the argument is
#                  a hostname, dig resolves that name before querying that name server."
#   name type      positional, "The type of query, as documented for -t. The default is A."
#   +noall         "Set or clear all display flags."
#   +comments      "Display comment lines in the output (on by default)." -- the HEADER and
#                  flags lines, and the section titles, come back through it
#   +answer        "Display the answer section of a reply (on by default)."
#   +authority     "Display the authority section of a reply (on by default)."
#   +additional    "Display the additional section of a reply (on by default)."
#   +question      "Print the question section of a query as a comment when an answer is
#                  returned (on by default)."
#   +[no]recurse   "Set the RD (recursion desired) bit in the query (on by default)."
#   +dnssec        "Request DNSSEC records be sent by setting the DNSSEC OK bit (DO) in the
#                  OPT record in the additional section of the query (off by default)."
#   +nocrypto      "Display cryptographic fields in DNSSEC records (on by default). [...] in
#                  the DNSKEY case the key id is displayed as the replacement"
#   +timeout=#     "Set the timeout for a query to # seconds."
#   +tries=#       "Set the number of times to try UDP queries to server to #."
# Output parsed: ";; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: N" and ";; flags: qr aa;
# QUERY: ..." [openbsd/src usr.bin/dig/dig.c:447-475], ";; ANSWER SECTION:" and its siblings
# [usr.bin/dig/lib/dns/message.c:2315-2322], one record per line, name TTL IN TYPE rdata, the
# DNSKEY rdata ending in "[key id = N]" under +nocrypto [lib/dns/rdata/generic/key_25.c],
# the RRSIG rdata carrying "type alg labels ttl expiration inception keytag signer"
# [lib/dns/rdata/generic/rrsig_46.c], dates as YYYYMMDDHHMMSS.
#
# Several questions in one dig (dqg below), "MULTIPLE QUERIES" of dig.1: options before the first
# name apply to every question. The lookups run one after the other, in command line order, a
# retry over TCP going back to the head of the queue [usr.bin/dig/dighost.c:865, 1532-1550]; a
# lookup prints one answer at most, or its failure line and the next lookup starts
# [dighost.c:2713-2722, 3581]; an answer whose question section is not the question asked is
# dropped by dig itself [dighost.c:3401-3440]. dig installs no SIGTERM handler
# [usr.bin/dig/lib/isc/unix/app.c, SIGPIPE only] and its stdout into a file is block buffered
# [verifie:man.openbsd.org/setvbuf.3]: killed by hn-timeout, it leaves nothing, the answers it
# already had included.
#
# Copyright (c) 2026 Jean-Gerard Pailloncy
#
# Permission to use, copy, modify, and distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
#
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.

# ksh imports the PATH of its caller over its own default and never restores it
# [verifie:openbsd/src bin/ksh/main.c:220-226, 272-274]. /usr/bin first: hn-timeout and xargs(1)
# exec dig through execvp, which tries each PATH entry in turn [verifie:execvp(3)].
# /usr/local/bin last, for hn-timeout alone (sysutils/hn-timeout): timeout(1) of base with its
# signals blocked before fork(), where base blocks them after and a command that exits in between
# leaves timeout asleep until its deadline. Last, so that no package shadows a base tool.
PATH=/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin
export PATH
set -u
export LC_ALL=C			# deterministic sed/awk/grep/sort parsing

PROG=probe-dns
NL='
'
# Absolute path to this file: a -C child is re-exec'd through it by xargs, from whatever
# directory the caller happened to be in.
case $0 in
/*)	SELF=$0 ;;
*)	SELF=$(cd "$(dirname "$0")" && pwd)/$(basename "$0") ;;
esac
ALL_CHECKS="delegation soa dnssec caa secondaries recursion"
# Out of zone name of the recursion check: a name every authoritative server of a zone of
# ours has no business answering.
RECURSION_NAME=www.iana.org

FORMAT=reed
# Empty until the domain is known: the per-domain path is built from the argument.
CONF=
CONFDIR=/etc/probe-dns
CONFFILE=/etc/probe-dns.conf
CACHEDIR=/var/cache/probe-dns
DOMAIN=
NOCACHE=0
# Set by -C: this invocation runs ONE check and nothing else. The parent re-execs this script
# once per check through xargs, which runs them in parallel and returns when the last one is
# gone -- one blocking wait instead of a poll loop, and hn-timeout around xargs is the only
# deadline, since it signals the whole process group [verifie:openbsd/src usr.bin/timeout/
# timeout.c:226-231,292-297].
RUNCHECK=0

usage_text() {
	# -n: neither read nor write the verdict cache. Both directions, so that a run by hand
	# measures the zone and leaves the file of the scheduled runs as it found it.
	echo "usage: $PROG [-hn] [-o reed|text|json] [-f config] <domain>"
	echo "       -n bypasses the verdict cache of ${CACHEDIR}, read and write"
	echo "       config keys: retries (default 2 attempts), retry_pause_s (default 1 s),"
	echo "                    cache_ttl_min (default 0, off)"
}
usage() { usage_text >&2; exit 2; }

# One stderr line, 120 characters at most, no single quote: reed substitutes the value
# textually into a shell command line [reed-alert functions.lisp:62-82].
emit() {
	# One line, whatever comes in: newlines and tabs folded to a space, single quotes dropped,
	# 120 characters through the precision of %s [verifie:man.openbsd.org/printf.1]. cut(1) would
	# terminate its own line and the echo behind it would make a second one.
	_m=$(printf '%s' "$1" | tr -d "'" | tr '\n\r\t' '   ')
	printf '%.120s\n' "$_m" >&2
}

# $1 and a newline on stdout. echo is a builtin and printf is not [verifie:openbsd/src
# bin/ksh/c_ksh.c and c_sh.c, builtin tables]; echo takes a leading "-" for an option and may
# read a backslash as an escape, so those two cases, and only them, go through printf.
putl() {
	case $1 in
	-* | *\\*)	printf '%s\n' "$1" ;;
	*)		echo "$1" ;;
	esac
}

# rc 2: the probe could not do its work. Never worded as a failure of the zone.
cannot() {
	emit "${DOMAIN:-$PROG}: could not test, $*"
	exit 2
}

while getopts Cc:f:ho:n _opt; do
	case "$_opt" in
	C)	RUNCHECK=1 ;;
	c)	CONF=$OPTARG ;;	# obsolete, -f
	h)	usage_text; exit 0 ;;
	f)	case $OPTARG in reed|text|json) FORMAT=$OPTARG ;; *) CONF=$OPTARG ;; esac ;;	# an old -f format is read as the format
	o)	FORMAT=$OPTARG ;;
	n)	NOCACHE=1 ;;
	*)	usage ;;
	esac
done
shift $((OPTIND - 1))
# -C takes the domain and the name of the check; anything else takes the domain alone.
if [ "$RUNCHECK" -eq 1 ]; then
	[ $# -eq 2 ] || usage
	CHECKNAME=$2
else
	[ $# -eq 1 ] || usage
	CHECKNAME=
fi
DOMAIN=$1

case "$FORMAT" in
reed|text|json)	;;
*)		usage ;;
esac

# Non-empty alnum/'-' labels, no edge '-', separated by '.'. Also what keeps a quote or a
# shell metacharacter out of the stderr line below. The character class first, on the whole
# string, then every way a string of that class breaks the label rule: an empty label (edge dot,
# two dots) or a label with an edge '-'. Patterns, not grep: no process, and no line by line
# reading that would let a second line pass.
is_domain() {
	case $1 in
	'' | *[!A-Za-z0-9.-]* | .* | *. | *..* | -* | *- | *.-* | *-.*)	return 1 ;;
	esac
	return 0
}
# Run by a -C child too, on the name its parent passes: no process on that path, the name being
# valid and lower case already.
is_domain "$DOMAIN" || cannot "the argument is not a domain name"
case $DOMAIN in
*[A-Z]*)	DOMAIN=$(printf '%s' "$DOMAIN" | tr 'A-Z' 'a-z') ;;
esac

# Resolved only now: the per-domain path carries the argument, and a name that is not a domain
# must never pick the file that gets read.
if [ -z "$CONF" ]; then
	if [ -f "$CONFDIR/$DOMAIN.conf" ]; then
		CONF=$CONFDIR/$DOMAIN.conf
	else
		CONF=$CONFFILE
	fi
fi

# stat, mkdir and mv are the verdict cache: its date, its directory, and the rename that puts
# a complete file in place of the old one.
for _t in dig hn-timeout xargs sed awk tr mktemp date stat mkdir mv; do
	command -v "$_t" >/dev/null 2>&1 || cannot "missing tool $_t"
done

# ---- configuration ----

CF_SOA_MAX_AGE_DAYS=90
CF_RRSIG_MIN_DAYS=7
CF_CAA_ISSUERS=
CF_UNSIGNED_OK=no
CF_CHECKS=all
CF_SKIP=
CF_TIMEOUT_S=15
CF_BUDGET=50
CF_RETRIES=2
CF_CACHE_TTL_MIN=0
# Seconds between two attempts of a check that could not test. A lost query is answered by the
# next one, and waiting a moment lets a server that was busy come back.
CF_RETRY_PAUSE_S=1

# Result in _trim, and not a process spawned: the parser calls this three times per config
# line, which was a third of the external commands of a run [mesure 2026-09-17, 1387 calls
# over the test suite]. ksh matches POSIX character classes in patterns
# [verifie:openbsd/src bin/ksh/ksh.1, "character class"] and /bin/sh is the same binary
# [verifie:openbsd/src bin/ksh/Makefile, LINKS].
trim() {
	_trim=$1
	while :; do
		case $_trim in
		[[:space:]]*)	_trim=${_trim#?} ;;
		*)		break ;;
		esac
	done
	while :; do
		case $_trim in
		*[[:space:]])	_trim=${_trim%?} ;;
		*)		break ;;
		esac
	done
}

if [ -e "$CONF" ]; then
	# access(2) answers for the real uid, so the readability of a file is tested by
	# opening it [verifie:man.openbsd.org/access.2].
	( : < "$CONF" ) 2>/dev/null || cannot "config unreadable, $CONF"
	_ln=0
	while IFS= read -r _line || [ -n "$_line" ]; do
		_ln=$((_ln + 1))
		_line=${_line%%#*}
		trim "$_line"; _line=$_trim
		[ -z "$_line" ] && continue
		case "$_line" in
		*=*)	;;
		*)	cannot "config line $_ln is not key = value" ;;
		esac
		trim "${_line%%=*}"; _k=$_trim
		trim "${_line#*=}"; _v=$_trim
		# The keys here and the keys of the shipped sample are the same set; the test
		# compares the two lists in both directions.
		case "$_k" in
		soa_max_age_days)	CF_SOA_MAX_AGE_DAYS=$_v ;;
		rrsig_min_days)		CF_RRSIG_MIN_DAYS=$_v ;;
		caa_issuers)		CF_CAA_ISSUERS=$_v ;;
		unsigned_ok)		CF_UNSIGNED_OK=$_v ;;
		checks)			CF_CHECKS=$_v ;;
		skip)			CF_SKIP="$CF_SKIP $_v" ;;
		timeout_s)		CF_TIMEOUT_S=$_v ;;
		budget)			CF_BUDGET=$_v ;;
		retries)		CF_RETRIES=$_v ;;
		cache_ttl_min)		CF_CACHE_TTL_MIN=$_v ;;
		retry_pause_s)		CF_RETRY_PAUSE_S=$_v ;;
		*)			cannot "unknown config key $_k" ;;
		esac
	done < "$CONF"
fi

case $CF_SOA_MAX_AGE_DAYS in ''|*[!0-9]*|0*) cannot "soa_max_age_days is not a positive integer" ;; esac
case $CF_RRSIG_MIN_DAYS in ''|*[!0-9]*|0*) cannot "rrsig_min_days is not a positive integer" ;; esac
case $CF_TIMEOUT_S in ''|*[!0-9]*|0*) cannot "timeout_s is not a positive integer" ;; esac
case $CF_BUDGET in ''|*[!0-9]*|0*) cannot "budget is not a positive integer" ;; esac
# retries counts the attempts, so 1 is one attempt and no retry; 0 would mean not running the
# check at all, which `checks` and `skip` are for.
case $CF_RETRIES in ''|*[!0-9]*|0*) cannot "retries is not a positive integer" ;; esac
# Zero is a value here, not the absence of one: it turns the cache off.
case $CF_CACHE_TTL_MIN in 0) ;; ''|*[!0-9]*|0*) cannot "cache_ttl_min is not a non-negative integer" ;; esac
# Zero is a value here too: the attempts follow one another with no pause, which is what the
# test suite asks for.
case $CF_RETRY_PAUSE_S in 0) ;; ''|*[!0-9]*|0*) cannot "retry_pause_s is not a non-negative integer" ;; esac
case "$CF_UNSIGNED_OK" in
yes|no)	;;
*)	cannot "unsigned_ok is neither yes nor no" ;;
esac
for _i in $CF_CAA_ISSUERS; do
	is_domain "$_i" || cannot "caa_issuers holds $_i, not a domain name"
done

if [ "$CF_CHECKS" = all ]; then
	SEL=$ALL_CHECKS
else
	SEL=
	for _c in $CF_CHECKS; do
		case " $ALL_CHECKS " in
		*" $_c "*)	SEL="$SEL $_c" ;;
		*)		cannot "unknown check name $_c" ;;
		esac
	done
fi
# Dropped by name, after the selection: a check that cannot hold for this zone leaves without
# touching the list `checks` names, which stays `all` and keeps growing with the probe.
for _c in $CF_SKIP; do
	case " $ALL_CHECKS " in
	*" $_c "*)	;;
	*)		cannot "unknown check to skip, $_c" ;;
	esac
	_keep=
	for _k in $SEL; do
		[ "$_k" = "$_c" ] || _keep="$_keep $_k"
	done
	SEL=$_keep
done
case $SEL in *[!\ ]*) ;; *) cannot "no check selected" ;; esac

# ---- dates ----

# Plausible calendar date at the head of the string: year 19xx or 20xx, month 01-12, day 01-31.
# Three patterns on what is left after each field, no process.
is_date() {
	case $1 in
	19[0-9][0-9]* | 20[0-9][0-9]*)	;;
	*)				return 1 ;;
	esac
	_id=${1#????}
	case $_id in
	0[1-9]* | 1[0-2]*)	;;
	*)			return 1 ;;
	esac
	_id=${_id#??}
	case $_id in
	0[1-9]* | [12][0-9]* | 3[01]*)	return 0 ;;
	esac
	return 1
}

# Day number of a YYYYMMDD[HHMMSS] string, proleptic Gregorian, days since 1970-01-01, in _DN.
# Shell arithmetic: date(1) of the base system parses no arbitrary input format, and the RRSIG
# dates of dig and the YYYYMMDDnn serials are day-grained needs anyway. Hours dropped. Only on a
# string is_date accepted: its first eight characters are digits. 10# keeps a leading zero from
# reading as octal [verifie:openbsd/src bin/ksh/ksh.1, "base#number"]; every division is on
# non-negative operands, where the integer quotient and the int() of awk agree. The suite holds
# this function against that awk formula.
daynum() {
	_yr=${1#????????}
	_yd=${1%"$_yr"}
	_yy=$(( 10#${_yd%????} ))
	_yd=${_yd#????}
	_ym=$(( 10#${_yd%??} ))
	_yd=$(( 10#${_yd#??} ))
	[ "$_ym" -le 2 ] && _yy=$(( _yy - 1 ))
	_ye=$(( _yy / 400 ))
	_yo=$(( _yy - _ye * 400 ))
	if [ "$_ym" -gt 2 ]; then
		_yp=$(( _ym - 3 ))
	else
		_yp=$(( _ym + 9 ))
	fi
	_yd=$(( (153 * _yp + 2) / 5 + _yd - 1 ))
	_yd=$(( _yo * 365 + _yo / 4 - _yo / 100 + _yd ))
	_DN=$(( _ye * 146097 + _yd - 719468 ))
}

# TODAY, the day number of the UTC date. Worked out once, by the parent, when soa or dnssec is
# selected, and handed to the -C children through PROBE_DNS_TODAY: one date(1) per zone instead
# of one per check.
TODAY=
today() {
	_td=$(date -u +%Y%m%d)
	case $_td in
	[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9])	;;
	*)						return 1 ;;
	esac
	is_date "$_td" || return 1
	daynum "$_td"
	TODAY=$_DN
}

# The tree belongs to the parent: a -C child is handed the path and touches neither the mktemp
# nor the trap, or six children would each make a tree and the first to exit would remove the
# one holding every result.
DEBUG=${PROBE_DNS_DEBUG:-}
if [ "$RUNCHECK" -eq 1 ]; then
	TMPD=${PROBE_DNS_TMPD:-}
	[ -d "$TMPD" ] || cannot "no result directory for check $CHECKNAME"
	# Digits or nothing: the value goes into arithmetic, and a check that finds it empty works
	# it out itself.
	TODAY=${PROBE_DNS_TODAY:-}
	case $TODAY in
	*[!0-9]*)	TODAY= ;;
	esac
else
	TMPD=$(mktemp -d "${TMPDIR:-/tmp}/probe-dns.XXXXXXXXXX") || cannot "mktemp failed"
	# PROBE_DNS_DEBUG set: the temporary directory survives and its path is named on stderr,
	# and each check keeps its own stderr beside its result. A check that dies before writing
	# its status leaves no other trace, and the contract keeps the real stderr for the summary.
	# PROBE_DNS_KEEP alone keeps the tree and says nothing: the contract gives stderr ONE summary
	# line, and a caller that runs the probe hundreds of times -- the suite -- cannot take a second
	# one. PROBE_DNS_DEBUG keeps it too and names it, for a run by hand.
	if [ -n "$DEBUG" ]; then
		trap 'echo "probe-dns: debug tree kept in $TMPD" >&2' EXIT HUP INT TERM
	elif [ -n "${PROBE_DNS_KEEP:-}" ]; then
		trap ':' EXIT HUP INT TERM
	else
		trap 'rm -rf "$TMPD"' EXIT HUP INT TERM
	fi
fi

# ---- verdict cache ----

# One file per zone, named after it. The domain was checked against $_DRE above, so it carries
# no separator and cannot name a file outside this directory.
CACHE=$CACHEDIR/$DOMAIN
CACHED=0


# Line format, one check per "s" line and one detail line per "d" line, both naming their
# check: "s soa fail", "d soa FAIL serial differs, ...". Parsed back into the tree the verdict
# reads, so a hit renders in the format asked for, and not in the one that filled the file.
CACHE_MAGIC="probe-dns cache 1"

# Fills $TMPD from $CACHE. Anything unexpected in the file is a miss, not an error: the file
# is a cache, and the checks are always able to answer in its place.
cache_load() {
	_head=1
	_seen=
	while IFS= read -r _l || [ -n "$_l" ]; do
		if [ "$_head" -eq 1 ]; then
			_head=0
			[ "$_l" = "$CACHE_MAGIC" ] || return 1
			continue
		fi
		case "$_l" in
		"s "*)	_r=${_l#s }; _n=${_r%% *}; _v=${_r#* } ;;
		"d "*)	_r=${_l#d }; _n=${_r%% *}; _v=${_r#* } ;;
		*)	return 1 ;;
		esac
		case " $ALL_CHECKS " in
		*" $_n "*)	;;
		*)		return 1 ;;
		esac
		case "$_l" in
		"s "*)
			# silent and untested never reach the file: only a run where every check
			# concluded is written.
			case "$_v" in
			ok|warn|skip|fail)	;;
			*)			return 1 ;;
			esac
			putl "$_v" > "$TMPD/$_n.st"
			_seen="$_seen $_n"
			;;
		*)
			[ -f "$TMPD/$_n.st" ] || return 1
			putl "$_v" >> "$TMPD/$_n.txt"
			;;
		esac
	done < "$CACHE"
	# A configuration that changed the list of checks makes the file answer another question.
	# Both lists compared on their words, whatever spacing built them.
	set -- $_seen; _a=$*
	set -- $SEL;   _b=$*
	[ "$_a" = "$_b" ] || return 1
}

# Parent only: a -C child exists because its parent found no fresh verdict, and six children
# would each pay the stat and the date of a lookup already done.
if [ "$RUNCHECK" -eq 0 ] && [ "$NOCACHE" -eq 0 ] && [ "$CF_CACHE_TTL_MIN" -gt 0 ] \
    && [ -f "$CACHE" ]; then
	_mt=$(stat -f %m "$CACHE" 2>/dev/null)
	case "$_mt" in
	'' | *[!0-9]*)	_mt= ;;
	esac
	if [ -n "$_mt" ]; then
		# The clock is read before the arithmetic: an empty operand is a fatal error that
		# kills this shell [docs/KSH.md]. A cache that cannot be dated is a cache miss.
		_nw=$(date +%s)
		case $_nw in
		''|*[!0-9]*)	_age=-1 ;;
		*)		_age=$(( (_nw - _mt) / 60 )) ;;
		esac
		# A negative age is a file dated in the future, from a clock that moved: it would
		# otherwise be served until the clock caught up with it.
		if [ "$_age" -ge 0 ] && [ "$_age" -lt "$CF_CACHE_TTL_MIN" ]; then
			if cache_load; then
				CACHED=1
			else
				rm -f "$TMPD"/*.st "$TMPD"/*.txt
			fi
		fi
	fi
fi

# ---- per check deadline ----

# The parent never signals a check: a background job killed by its parent makes the shell
# print a job message on stderr, and rc 0 owes the caller complete silence. Each check
# carries its own deadline instead and every network call gets what is left of it.
DL=0
# SECONDS counts from the start of this shell, on CLOCK_MONOTONIC, as an integer
# [verifie:openbsd/src bin/ksh/var.c:925-937, setint of difference.tv_sec]. Truncated at each
# read all the same: the ksh93 of a workstation answers a decimal, which an integer arithmetic
# expansion will not take. A deadline read from it costs no fork, cannot be
# shifted by an adjustment of the wall clock, and always holds an integer: `$(( DL - ))` on an
# empty operand is a fatal error that killed a check BEFORE it wrote its status, silently, its
# stderr going nowhere [mesure 2026-09-17].
# Sets _LEFT rather than printing it: a substitution would fork a subshell, and printf is not a
# ksh builtin [verifie:openbsd/src bin/ksh/c_ksh.c, c_sh.c builtin tables], so reading the time
# left cost two processes on the hottest path of the probe -- once per DNS query.
left() {
	_LEFT=$(( DL - ${SECONDS%%.*} ))
	[ "$_LEFT" -lt 1 ] && _LEFT=0
}

# dq [@server] name type [+opt ...]: the answer with its header, flags and section titles, one
# record per line. Empty when the deadline is spent or nothing answered. The header, the flags
# and the titles are the comment lines this probe reads, which is why +comments comes back
# after +noall.
dq() {
	_df=$1; shift
	left; _qt=$_LEFT
	[ "$_qt" -eq 0 ] && { : > "$_df"; return 1; }
	_qw=5
	[ "$_qt" -lt 5 ] && _qw=$_qt
	hn-timeout "$_qt" dig +noall +comments +answer +authority +additional \
	    +timeout="$_qw" +tries=1 "$@" > "$_df" 2>/dev/null
	_qr=$(<"$_df")
	# dig prints ";; connection timed out; no servers could be reached" on stdout, whatever
	# the display flags [openbsd/src usr.bin/dig/dighost.c:2715]: an answer is what carries a
	# HEADER line, the rest is no answer.
	case "$_qr" in
	*";; ->>HEADER<<-"*)	return 0 ;;
	esac
	: > "$_df"
	return 1
}

# dqg file count [@server] [+opt ...] name type ...: count questions to ONE server in ONE dig,
# their answers one after the other in the file, each with its question section, for the readers
# below to tell them apart. One hn-timeout and one dig where dq pays one of each per question.
#
# Only when the deadline leaves 5 s per question: each question then waits what dq would give it,
# and dig is done before hn-timeout fires, which would take the answers already in its buffer
# along with the one it kills (see the head of this file). Otherwise rc 1 and nothing asked: the
# caller asks one question per dq, each with what is left. One case escapes the arithmetic: an
# answer truncated over UDP is asked again over TCP [dighost.c:3466-3480], a second wait for
# the same question; the SOA and NS sets asked here fit a UDP answer [infere].
dqg() {
	_df=$1; _dk=$2; shift 2
	left; _qt=$_LEFT
	[ "$_qt" -lt $(( 5 * _dk )) ] && return 1
	hn-timeout "$_qt" dig +noall +comments +question +answer +authority +additional \
	    +timeout=5 +tries=1 "$@" > "$_df" 2>/dev/null
	return 0
}

# Records of one section and one type: "name ttl class type rdata...", one per line. The
# section titles are the only lines starting with ";; " and ending in " SECTION:".
recs() {
	awk -v s="$2" -v t="$3" '
		/^;; [A-Z]+ SECTION:$/ { sec = $2; next }
		/^;/ || NF < 5 { next }
		sec == s && $4 == t { print }' "$1"
}

# The readers of an answer below are one awk each: a chain of awk, sed, tr and sort pays a
# process per link. Every one keeps the record filter of recs. The sets they print are what
# `sort -u` prints: unique lines in byte order -- awk compares two strings with strcmp
# [verifie:openbsd/src usr.bin/awk/run.c:895-901], sort(1) always as LC_ALL=C
# [verifie:openbsd/src usr.bin/sort/sort.1, STANDARDS]. Two values that look like numbers compare
# as numbers in awk [same lines], so every member is made a string first: tolower(), substr() or
# a concatenation.
#
# keep(k, v) adds v to set k; srt(k) sorts it in place; out(k) prints it one per line, jn(k)
# returns it joined by spaces. full[k]: a member other than "", what makes $(... | sort -u)
# non-empty.
AWKLIB='
function keep(k, v) {
	if ((k, v) in seen)
		return
	seen[k, v] = 1
	lst[k, ++cnt[k]] = v
	if (v != "")
		full[k] = 1
}
function srt(k,    i, j, v) {
	for (i = 2; i <= cnt[k]; i++) {
		v = lst[k, i]
		for (j = i - 1; j > 0 && lst[k, j] > v; j--)
			lst[k, j + 1] = lst[k, j]
		lst[k, j + 1] = v
	}
}
function out(k,    i) {
	srt(k)
	for (i = 1; i <= cnt[k]; i++)
		print lst[k, i]
}
function jn(k,    i, r) {
	srt(k)
	r = ""
	for (i = 1; i <= cnt[k]; i++)
		r = (i > 1) ? r " " lst[k, i] : lst[k, i]
	return r
}
'

# NS targets of section s owned by n, lower case, no trailing dot, set 1; n "." takes every owner.
# s2 names a second section, set 2, printed instead when set 1 has no member but "".
#
# qk > 0: the file is a dqg one, read twice, and only the answer to "n NS" counts, question qi
# of qk. An answer is told by its question section, which dig has already matched against the
# question asked. An answer without one -- a server that drops it -- is placed by its rank, and
# only when every question got an answer: a lookup prints one answer at most, so qk answers for
# qk questions are the questions in order, while fewer leave no way to tell which one is missing.
# rc 1 when no answer is that question: what a failed dq means.
NSAWK='
function mine() {
	if (bq != "")
		return bq == qn " NS"
	return nb == qk && b == qi
}
BEGIN { qn = (n == ".") ? "." : n "." }
qk && NR == FNR {
	if ($0 ~ /^;; ->>HEADER<<-/)
		nb++
	next
}
qk && /^;; ->>HEADER<<-/ {
	if (b && mine())
		hit = 1
	b++
	bq = ""
	sec = ""
	next
}
/^;; [A-Z]+ SECTION:$/ { sec = $2; next }
/^;/ {
	if (qk && sec == "QUESTION" && bq == "")
		bq = tolower(substr($1, 2)) " " toupper($3)
	next
}
NF < 5 { next }
qk && !mine() { next }
$4 == "NS" && (n == "." || tolower($1) == qn) {
	v = tolower($5)
	sub(/\.$/, "", v)
	if (sec == s)
		keep(1, v)
	else if (s2 != "" && sec == s2)
		keep(2, v)
}
END {
	if (qk && b && mine())
		hit = 1
	if (full[1] || s2 == "")
		out(1)
	else
		out(2)
	if (qk && !hit)
		exit 1
}
'

# "NOERROR", "NXDOMAIN", "REFUSED"... from the HEADER line; empty without one.
# Sets _RCODE from the FIRST "status: <code>," of the answer -- ${x#*...} stops at the shortest
# prefix, which is the head -1 the pipeline paid a third process for.
rcode_of() {
	_RCODE=
	case $1 in
	*"status: "*)	_t=${1#*status: }; _RCODE=${_t%%,*} ;;
	esac
}

# The flags of the answer, " qr aa rd" style, with a leading space so that " aa" is testable.
flags_of() {
	_FLAGS=
	case $1 in
	*";; flags:"*)	_t=${1#*;; flags:}; _FLAGS=${_t%%; QUERY:*} ;;
	esac
}

# ns_targets file section owner [second section]: sorted unique NS targets, one per line.
ns_targets() {
	awk -v s="$2" -v n="$3" -v s2="${4:-}" -v qk=0 -v qi=0 "$AWKLIB$NSAWK" "$1"
}

# ns_group file owner index count: the same, answer section, from question index of the count a
# dqg file holds; rc 1 when that answer is not in the file.
ns_group() {
	awk -v s=ANSWER -v n="$2" -v s2= -v qi="$3" -v qk="$4" "$AWKLIB$NSAWK" "$1" "$1"
}

# Serial of the first SOA of the answer section: rdata is mname rname serial ...
SERIALAWK='
/^;; [A-Z]+ SECTION:$/ { sec = $2; next }
/^;/ || NF < 5 { next }
sec == "ANSWER" && $4 == "SOA" { print $7; exit }
'

# Authoritative servers of the zone as the resolver knows them, sorted, in _SRV; empty when the
# resolver gave none. $1, when given, is a dqg file holding "<domain> NS" as its second question
# of two, read in place of a query of its own.
auth_servers() {
	_SRV=
	if [ -n "${1:-}" ]; then
		_SRV=$(ns_group "$1" "$DOMAIN" 2 2) || _SRV=
		return 0
	fi
	dq "$QF.as" "$DOMAIN" NS || return 1
	_SRV=$(ns_targets "$QF.as" ANSWER "$DOMAIN")
}

# The zone above the domain and its servers. The parent of a name is the closest ancestor
# the resolver has an NS set for; the root for a top level domain. Sets PARENT and PARENT_NS.
# $1, when given, is a dqg file holding the NS question of the first ancestor as its first
# question of two, read in place of the first query.
#
# The ancestor goes to dig with its final dot: a bare label that dig knows as a type or a class
# -- the TLDs in and ch are classes, mx, md, mg and mr types [verifie:openbsd/src
# usr.bin/dig/lib/dns/rcode.c:171-225, lib/dns/rdata.c:791-796] -- is read as that type or
# class, not as a name [verifie:usr.bin/dig/dig.c:1686-1745, parse_args], and dig asks another
# question. Before the first name of a dqg call, it would be the class of every question.
PARENT=
PARENT_NS=
find_parent() {
	_pf=${1:-}
	_z=$DOMAIN
	while :; do
		case "$_z" in
		*.*)	_z=${_z#*.} ;;
		*)	_z=. ;;
		esac
		if [ -n "$_pf" ]; then
			_ns=$(ns_group "$_pf" "$_z" 1 2) || return 1
			_pf=
		else
			_zq=$_z.
			[ "$_z" = . ] && _zq=.
			dq "$QF.fp" "$_zq" NS || return 1
			_ns=$(ns_targets "$QF.fp" ANSWER "$_z")
		fi
		if [ -n "$_ns" ]; then
			PARENT=$_z
			PARENT_NS=$_ns
			return 0
		fi
		[ "$_z" = . ] && return 1
	done
}

# The first parent server that answers the question, +norecurse. Sets ANS.
#
# EVERY server of the set is tried. One that stays silent is not a parent that stays silent, and
# giving up on the first turned a whole check into "could not test": measured on hors-normes.media
# the 2026-09-23, one pass in five came back "no answer from the servers of media" while the six
# other checks of that zone were green.
#
# A spent deadline is the other reason dq fails, and there the next server has nothing left to
# answer within: the budget is read after each attempt, and an exhausted one ends the loop where
# a silent server does not.
ask_parent() {
	ANS=$QF.ap
	: > "$ANS"
	for _s in $PARENT_NS; do
		if dq "$ANS" "@$_s" +norecurse "$@"; then
			[ -s "$ANS" ] && return 0
		fi
		left
		[ "$_LEFT" -eq 0 ] && return 1
	done
	return 1
}

# ---- result of one check ----

OUTF=/dev/null
ST=ok
# The lines pile up in _DETAIL and the file is written once, at the end of the attempt: printf is
# not a ksh builtin [verifie:openbsd/src bin/ksh/c_ksh.c and c_sh.c, builtin tables], and these
# five are called once per line of the verdict. A check cut before that write leaves no detail,
# and none is read: its status file is missing too, so the verdict already says silent.
_DETAIL=
add() {
	if [ -n "$_DETAIL" ]; then
		_DETAIL="$_DETAIL
$1"
	else
		_DETAIL=$1
	fi
}
good() { add "OK   $*"; }
soft() { add "WARN $*"; [ "$ST" = fail ] || ST=warn; }
bad()  { add "FAIL $*"; ST=fail; }
skip() { add "SKIP $*"; [ "$ST" = ok ] && ST=skip; return 0; }
# Nothing was measured, and the reason is not a choice: the verdict carries it as rc 2, where a
# skip stays silent.
none() { add "SKIP $*"; ST=untested; return 0; }

# The words of $1, one space between them, whatever spacing built it: the shell splits on IFS
# and $* joins on its first character, a space. printf, tr and sed are three processes for what
# `set --` does with none [verifie:openbsd/src bin/ksh/c_ksh.c and c_sh.c, builtin tables].
join() { set -- $1; _JOIN=$*; }

# ---- checks ----

# Owners of the A and AAAA of the additional section, lower case, final dot kept, space separated.
GLUEAWK='
/^;; [A-Z]+ SECTION:$/ { sec = $2; next }
/^;/ || NF < 5 { next }
sec == "ADDITIONAL" && ($4 == "A" || $4 == "AAAA") { printf "%s ", tolower($1) }
END { print "" }
'

# The NS set the parent delegates to, against the NS set the zone publishes, and the glue the
# parent must carry for a server named inside the zone [RFC 1034 section 4.2.1].
chk_delegation() {
	find_parent || { none "no NS set for any zone above $DOMAIN"; return 0; }
	ask_parent "$DOMAIN" NS || { none "no answer from the servers of $PARENT"; return 0; }
	# A referral carries the NS in the authority section; a parent also authoritative for
	# the child answers them in the answer section.
	_pset=$(ns_targets "$ANS" AUTHORITY "$DOMAIN" ANSWER)
	if [ -z "$_pset" ]; then
		rcode_of "$(<"$ANS")"
		bad "no delegation for $DOMAIN at the parent $PARENT, status $_RCODE"
		return 0
	fi
	join "$_pset"
	good "parent $PARENT delegates to $_JOIN"
	# Read at the first server named inside the zone, and only then: hosted zones have none.
	_glue=
	_gread=0
	_cset=
	for _s in $_pset; do
		case "$_s" in
		"$DOMAIN" | *".$DOMAIN")
			if [ "$_gread" -eq 0 ]; then
				_glue=$(awk "$GLUEAWK" "$ANS")
				_gread=1
			fi
			case " $_glue " in
			*" $_s. "*)	good "glue for $_s at the parent" ;;
			*)		bad "no glue for $_s at the parent, a server inside the zone needs one" ;;
			esac
			;;
		esac
		if [ -z "$_cset" ]; then
			dq "$QF.d" "@$_s" +norecurse "$DOMAIN" NS || :
			_cset=$(ns_targets "$QF.d" ANSWER "$DOMAIN")
		fi
	done
	if [ -z "$_cset" ]; then
		bad "no NS set answered by the delegated servers"
		return 0
	fi
	if [ "$_pset" = "$_cset" ]; then
		good "zone publishes the same NS set"
	else
		join "$_pset"; _jp=$_JOIN
		join "$_cset"
		bad "NS set differs, parent $_jp zone $_JOIN"
	fi
	return 0
}

# Same serial everywhere, and a serial that still moves: under the YYYYMMDDnn convention its
# date is the last time anyone touched the zone.
chk_soa() {
	[ -n "$TODAY" ] || today || cannot "date arithmetic failed"
	auth_servers; _srv=$_SRV
	[ -z "$_srv" ] && { none "no NS set from the resolver for $DOMAIN"; return 0; }
	# The first serial, and the count of distinct ones, kept as they come: the sort -u and the
	# head -1 of a pipeline over the list are a word test away.
	_first=
	_uniq=
	_distinct=0
	_pairs=
	for _s in $_srv; do
		dq "$QF.s" "@$_s" +norecurse "$DOMAIN" SOA || :
		_n=$(awk "$SERIALAWK" "$QF.s")
		if [ -z "$_n" ]; then
			bad "$_s answers no SOA for $DOMAIN"
			continue
		fi
		[ -z "$_first" ] && _first=$_n
		case " $_uniq " in
		*" $_n "*)	;;
		*)		_uniq="$_uniq $_n"; _distinct=$((_distinct + 1)) ;;
		esac
		_pairs="$_pairs $_s=$_n"
	done
	[ -z "$_pairs" ] && { none "no SOA answer from any server of $DOMAIN"; return 0; }
	if [ "$_distinct" -eq 1 ]; then
		good "serial $_first on every answering server"
	else
		bad "serial differs between servers,$_pairs"
	fi
	case $_first in
	[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9])	_ten=1 ;;
	*)							_ten=0 ;;
	esac
	if [ "$_ten" -eq 1 ] && is_date "$_first"; then
		# A day number that is not a non-negative integer -- a date before 1970 -- is not
		# read as an age.
		daynum "$_first"; _dn=$_DN
		case $_dn in
		''|*[!0-9]*)	none "serial $_first is not a readable date"; return 0 ;;
		esac
		_age=$(( TODAY - _dn ))
		if [ "$_age" -gt "$CF_SOA_MAX_AGE_DAYS" ]; then
			bad "serial $_first is $_age days old, over the $CF_SOA_MAX_AGE_DAYS allowed"
		else
			good "serial $_first is $_age days old"
		fi
	else
		good "serial $_first is not a date, age not checked"
	fi
	return 0
}

# Key tags of the DS of the answer section, sorted as sort -u sorts them: as strings.
DSAWK='
/^;; [A-Z]+ SECTION:$/ { sec = $2; next }
/^;/ || NF < 5 { next }
sec == "ANSWER" && $4 == "DS" { keep(1, $5 "") }
END { out(1) }
'

# Key ids of the DNSKEY of the answer section, from the last "[key id = N]" of the line, the
# capture of `sed -n "s/.*\[key id = \([0-9]*\)\].*/\1/p"`; rc 1 when the section holds no
# DNSKEY.
KEYAWK='
/^;; [A-Z]+ SECTION:$/ { sec = $2; next }
/^;/ || NF < 5 { next }
sec == "ANSWER" && $4 == "DNSKEY" {
	anykey = 1
	r = $0
	got = 0
	while (match(r, /\[key id = [0-9]*\]/)) {
		id = substr(r, RSTART + 10, RLENGTH - 11)
		got = 1
		r = substr(r, RSTART + 1)
	}
	if (got)
		keep(1, id)
}
END {
	out(1)
	exit !anykey
}
'

# The RRSIG of the answer sections of every file given, in file order: a first line "D S", 1 when
# one covers DNSKEY, 1 when one covers SOA, then "covered expiration" per RRSIG. The section is
# reset per file and per answer, so that a dqg file reads as the files dq would have written.
SIGAWK='
FNR == 1 { sec = "" }
/^;; ->>HEADER<<-/ { sec = ""; next }
/^;; [A-Z]+ SECTION:$/ { sec = $2; next }
/^;/ || NF < 5 { next }
sec == "ANSWER" && $4 == "RRSIG" {
	if ($5 == "DNSKEY")
		hk = 1
	if ($5 == "SOA")
		hs = 1
	l[++c] = $5 " " $9
}
END {
	print (hk + 0) " " (hs + 0)
	for (i = 1; i <= c; i++)
		print l[i]
}
'

# DS at the parent against the DNSKEY of the zone by key tag, RRSIG on SOA and DNSKEY, and
# the expiration of every RRSIG seen on the apex: past it, every validating resolver answers
# SERVFAIL for the whole zone, and the date is readable weeks ahead.
chk_dnssec() {
	[ -n "$TODAY" ] || today || cannot "date arithmetic failed"
	# The two questions this check puts to the resolver, in one dig: the NS set of the first
	# ancestor, which find_parent asks first, and that of the zone, which auth_servers asks
	# further down. Final dots: see find_parent.
	case $DOMAIN in
	*.*)	_z1=${DOMAIN#*.}. ;;
	*)	_z1=. ;;
	esac
	_rf=
	dqg "$QF.r" 2 "$_z1" NS "$DOMAIN." NS && _rf=$QF.r
	find_parent "$_rf" || { none "no NS set for any zone above $DOMAIN"; return 0; }
	ask_parent "$DOMAIN" DS || { none "no answer from the servers of $PARENT"; return 0; }
	_dstags=$(awk "$AWKLIB$DSAWK" "$ANS")
	if [ -z "$_dstags" ]; then
		if [ "$CF_UNSIGNED_OK" = yes ]; then
			good "unsigned, no DS at the parent $PARENT"
		else
			bad "no DS at the parent $PARENT, the zone is unsigned"
		fi
		return 0
	fi
	join "$_dstags"
	good "DS at the parent, key tag $_JOIN"
	auth_servers "$_rf"; _srv=$_SRV
	[ -z "$_srv" ] && { none "no NS set from the resolver for $DOMAIN"; return 0; }
	# The first server that answers a DNSKEY gives the SOA and the NS too, both in one dig: it
	# has just answered, where a silent one would cost a wait per question.
	_keyids=
	_grp=0
	for _s in $_srv; do
		dq "$QF.k" "@$_s" +norecurse +dnssec +nocrypto "$DOMAIN" DNSKEY || :
		_keyids=$(awk "$AWKLIB$KEYAWK" "$QF.k") || continue
		if dqg "$QF.g" 2 "@$_s" +norecurse +dnssec "$DOMAIN." SOA "$DOMAIN." NS; then
			_grp=1
		else
			dq "$QF.soa" "@$_s" +norecurse +dnssec "$DOMAIN" SOA || :
			dq "$QF.ns" "@$_s" +norecurse +dnssec "$DOMAIN" NS || :
		fi
		break
	done
	[ -z "$_keyids" ] && { bad "DS at the parent but no DNSKEY answered by the servers of $DOMAIN"; return 0; }
	_hit=0
	for _d in $_dstags; do
		# grep -x of a key tag against the key ids, one per line: a whole line, so newlines
		# around both.
		case "$NL$_keyids$NL" in
		*"$NL$_d$NL"*)
			good "DS $_d matches a DNSKEY of the zone"
			_hit=$((_hit + 1))
			;;
		*)
			soft "DS $_d matches no DNSKEY of the zone, stale DS or key rolled"
			;;
		esac
	done
	join "$_keyids"
	[ "$_hit" -eq 0 ] && bad "no DS at the parent matches a DNSKEY, key tags $_JOIN"
	if [ "$_grp" -eq 1 ]; then
		awk "$SIGAWK" "$QF.k" "$QF.g" > "$QF.sig"
	else
		awk "$SIGAWK" "$QF.k" "$QF.soa" "$QF.ns" > "$QF.sig"
	fi
	# RRSIG rdata: covered alg labels ttl expiration inception keytag signer, so the second word
	# of a line below, $9 of the record, is the expiration as YYYYMMDDHHMMSS.
	_min=
	_mint=
	{
		read -r _hk _hs
		if [ "$_hk" = 1 ]; then
			good "RRSIG on DNSKEY"
		else
			bad "no RRSIG on DNSKEY, the apex is not signed"
		fi
		if [ "$_hs" = 1 ]; then
			good "RRSIG on SOA"
		else
			bad "no RRSIG on SOA, the apex is not signed"
		fi
		while read -r _t _e; do
			is_date "$_e" || { bad "RRSIG on $_t has an unreadable expiration $_e"; continue; }
			daynum "$_e"; _dn=$_DN
			case $_dn in
			''|*[!0-9]*)	bad "RRSIG on $_t has an unreadable expiration $_e"; continue ;;
			esac
			_d=$(( _dn - TODAY ))
			if [ -z "$_min" ] || [ "$_d" -lt "$_min" ]; then
				_min=$_d
				_mint=$_t
			fi
		done
	} < "$QF.sig"
	if [ -n "$_min" ]; then
		if [ "$_min" -lt "$CF_RRSIG_MIN_DAYS" ]; then
			bad "RRSIG on $_mint expires in $_min days, under the $CF_RRSIG_MIN_DAYS required"
		else
			good "closest RRSIG expiration in $_min days, on $_mint"
		fi
	fi
	return 0
}

# Issue values of the CAA of the answer section. rc 3: no CAA at all; rc 4: no issue value but
# "". Otherwise three lines: 1 when a value forbids every CA, the issuers sorted, the allowed
# issuers of the configuration in canonical form -- lower case, no final dot.
CAAAWK='
/^;; [A-Z]+ SECTION:$/ { sec = $2; next }
/^;/ || NF < 5 { next }
sec == "ANSWER" && $4 == "CAA" {
	anycaa = 1
	if ($6 == "issue" || $6 == "issuewild") {
		v = $7
		gsub(/"/, "", v)
		keep(1, tolower(v))
	}
}
END {
	if (!anycaa)
		exit 3
	if (!full[1])
		exit 4
	deny = 0
	for (i = 1; i <= cnt[1]; i++) {
		v = lst[1, i]
		if (v == "")
			continue
		if (substr(v, 1, 1) == ";")
			deny = 1
		else {
			sub(/;.*/, "", v)
			keep(2, v)
		}
	}
	print deny
	print jn(2)
	na = split(allow, al)
	r = ""
	for (i = 1; i <= na; i++) {
		a = tolower(al[i])
		sub(/\.$/, "", a)
		r = (i > 1) ? r " " a : a
	}
	print r
}
'

# A CAA set at the apex, and its issue values among the issuers allowed [RFC 8659 section 4].
chk_caa() {
	auth_servers; _srv=$_SRV
	[ -z "$_srv" ] && { none "no NS set from the resolver for $DOMAIN"; return 0; }
	: > "$QF.caa"
	for _s in $_srv; do
		dq "$QF.caa" "@$_s" +norecurse "$DOMAIN" CAA && break
	done
	[ -s "$QF.caa" ] || { none "no answer from any server of $DOMAIN"; return 0; }
	# rdata: flags tag "value". A value of ";" alone names NO issuer and forbids every CA
	# [verifie:RFC 8659 section 4.3, "the CA that issues no certificates"]; any other value is
	# an issuer, its parameters following a semicolon. Cutting the parameters first would turn
	# the refusal into an empty string and read it as the absence of an issue record.
	awk -v allow="$CF_CAA_ISSUERS" "$AWKLIB$CAAAWK" "$QF.caa" > "$QF.cv"
	case $? in
	0)	;;
	4)	bad "CAA present but no issue record"; return 0 ;;
	*)	bad "no CAA at the apex, any CA may issue for $DOMAIN"; return 0 ;;
	esac
	{ read -r _deny; read -r _issue; read -r _allow; } < "$QF.cv"
	# The two together contradict each other at one apex: a CA reading the set sees an issuer
	# allowed and every issuer forbidden, and which one it honours is its own business.
	if [ "$_deny" = 1 ] && [ -n "$_issue" ]; then
		join "$_issue"
		bad "CAA forbids every issuer and names $_JOIN at the same apex"
		return 0
	fi
	if [ "$_deny" = 1 ]; then
		good "CAA issue \";\", no CA may issue for $DOMAIN"
		return 0
	fi
	join "$_issue"
	good "CAA issue $_JOIN"
	case $CF_CAA_ISSUERS in
	*[!\ ]*)	;;
	*)		soft "caa_issuers empty in the configuration, issuers not checked"; return 0 ;;
	esac
	for _i in $_issue; do
		case " $_allow " in
		*" $_i "*)	good "issuer $_i allowed" ;;
		*)		bad "issuer $_i not in caa_issuers" ;;
		esac
	done
	return 0
}

# A secondary is a stand-in for the others: the zone is served as long as one of them answers a
# coherent SOA, so one unreachable server is a WARN, not a FAIL -- the redundancy did its job.
# A server that answers with a DIFFERENT serial is a different fault: it is not silent, it
# actively serves a stale zone to whoever queries it, and that stays a FAIL regardless of how
# many other servers are current -- redundancy does not fix a server telling the wrong answer.
chk_secondaries() {
	auth_servers; _srv=$_SRV
	[ -z "$_srv" ] && { none "no NS set from the resolver for $DOMAIN"; return 0; }
	_ref=
	_seen=0
	_ok=0
	for _s in $_srv; do
		dq "$QF.sec" "@$_s" +norecurse "$DOMAIN" SOA || :
		if [ ! -s "$QF.sec" ]; then
			soft "$_s does not answer"
			continue
		fi
		_seen=$((_seen + 1))
		_n=$(awk "$SERIALAWK" "$QF.sec")
		flags_of "$(<"$QF.sec")"
		case "$_FLAGS" in
		*" aa"*)	;;
		*)		soft "$_s answers without aa, not authoritative for $DOMAIN"; continue ;;
		esac
		[ -z "$_n" ] && { soft "$_s answers no SOA for $DOMAIN"; continue; }
		[ -z "$_ref" ] && _ref=$_n
		if [ "$_n" = "$_ref" ]; then
			good "$_s authoritative, serial $_n"
			_ok=$((_ok + 1))
		else
			bad "$_s authoritative with serial $_n, expected $_ref"
		fi
	done
	[ "$_seen" -eq 0 ] && none "no answer from any server of $DOMAIN"
	# Answers were seen but none of them a coherent SOA: no secondary is actually standing in,
	# same as if none had answered at all.
	[ "$_seen" -gt 0 ] && [ "$_ok" -eq 0 ] && bad "no secondary of $DOMAIN answers a coherent SOA"
	return 0
}

# No authoritative server answers a question outside the zone: an open resolver on an
# authoritative address is an amplifier and a cache to poison. REFUSED, or an empty answer
# section, is what is expected; the RA flag alone says recursion is on offer. The question goes
# with RD set, dig's default [verifie:man.openbsd.org/dig.1, +[no]recurse]: without it a
# resolver answers from its cache only, and a name it has not cached comes back empty.
chk_recursion() {
	auth_servers; _srv=$_SRV
	[ -z "$_srv" ] && { none "no NS set from the resolver for $DOMAIN"; return 0; }
	_seen=0
	for _s in $_srv; do
		dq "$QF.rec" "@$_s" +recurse "$RECURSION_NAME" A || :
		if [ ! -s "$QF.rec" ]; then
			soft "$_s does not answer the out of zone question"
			continue
		fi
		_seen=$((_seen + 1))
		rcode_of "$(<"$QF.rec")"; _rc=$_RCODE
		if [ "$_rc" = REFUSED ]; then
			good "$_s refuses $RECURSION_NAME"
			continue
		fi
		if [ -n "$(recs "$QF.rec" ANSWER A)" ]; then
			bad "$_s answers $RECURSION_NAME, recursion offered"
			continue
		fi
		flags_of "$(<"$QF.rec")"
		case "$_FLAGS" in
		*" ra"*)	soft "$_s sets ra on $RECURSION_NAME, recursion available, status $_rc" ;;
		*)		good "$_s gives no answer for $RECURSION_NAME, status $_rc" ;;
		esac
	done
	[ "$_seen" -eq 0 ] && none "no answer from any server of $DOMAIN"
	return 0
}

# ---- run ----

FIRED=0
NCHK=0
for _ck in $SEL; do
	NCHK=$((NCHK + 1))
done

# A fresh verdict was loaded: no resolver, no query, no watchdog. The tree $TMPD carries the
# statuses and the details the verdict section below reads, exactly as a run would leave them.
# The body below is not indented under this `if`: the comments of a check are wrapped to the
# width of the file, and one tab more would push them past it.
if [ "$CACHED" -eq 0 ]; then

# A silent resolver turns every DNS check into a fake failure of the zone, so it is rc 2. The
# parent answers this for the whole run: a -C child would repeat the query once per check, six
# round trips buying nothing.
if [ "$RUNCHECK" -eq 0 ]; then
	case " $SEL " in
	*" soa "* | *" dnssec "*)	today || cannot "date arithmetic failed" ;;
	esac
	[ -n "$(hn-timeout 10 dig +noall +answer +timeout=5 +tries=1 . NS 2>/dev/null)" ] \
	    || cannot "resolver silent, no root NS answer"
fi

# Both descriptors go nowhere. stderr: the one line there belongs to the summary, and past the
# budget the parent is gone with the temporary directory, so a check still running writes into
# nothing and its shell would complain on the stderr reed reads. stdout: a check inherits the
# pipe the caller reads the verdict through, and an orphan left running past the budget holds
# that pipe open until its own timeout -- the reader then blocks well past the deadline it asked
# for. Detail lines go to ${OUTF}, so neither descriptor carries anything.
run_one_check() {
	_ck=$1
	if [ -n "$DEBUG" ]; then
		exec 2>"$TMPD/$_ck.err" 1>/dev/null
	else
		exec 2>/dev/null 1>/dev/null
	fi
	OUTF="$TMPD/$_ck.txt"
	# Prefix of the answer files of this check: dq writes there and awk reads there, one process
	# per analysis instead of two -- a printf feeding a pipe is a process for a string the shell
	# already holds.
	QF="$TMPD/$_ck"
	# One deadline for the whole check, attempts included: the retries buy a second draw,
	# never more time than the check was given.
	DL=$(( ${SECONDS%%.*} + CF_TIMEOUT_S ))
	_try=1
	while :; do
		# Emptied at each attempt: the lines of an attempt that measured nothing describe
		# nothing, and the detail must read as the attempt that stands.
		_DETAIL=
		ST=ok
		"chk_$_ck"
		[ "$ST" = untested ] || break
		[ "$_try" -ge "$CF_RETRIES" ] && break
		# Deadline spent: the next attempt would find no time for a query and end untested
		# again, on the same silence.
		left; [ "$_LEFT" -eq 0 ] && break
		_try=$((_try + 1))
		[ "$CF_RETRY_PAUSE_S" -eq 0 ] || sleep "$CF_RETRY_PAUSE_S"
	done
	if [ -n "$_DETAIL" ]; then
		putl "$_DETAIL" > "$OUTF"
	else
		: > "$OUTF"
	fi
	# Written last: a check that never got there is reported as silent, not as green.
	putl "$ST" > "$TMPD/$_ck.st"
}

# -C: this process IS one check. Nothing below runs, the parent reads the tree.
if [ "$RUNCHECK" -eq 1 ]; then
	case " $ALL_CHECKS " in
	*" $CHECKNAME "*)	;;
	*)			cannot "unknown check $CHECKNAME" ;;
	esac
	run_one_check "$CHECKNAME"
	exit 0
fi

# One process per check, run in parallel by xargs, which returns when the last one is gone
# [verifie:openbsd/src usr.bin/xargs/xargs.1, -P maxprocs]. hn-timeout around it is the whole
# deadline: without -f it puts the command in its own process group and signals the GROUP when
# the budget is spent [verifie:openbsd/src usr.bin/timeout/timeout.c:226-231,292-297], so xargs
# and every check still in flight go together. One blocking wait, no poll, no sleep, and no
# clock read in a loop -- what the poll cost, measured over the suite: 1140 forks of sleep and
# as many of date.
#
# Processes per zone, a signed zone of two servers, six checks, rc 0 in the reed format: 249
# before -- 43 of them timeout and dig, 68 printf, the rest the awk, sed, sort, tr, grep and cat
# chains over each answer and a preamble every child repeated -- and 66 now, 39 of them hn-timeout
# and dig [mesure 2026-09-27 on the workstation, the stubs of test-probe-dns.sh behind wrappers
# that log every exec of the PATH, printf forced out of the builtins of bash as it is out of
# those of ksh; infere for the host until ktrace -i on one zone, kdump, and a count of execve].
PROBE_DNS_TMPD=$TMPD
PROBE_DNS_TODAY=$TODAY
export PROBE_DNS_TMPD PROBE_DNS_TODAY
# The stderr of xargs goes to a file of the tree, never to the terminal: that stream carries the
# ONE summary line the contract gives reed, and xargs reports there both its own death by the
# group signal and whatever a child wrote before redirecting. Kept for the debug tree, read by
# nothing else -- but a check that dies in its preamble leaves its reason here and nowhere else.
# The child re-reads the same configuration file rather than inheriting parsed values: one
# source, and a key added later needs nothing here. An unreadable path was already refused
# above, and an absent one leaves the built-in defaults on both sides.
# Through sh and not the path alone: the child is this same file, and whether it carries the
# execute bit is the caller's business, not ours -- the installed one does, a copy a test made
# with a redirection does not.
# The names reach xargs through a here document, a file the shell writes itself
# [verifie:openbsd/src bin/ksh/ksh.1, "here document"]: no printf process feeding a pipe.
_names=
for _ck in $SEL; do
	_names="$_names$_ck$NL"
done
if [ -n "$CONF" ]; then
	hn-timeout "$CF_BUDGET" xargs -n1 -P "$NCHK" sh "$SELF" -C -f "$CONF" "$DOMAIN" \
	    2>>"$TMPD/launch.err" <<EOF
$_names
EOF
else
	hn-timeout "$CF_BUDGET" xargs -n1 -P "$NCHK" sh "$SELF" -C "$DOMAIN" \
	    2>>"$TMPD/launch.err" <<EOF
$_names
EOF
fi
# 124 is what hn-timeout reports when it fired [verifie:man.openbsd.org/timeout.1 EXIT STATUS].
[ $? -eq 124 ] && FIRED=1

fi	# CACHED

# ---- verdict ----

TOTAL=0
NKO=0
KO=
SILENT=
for _ck in $SEL; do
	TOTAL=$((TOTAL + 1))
	if [ -f "$TMPD/$_ck.st" ]; then
		# read, a builtin: no cat process per check.
		_s=
		read -r _s < "$TMPD/$_ck.st" || :
	else
		_s=silent
		SILENT="$SILENT $_ck"
	fi
	if [ "$_s" = fail ]; then
		NKO=$((NKO + 1))
		KO="$KO $_ck"
	fi
	# A check that could not run measured nothing: calling that a success would hand a green
	# verdict for a test that never happened, which is the one thing a probe must not do.
	[ "$_s" = untested ] && SILENT="$SILENT $_ck"
done

RC=0
[ "$NKO" -gt 0 ] && RC=1
[ -n "$SILENT" ] && RC=2

join_names() {
	printf '%s' "$*" | sed 's/^ *//; s/  */, /g'
}

sum_line() {
	# No domain in it: reed2 writes the desc of the probe before this line in syslog, and the body
	# of the probe before it in the ntfy message, both naming the domain already [ports-hn
	# sysutils/reed2, Reed2.Notifier]. The text format, which nothing prefixes, adds it itself.
	_base="$NKO/$TOTAL KO"
	_names=
	_trunc=
	for _k in $KO; do
		if [ -z "$_names" ]; then
			_cand=$_k
		else
			_cand="$_names, $_k"
		fi
		# 115 leaves room for the ", ..." that says the list was cut.
		if [ ${#_base} -lt 110 ] && [ $((${#_base} + ${#_cand} + 3)) -le 115 ]; then
			_names=$_cand
		else
			_trunc=1
			break
		fi
	done
	if [ -n "$_names" ]; then
		[ -n "$_trunc" ] && _names="$_names, ..."
		printf '%s (%s)' "$_base" "$_names"
	else
		printf '%s' "$_base"
	fi
}

detail_of() {
	[ -f "$TMPD/$1.txt" ] || return 0
	awk '{printf "%s%s", sep, $0; sep = "; "} END {print ""}' "$TMPD/$1.txt"
}

status_of() {
	if [ -f "$TMPD/$1.st" ]; then
		_so=
		read -r _so < "$TMPD/$1.st" || :
		echo "$_so"
	else
		echo silent
	fi
}

jesc() {
	printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' | tr '\t\n\r' '   '
}

if [ "$FORMAT" = json ]; then
	printf '{"domain":"%s"' "$DOMAIN"
	for _ck in $SEL; do
		printf ',"%s":{"status":"%s","detail":"%s"}' \
		    "$_ck" "$(status_of "$_ck")" "$(jesc "$(detail_of "$_ck")")"
	done
	_arr=
	for _k in $KO; do
		if [ -z "$_arr" ]; then
			_arr="\"$_k\""
		else
			_arr="$_arr,\"$_k\""
		fi
	done
	printf ',"verdict":{"ko":%d,"total":%d,"failed":[%s],"rc":%d}}\n' \
	    "$NKO" "$TOTAL" "$_arr" "$RC"
elif [ "$FORMAT" = text ]; then
	# For a human at a terminal: every check, whatever the verdict, and a last line that says
	# where it landed. reed stays silent on success and cannot serve that purpose.
	for _ck in $SEL; do
		printf '%-11s %s\n' "$_ck" "$(status_of "$_ck")"
		[ -f "$TMPD/$_ck.txt" ] && sed 's/^/    /' "$TMPD/$_ck.txt"
	done
	if [ "$RC" -eq 0 ]; then
		printf '%s: %d/%d ok\n' "$DOMAIN" "$TOTAL" "$TOTAL"
	else
		printf '%s: %s\n' "$DOMAIN" "$(sum_line)"
	fi
else
	# reed: rc 0 owes complete silence, so the per check detail is printed only when there
	# is something to report.
	if [ "$RC" -ne 0 ]; then
		for _ck in $SEL; do
			printf '%-11s %s\n' "$_ck" "$(status_of "$_ck")"
			[ -f "$TMPD/$_ck.txt" ] && sed 's/^/    /' "$TMPD/$_ck.txt"
		done
	fi
fi

# The summary goes to stderr for the machine formats: reed reads the first stderr line, and json
# is consumed by something that wants the verdict apart from the document. In text a human reads
# stdout, where the same line already sits, and a duplicate on stderr would interleave with it.
if [ "$FORMAT" = text ]; then
	:
elif [ "$RC" -eq 2 ]; then
	_what=$(join_names "$SILENT")
	if [ "$FIRED" -eq 1 ]; then
		emit "could not test, budget ${CF_BUDGET}s expired, no result for $_what"
	else
		emit "could not test, no result for $_what"
	fi
elif [ "$RC" -eq 1 ]; then
	emit "$(sum_line)"
fi

# ---- the verdict kept ----

cache_dump() {
	printf '%s\n' "$CACHE_MAGIC"
	for _c in $SEL; do
		printf 's %s %s\n' "$_c" "$(status_of "$_c")"
		[ -f "$TMPD/$_c.txt" ] && sed "s|^|d $_c |" "$TMPD/$_c.txt"
	done
	return 0
}

# A subshell: umask, and a failure that stops at the closing parenthesis. Writing needs a
# directory root owns, so it may well fail -- that leaves no cache, which is not an error.
# The file appears whole or not at all, mv inside the directory being a rename(2) and
# rename(2) guaranteeing an instance of the destination always exists [verifie:openbsd/src
# bin/mv/mv.1, lib/libc/sys/rename.2].
cache_store() {
	(
		umask 022
		mkdir -p "$CACHEDIR" || exit 1
		_tmp="$CACHE.$$"
		cache_dump > "$_tmp" || { rm -f "$_tmp"; exit 1; }
		mv "$_tmp" "$CACHE" || { rm -f "$_tmp"; exit 1; }
	) 2>/dev/null || true
}

# A verdict of success alone. Never on rc 2: a run that could not test says nothing about the
# zone. Never on rc 1 either: reed alerts on TWO consecutive failures [:try 2 of the __mon_cmd
# entry], and a cached failure would answer both passes from a single measurement, which is one
# measurement short of what the operator asked for. A zone in fault is therefore measured at
# every pass, which is when the cost of measuring matters least. Never after a hit -- rewriting
# the file would carry its date forward at every pass, and one verdict would answer for the zone
# for as long as the probe keeps reading it.
if [ "$NOCACHE" -eq 0 ] && [ "$CF_CACHE_TTL_MIN" -gt 0 ] && [ "$CACHED" -eq 0 ] \
    && [ "$RC" -eq 0 ]; then
	cache_store
fi

exit "$RC"
