#!/bin/ksh
#
# probe-mailserver [-s] [-o reed|text|json] [-f config] <domain|mx-host>
#
# Configuration: what -f names, else /etc/probe-mailserver/<domain>.conf when it is there,
# else /etc/probe-mailserver.conf, else the defaults below. One file per domain is how
# domains that do not hold the same policy are watched by one command.
#
# One verdict for a mail domain: MX, PTR, SPF, DKIM, DMARC, CAA, MTA-STS, TLS-RPT, TLS,
# DANE, open relay, RFC 2142 roles, DNSBL, SPF of the MX host. The argument may also be the name
# of an MX host of a parent domain: the parent is then analysed, on that host. Read-only, unprivileged, no state, no cache:
# the config file is the only thing read besides the network.
#
# 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.

# Text handling is done by the shell alone: printf is no builtin of the target ksh, and every
# sed, grep, tr, head or awk on a small answer costs an execve that the -C fan-out multiplies by
# sixteen [verifie:openbsd/src bin/ksh/c_sh.c, c_ksh.c, builtin tables; docs/KSH.md]. A helper
# hands its result in a global (_Q, _G, _SB, ...) instead of printing it into a substitution.

set -u
export LC_ALL=C			# deterministic host(1) parsing, byte-wise patterns
# The PATH of the caller is kept, in its order: the port test puts its host, openssl and ftp
# ahead of it. /usr/local/bin is appended when absent, 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 tool the PATH of the caller reaches first.
case :$PATH: in
*:/usr/local/bin:*)	;;
*)	PATH=$PATH:/usr/local/bin ;;
esac
export PATH

PROG=probe-mailserver
NL='
'
TAB='	'
# Built on first use, by the processes that write a CR: a fork, no exec.
CR=
# 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 "${0%/*}" && pwd)/${0##*/} ;;
*)	SELF=$(pwd)/$0 ;;
esac
# -C runs ONE check inside the tree its parent made. Internal, so out of the usage: it is
# how the parent launches its checks, never something a caller writes.
RUNCHECK=0
ALL_CHECKS="mx ptr spf dkim dmarc caa mtasts tlsrpt tls dane dnssec relay rfc bl client spfhost"
DKIM_COMMON="dkim default mail selector1 selector2 google smtp key1 mx s1 s2"
RELAY_RCPT=relay-test@example.org	# RFC 2606 reserved name: never a real mailbox

FORMAT=reed
# Empty until the domain is known: the per-domain path is built from the argument.
CONF=
CONFDIR=/etc/probe-mailserver
CONFFILE=/etc/probe-mailserver.conf
# Fleet-wide defaults shared with sysutils/probe-webserver, read before CONF and overridden by
# it. One key only: the two probes name their checks differently, and a shared checks/skip would
# be read against the ALL_CHECKS of whichever one runs, wrongly rejecting the other's names.
PROBECONF=/etc/probe.conf
DOMAIN=
# -s: the argument is analysed as it is, nothing is resolved.
NORESOLVE=0
# What the user typed (folded), the notice a resolution leaves, and the MX host it forces
# (CF_MXHOST, empty: the lowest priority MX of the domain).
ARGUMENT=
NOTICE=
CF_MXHOST=${CF_MXHOST:-}

usage_text() {
	echo "usage: $PROG [-hs] [-o reed|text|json] [-f config] <domain|mx-host>"
}
usage() { usage_text >&2; exit 2; }

need_cr() {
	[ -n "$CR" ] || CR=$(print -n '\r' 2>/dev/null)
}

# subst <from> <to> <text> -> _SB, every occurrence of from replaced, left to right, the
# replacement never rescanned: what tr(1) or sed s///g did on one value. An empty from leaves
# the text as it is.
subst() {
	_SB=
	_sbr=$3
	[ -n "$1" ] || { _SB=$_sbr; return 0; }
	while :; do
		case $_sbr in
		*"$1"*)	_SB=$_SB${_sbr%%"$1"*}$2
			_sbr=${_sbr#*"$1"} ;;
		*)	_SB=$_SB$_sbr
			return 0 ;;
		esac
	done
}

# lower <text> -> _LOW, ASCII letters folded under LC_ALL=C: tr 'A-Z' 'a-z'.
lower() {
	typeset -l _lw="$1"
	_LOW=$_lw
}

# chomp <text> -> _CH, trailing newlines cut, as a $(...) cuts them.
chomp() {
	_CH=$1
	while :; do
		case $_CH in
		*"$NL")	_CH=${_CH%"$NL"} ;;
		*)	return 0 ;;
		esac
	done
}

# 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, CR and tabs folded to a space, single quotes
	# dropped, 120 characters through the precision of %s [verifie:man.openbsd.org/printf.1].
	# printf kept for that precision, once per run: ksh has no substring expansion
	# [verifie:openbsd/src bin/ksh/ksh.1, parameter substitution]. Portable on purpose, a
	# hostile argument reaching this line before anything ksh-only runs.
	need_cr
	subst "'" '' "$1"
	subst "$NL" ' ' "$_SB"
	subst "$CR" ' ' "$_SB"
	subst "$TAB" ' ' "$_SB"
	printf '%.120s\n' "$_SB" >&2
}

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

while getopts Cc:f:hso: _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 ;;
	s)	NORESOLVE=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, newline included; then the label rules: an empty label, or a '-' at a label edge.
case "$DOMAIN" in
'' | *[!A-Za-z0-9.-]*) cannot "the argument is not a domain name" ;;
esac
case "$DOMAIN" in
.* | *. | *..* | -* | *- | *.-* | *-.*) cannot "the argument is not a domain name" ;;
esac
# Names compare without case [RFC 1035 sec 2.3.3]: folded once, so the configuration path, the
# comparisons and the output all carry the folded name.
lower "$DOMAIN"
DOMAIN=$_LOW

# Result in _trim, and no process spawned: the parser calls this three times per config line.
# ksh matches POSIX character classes in patterns [verifie:openbsd/src bin/ksh/ksh.1,
# "character class"] and /bin/sh is the same binary [verifie: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
}

# ---- text of an answer ----

# grab <marker> <text> -> _Q, one per line: of each line holding marker, what follows its last
# occurrence -- the output of sed -n 's/.*<marker>//p'. _gbn counts them.
grab() {
	_Q=
	_gbn=0
	while IFS= read -r _gbl; do
		case $_gbl in
		*"$1"*)	;;
		*)	continue ;;
		esac
		if [ "$_gbn" -eq 0 ]; then
			_Q=${_gbl##*"$1"}
		else
			_Q=$_Q$NL${_gbl##*"$1"}
		fi
		_gbn=$((_gbn + 1))
	done <<EOF
$2
EOF
	chomp "$_Q"
	_Q=$_CH
}

# grepi <text> <glob> [<glob>] -> _G, the lines either glob matches, and _GN their count: what
# grep -i printed and counted, the caller writing the glob case-folded.
# shellcheck disable=SC2254  # $2 and $3 are globs, matched as such
grepi() {
	_G=
	_GN=0
	while IFS= read -r _gil; do
		_gih=
		case $_gil in $2) _gih=1 ;; esac
		[ $# -gt 2 ] && case $_gil in $3) _gih=1 ;; esac
		[ -n "$_gih" ] || continue
		if [ "$_GN" -eq 0 ]; then
			_G=$_gil
		else
			_G=$_G$NL$_gil
		fi
		_GN=$((_GN + 1))
	done <<EOF
$1
EOF
}

# lspace <text> -> _LS, the leading spaces removed, tabs kept: sed 's/^ *//'.
lspace() {
	_LS=$1
	while :; do
		case $_LS in
		' '*)	_LS=${_LS# } ;;
		*)	return 0 ;;
		esac
	done
}

# lastfield <line> -> _LF, the last blank separated field: awk's $NF.
lastfield() {
	_LF=$1
	while :; do
		case $_LF in
		*[[:blank:]])	_LF=${_LF%?} ;;
		*)		break ;;
		esac
	done
	_LF=${_LF##*[[:blank:]]}
}

# ---- 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
left() {
	# SECONDS, not a clock read: it is an integer of the shell on CLOCK_MONOTONIC, costs no
	# fork, and cannot leave an empty operand -- which is a FATAL error that would kill this
	# shell before the check writes anything [docs/KSH.md]. Set, not printed: no subshell.
	_LEFT=$(( DL - ${SECONDS%%.*} ))
	[ "$_LEFT" -lt 1 ] && _LEFT=0
	return 0
}

hq() {
	left
	_t=$_LEFT
	[ "$_t" -eq 0 ] && return 1
	_w=5
	[ "$_t" -lt 5 ] && _w=$_t
	hn-timeout "$_t" host -W "$_w" "$@" 2>/dev/null
}

# q <type> <name> <marker> -> _Q, the answers of one type, cut after the marker host(1) writes.
q() {
	_qo=$(hq -t "$1" "$2")
	grab "$3" "$_qo"
}

q_a()      { q A      "$1" ' has address '; }
q_aaaa()   { q AAAA   "$1" ' has IPv6 address '; }
q_txt()    { q TXT    "$1" ' descriptive text '; }
q_srv()    { q SRV    "$1" ' has SRV record '; }
q_tlsa()   { q TLSA   "$1" ' has TLSA record '; }
q_dnskey() { q DNSKEY "$1" ' has DNSKEY record '; }
# A type without a text of its own in host(1) prints "has <TYPE> record" [verifie:openbsd/src
# usr.bin/dig/host.c, rtypes and say_message].
q_ds()     { q DS     "$1" ' has DS record '; }
# q_mx alone reads a cache: nine call sites ask for the same MX RRset, $DOMAIN's, and the parent
# has already paid for that one answer before the fan-out. Three cases on CF_MX_CACHE
# [docs/PROBES.md, the -C contract]: exported and non-empty, used as is, already sorted; exported
# and empty, which IS the answer (no MX record) and must not trigger a second query that could
# disagree with what the parent measured; unset, a -C run outside the parent, the original query.
q_mx() {
	if [ "$1" = "$DOMAIN" ] && [ -n "${CF_MX_CACHE+set}" ]; then
		_Q=$CF_MX_CACHE
		return 0
	fi
	q MX "$1" ' mail is handled by '; mx_sort
}
q_ptr()    { _qo=$(hq "$1"); grab ' domain name pointer ' "$_qo"; }
q_caa() {
	_qo=$(hq -t CAA "$1")
	grepi "$_qo" '*[Cc][Aa][Aa] [Rr][Ee][Cc][Oo][Rr][Dd]*'
	_Q=$_G
}

# mx_key <line> -> _MK, the priority heading an MX answer, leading zeros dropped so no shell
# reads it as octal. host(1) writes it as a plain decimal [infere].
mx_key() {
	_MK=${1%%[!0-9]*}
	while :; do
		case $_MK in
		0?*)	_MK=${_MK#0} ;;
		*)	break ;;
		esac
	done
	[ -n "$_MK" ] || _MK=0
}

# True when line $1 sorts before line $2 under sort -n: the priority, then the whole line byte
# by byte, the last-resort comparison sort(1) makes without -s [verifie:openbsd/src
# usr.bin/sort/coll.c, list_coll_offset]. [[ < ]] compares on the ASCII value
# [verifie:openbsd/src bin/ksh/ksh.1, string < string].
mx_before() {
	mx_key "$1"; _mb1=$_MK
	mx_key "$2"; _mb2=$_MK
	[ "$_mb1" -lt "$_mb2" ] && return 0
	[ "$_mb1" -gt "$_mb2" ] && return 1
	[[ $1 < $2 ]]
}

# _Q in the order of sort -n. A handful of answers: selection of the smallest, no process.
mx_sort() {
	[ "$_gbn" -gt 1 ] || return 0
	_msi=$IFS
	IFS=$NL
	set -f
	set -- $_Q
	set +f
	IFS=$_msi
	_Q=
	while [ $# -gt 0 ]; do
		_msm=$1
		for _msx in "$@"; do
			mx_before "$_msx" "$_msm" && _msm=$_msx
		done
		if [ -z "$_Q" ]; then
			_Q=$_msm
		else
			_Q=$_Q$NL$_msm
		fi
		_msr=
		_msd=
		for _msx in "$@"; do
			if [ -z "$_msd" ] && [ "$_msx" = "$_msm" ]; then
				_msd=1
				continue
			fi
			_msr=$_msr$_msx$NL
		done
		IFS=$NL
		set -f
		set -- $_msr
		set +f
		IFS=$_msi
	done
}

# sortu <word>... -> _SU, the words sorted byte by byte, each once: sort -u.
sortu() {
	_SU=
	while [ $# -gt 0 ]; do
		_sum=$1
		for _sux in "$@"; do
			[[ $_sux < $_sum ]] && _sum=$_sux
		done
		_SU="$_SU $_sum"
		_sur=
		for _sux in "$@"; do
			[ "$_sux" = "$_sum" ] || _sur="$_sur $_sux"
		done
		set -f
		set -- $_sur
		set +f
	done
	return 0
}

# A records of a name that must NOT appear in any argv. host(1) takes the name to resolve as a
# positional argument and offers no other input channel [verifie:man.openbsd.org/host.1 SYNOPSIS],
# and KERN_PROC_ARGV has no access check at all, where KERN_PROC_ENV demands the same uid or root
# [verifie:openbsd/src sys/kern/kern_sysctl.c, sysctl_proc_args]. The name therefore travels in
# the environment, and the resolution goes through perl, which is in the base system.
# gethostbyname(3) asks the system resolver, the same one host(1) queries, so the verdict is the
# one a client would get. Addresses only: TXT would need a DNS query of its own, and the caller
# says so in its message.
q_a_env() {
	_Q=
	left
	[ "$_LEFT" -eq 0 ] && return 1
	_Q=$(HN_PROBE_Q=$1 hn-timeout "$_LEFT" perl -e '
		my @h = gethostbyname($ENV{HN_PROBE_Q}) or exit 0;
		print join(".", unpack("C4", $_)), "\n" for @h[4 .. $#h];' 2>/dev/null)
}

fetch() {
	left
	[ "$_LEFT" -eq 0 ] && return 1
	hn-timeout "$_LEFT" ftp -V -o - "$1" 2>/dev/null
}

# mx_first <domain> -> _MX, the lowest priority MX, trailing dot stripped; empty when the
# domain has none.
mx_first() {
	q_mx "$1"
	_MX=${_Q%%"$NL"*}
	_MX=${_MX##* }
	_MX=${_MX%.}
}

# mx_target -> _MX, the MX host the host level checks (ptr tls dane dnssec relay rfc bl and the
# address part of mx) look at: the host the user named when the parent forced one in CF_MXHOST,
# else the lowest priority MX of the domain. Empty: the domain has none.
mx_target() {
	if [ -n "${CF_MXHOST:-}" ]; then
		_MX=$CF_MXHOST
		return 0
	fi
	mx_first "$DOMAIN"
}

# mx_has <MX answers> <host> -> rc 0 when the target of one answer is host, one trailing dot
# ignored and case folded [RFC 1035 sec 2.3.3]. An answer is "<priority> <target>".
mx_has() {
	_mhr=1
	while IFS= read -r _mhl; do
		_mhl=${_mhl#* }
		_mhl=${_mhl%.}
		lower "$_mhl"
		if [ "$_LOW" = "$2" ]; then
			_mhr=0
			break
		fi
	done <<EOF
$1
EOF
	return "$_mhr"
}

# resolve_target -- parent only. The argument (DOMAIN, folded) is a domain with an MX, or an MX
# host of a parent domain. Sets DOMAIN to the domain to analyse, CF_MXHOST to the host named when
# there is one, CF_MX_CACHE to the sorted MX answer of that domain, NOTICE. Parents are tried from
# the nearest and the walk stops at two labels: a single label suffix is a TLD, not a mail
# domain. Any MX of the parent may match, not the lowest priority one alone. The deadline is
# fixed: the configuration that sets timeout and budget depends on the domain found here.
resolve_target() {
	ARGUMENT=$DOMAIN
	DL=$(( ${SECONDS%%.*} + 10 ))
	q_mx "$DOMAIN"
	if [ -n "$_Q" ]; then
		CF_MX_CACHE=$_Q
		export CF_MX_CACHE
		return 0
	fi
	_rt=$DOMAIN
	while :; do
		case $_rt in
		*.*.*)	_rt=${_rt#*.} ;;
		*)	break ;;
		esac
		q_mx "$_rt"
		[ -n "$_Q" ] || continue
		mx_has "$_Q" "$DOMAIN" || continue
		NOTICE="$DOMAIN is an MX of $_rt, analysing $_rt on that host"
		CF_MXHOST=$DOMAIN
		CF_MX_CACHE=$_Q
		DOMAIN=$_rt
		export CF_MX_CACHE
		return 0
	done
	cannot "no MX, and not the MX of a parent domain, -s analyses it as is"
}

# The configuration is resolved, parsed and checked by the parent alone, and handed to the -C
# children in the environment: sixteen children re-reading the same file bought nothing. The
# environment, not argv: a DQS key rides in CF_DNSBL, and KERN_PROC_ENV wants the same uid or
# root where KERN_PROC_ARGV has no access check [verifie:openbsd/src sys/kern/kern_sysctl.c,
# sysctl_proc_args].
if [ "$RUNCHECK" -eq 0 ]; then
	# Nothing inherited stands for the answer of this run.
	unset CF_MX_CACHE
	CF_MXHOST=
	for _t in host openssl ftp hn-timeout xargs mktemp; do
		command -v "$_t" >/dev/null 2>&1 || cannot "missing tool $_t"
	done

	# A silent resolver turns every DNS check into a fake failure of the domain, so it is rc 2.
	# The parent answers it for the whole run, ahead of the resolution of the argument: a -C
	# child would repeat the query once per check, buying nothing.
	[ -n "$(hn-timeout 10 host -W 5 -t NS . 2>/dev/null)" ] \
	    || cannot "resolver silent, no root NS answer"

	# The domain to analyse is known from here on, and with it the per-domain file.
	[ "$NORESOLVE" -eq 1 ] || resolve_target

	# Resolved only now: the per-domain path carries the domain, 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

	# ---- configuration ----

	CF_DKIM_SELECTOR=
	CF_DNSBL=
	CF_RELAY_FROM=
	# TLS endpoints of the service, each as [host:]port: where a certificate is fetched, where
	# the suites are offered, and where a TLSA is read. No host: the MX of the domain, the name
	# under which DANE is published for incoming SMTP [RFC 7672]. Ports a mail service offers
	# elsewhere -- submission, imaps, pop3s -- are served by a name the DNS of the domain does
	# not give, hence the host. Empty: the three ports of the MX, which is what chk_tls already
	# measures a certificate on.
	CF_ENDPOINTS=
	# TLSv1.2 suites the service may negotiate, colon separated. Empty: the suites are not read.
	CF_CIPHERS=
	# Address a CA reports to when it is asked for a certificate it refuses, as the iodef tag
	# of a CAA record names it [RFC 8659]. Empty: an iodef is still wanted, its value not read.
	CF_CAA_IODEF=
	# CAs allowed to issue for the domain, space separated, each as the domain a CA names in
	# its CAA identifier. Empty: the issue records are read but not compared.
	CF_CAA_ISSUERS=
	# POP3 is off by default on the fleet this probe was written for [infra.git
	# roles/mail/defaults], and 995 is then deliberately closed: testing its certificate would
	# be a false FAIL, not a measure. On when the domain actually serves it.
	CF_POP3=no
	CF_CHECKS=all
	CF_SKIP=
	CF_TIMEOUT=15
	CF_BUDGET=50

	# Read first, so CONF below overrides it key for key rather than the reverse -- a domain
	# that needs its own exception keeps the shared file for everything else.
	if [ -e "$PROBECONF" ]; then
		( : < "$PROBECONF" ) 2>/dev/null || cannot "config unreadable, $PROBECONF"
		_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 "$PROBECONF line $_ln is not key = value" ;;
			esac
			trim "${_line%%=*}"; _k=$_trim
			trim "${_line#*=}"; _v=$_trim
			case "$_k" in
			ciphers)	CF_CIPHERS=$_v ;;
			*)		cannot "unknown key in $PROBECONF, $_k" ;;
			esac
		done < "$PROBECONF"
	fi

	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
			dkim_selector)	CF_DKIM_SELECTOR=$_v ;;
			caa_issuers)	CF_CAA_ISSUERS=$_v ;;
			caa_iodef)	CF_CAA_IODEF=$_v ;;
			ciphers)	CF_CIPHERS=$_v ;;
			endpoints)	CF_ENDPOINTS="$CF_ENDPOINTS $_v" ;;
			# The name this key was shipped under, accepted so a configuration written for
			# it keeps working while the fleet moves over. To drop once nothing renders it.
			dane)		CF_ENDPOINTS="$CF_ENDPOINTS $_v" ;;
			dnsbl)		CF_DNSBL="$CF_DNSBL $_v" ;;
			relay_from)	CF_RELAY_FROM=$_v ;;
			pop3)		CF_POP3=$_v ;;
			checks)		CF_CHECKS=$_v ;;
			skip)		CF_SKIP="$CF_SKIP $_v" ;;
			timeout)	CF_TIMEOUT=$_v ;;
			budget)		CF_BUDGET=$_v ;;
			*)		cannot "unknown config key $_k" ;;
			esac
		done < "$CONF"
	fi

	case $CF_TIMEOUT in ''|*[!0-9]*|0*) cannot "timeout is not a positive integer" ;; esac
	case $CF_BUDGET  in ''|*[!0-9]*|0*) cannot "budget is not a positive integer" ;; esac
	case $CF_POP3 in yes|no) ;; *) cannot "pop3 is not yes or no, $CF_POP3" ;; esac

	# Nothing configured: the three ports chk_tls already fetches a certificate on, so the two
	# checks answer about the same endpoints and a TLSA published on one of them is never read
	# as absent.
	case $CF_ENDPOINTS in *[!\ ]*) ;; *) CF_ENDPOINTS="25 465 587" ;; esac
	for _e in $CF_ENDPOINTS; do
		case $_e in
		*:*)	_dp=${_e##*:}; _dh=${_e%:*} ;;
		*)	_dp=$_e; _dh=- ;;
		esac
		case $_dp in ''|*[!0-9]*|0*) cannot "endpoints is not [host:]port, $_e" ;; esac
		[ "$_dp" -le 65535 ] || cannot "endpoints port out of range, $_e"
		case $_dh in ''|*[!A-Za-z0-9.-]*) cannot "endpoints host is not a name, $_e" ;; esac
	done
	# A value comes from one line of the file, so the class alone decides.
	case $CF_DKIM_SELECTOR in
	*[!A-Za-z0-9._-]*)	cannot "invalid dkim_selector" ;;
	esac

	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 domain -- a CAA
	# the registrar does not let us publish, a redirect another host owns -- 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

	HELO=$(hostname 2>/dev/null)
	[ -z "$HELO" ] && HELO=probe.invalid
	RELAY_FROM=$CF_RELAY_FROM
	[ -z "$RELAY_FROM" ] && RELAY_FROM="probe@$HELO"

	export CF_DKIM_SELECTOR CF_DNSBL CF_ENDPOINTS CF_CIPHERS CF_CAA_IODEF CF_CAA_ISSUERS \
	    CF_POP3 CF_TIMEOUT HELO RELAY_FROM CF_MXHOST
else
	# Set by the parent before the fan-out; absent, this is no child of a run.
	[ -n "${CF_TIMEOUT:-}" ] || cannot "no configuration handed down for check $CHECKNAME"
fi

DEBUG=${PROBE_MAILSERVER_DEBUG:-}
# The tree belongs to the parent: a -C child is handed the path and touches neither the
# mktemp nor the trap, or every child would make a tree and the first to exit would remove
# the one holding all the results.
if [ "$RUNCHECK" -eq 1 ]; then
	TMPD=${PROBE_MAILSERVER_TMPD:-}
	[ -d "$TMPD" ] || cannot "no result directory for check $CHECKNAME"
else
	TMPD=$(mktemp -d "${TMPDIR:-/tmp}/probe-mailserver.XXXXXXXXXX") || cannot "mktemp failed"
	# PROBE_MAILSERVER_DEBUG set: the working tree survives, 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. PROBE_MAILSERVER_KEEP 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.
	if [ -n "$DEBUG" ]; then
		trap 'echo "probe-mailserver: debug tree kept in $TMPD" >&2' EXIT HUP INT TERM
	elif [ -n "${PROBE_MAILSERVER_KEEP:-}" ]; then
		trap ':' EXIT HUP INT TERM
	else
		trap 'rm -rf "$TMPD"' EXIT HUP INT TERM
	fi
fi

# The addresses grep -oiE 'mailto:[^ ,;"!]+' found, the scheme cut, as sed did: -> _EM, space
# separated, for a caller that splits it into words.
EMAIL_END='[ ,;"!]*'
emails_from() {
	_EM=
	while IFS= read -r _efl; do
		while :; do
			case $_efl in
			*[Mm][Aa][Ii][Ll][Tt][Oo]:*)	;;
			*)				break ;;
			esac
			_efl=${_efl#*[Mm][Aa][Ii][Ll][Tt][Oo]:}
			_efa=${_efl%%$EMAIL_END}
			[ -n "$_efa" ] || continue
			_EM="$_EM $_efa"
			_efl=${_efl#"$_efa"}
		done
	done <<EOF
$1
EOF
}

# between <open> <close> <text> -> _BT, one per line: what sed -n
# 's/.*<open>\(.*\)<close>.*/\1/p' captured -- from the last open that a close follows, to the
# last close.
between() {
	_BT=
	while IFS= read -r _btl; do
		case $_btl in
		*"$1"*"$2"*)	;;
		*)		continue ;;
		esac
		_btv=${_btl%"$2"*}
		_BT=$_BT$NL${_btv##*"$1"}
	done <<EOF
$3
EOF
}

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

OUTF=/dev/null
ST=ok
good() { print -r -- "OK   $*" >> "$OUTF"; }
soft() { print -r -- "WARN $*" >> "$OUTF"; [ "$ST" = fail ] || ST=warn; }
bad()  { print -r -- "FAIL $*" >> "$OUTF"; ST=fail; }
skip() { print -r -- "SKIP $*" >> "$OUTF"; [ "$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. What separates them: bl without a configured zone is a skip, an SMTP
# dialogue that never answered is untested.
none() { print -r -- "SKIP $*" >> "$OUTF"; ST=untested; return 0; }
# A line the reader wants without a verdict attached to it, the helper of probe-webserver.
note() { print -r -- "INFO $*" >> "$OUTF"; }

# ---- SMTP ----

# LOCKSTEP through a ksh coprocess, the dialogue hn-check-mail-addresses.sh ran in production:
# every reply is READ before the next command goes out, so no fixed pause and no answer read
# against the wrong command. The one-shot alternative -- write everything, then read -- takes one
# reply back for seven commands, openssl s_client closing the connection as soon as stdin reaches
# EOF [constate sur mail 2026-09-13].
#
# The coprocess is NEVER read from a $(...): in ksh that subshell closes it for the parent. These
# functions therefore set globals and are called directly.
#
# The ADDRESS goes to the connect and the name to the SNI: openssl otherwise resolves through the
# system resolver, which reads /etc/hosts first, and the probe would measure the inside path while
# reporting on what the world reaches. host queries the DNS and never reads that file.

# smtp_read_code -> RCODE, the final code of an answer ("250-" continues, "250 " ends), empty on
# EOF, which is a dead coprocess: timeout, refused connection or a drop mid-dialogue.
smtp_read_code() {
	RCODE=""
	while read -rp _line 2>/dev/null; do
		case "$_line" in
		[0-9][0-9][0-9]" "*) RCODE=${_line%"${_line#???}"}; return ;;
		esac
	done
}

# smtp_dialogue <host> <command>... -> SMTP_CODES, one code per line, SMTP_N their count. Stops at
# the first command left unanswered, so the caller sees a short list rather than a shifted one.
smtp_dialogue() {
	_h=$1
	shift
	SMTP_CODES=
	SMTP_N=0
	need_cr
	q_a "$_h"
	_ip=${_Q%%"$NL"*}
	left
	_t=$_LEFT
	[ "$_t" -eq 0 ] && return 1
	hn-timeout "$_t" openssl s_client -quiet -starttls smtp \
	    -connect "${_ip:-$_h}:25" -servername "$_h" 2>/dev/null |&
	_pid=$!
	for _c in "$@"; do
		# 2>/dev/null on the send: writing to a coprocess openssl has already left prints
		# "no query process", noise over a failure the empty RCODE already carries.
		print -p "$_c$CR" 2>/dev/null
		smtp_read_code
		[ -n "$RCODE" ] || break
		SMTP_CODES="$SMTP_CODES$RCODE
"
		SMTP_N=$((SMTP_N + 1))
	done
	# The write end is closed before waiting: the parent keeps it open otherwise, the peer never
	# sees EOF and wait(1) hangs on a server that only leaves on end of input. openssl itself
	# exits when the server hangs up after QUIT, which is why the production script could wait
	# without this -- a stub cannot [constate 2026-09-13].
	exec 3>&p
	exec 3>&-
	wait "$_pid" 2>/dev/null
	return 0
}

# nth <codes> <n> -> _NTH, the n-th reply code, empty past the last. The codes are three digits
# each, so a word is a line.
nth() {
	_NTH=
	_ni=0
	for _nw in $1; do
		_ni=$((_ni + 1))
		if [ "$_ni" -eq "$2" ]; then
			_NTH=$_nw
			return 0
		fi
	done
	return 0
}

# ---- certificates ----

# subject_cn <subject line> -> _CN, after the last "CN<blanks>=<blanks>", up to the first comma
# or slash: the CN of "C = US, O = X, CN = name" or of "/O=X/CN=name".
subject_cn() {
	_CN=
	_scl=$1
	while :; do
		case $_scl in
		*CN*)	;;
		*)	break ;;
		esac
		_scl=${_scl#*CN}
		_sct=${_scl#"${_scl%%[![:space:]]*}"}
		case $_sct in
		=*)	_sct=${_sct#=}
			_CN=${_sct#"${_sct%%[![:space:]]*}"} ;;
		esac
	done
	_CN=${_CN%%,*}
	_CN=${_CN%%/*}
	_CN=${_CN%"${_CN##*[![:space:]]}"}
}

# san_dns <value line of the SAN> -> _SD, " name" for each DNS: entry of the comma list.
san_dns() {
	_SD=
	_sdl=$1
	while :; do
		_sdt=${_sdl%%,*}
		_sdt=${_sdt#"${_sdt%%[![:space:]]*}"}
		case $_sdt in
		DNS:*)	_sdt=${_sdt#DNS:}
			_sdt=${_sdt%"${_sdt##*[![:space:]]}"}
			[ -n "$_sdt" ] && _SD="$_SD $_sdt" ;;
		esac
		case $_sdl in
		*,*)	_sdl=${_sdl#*,} ;;
		*)	break ;;
		esac
	done
}

# name_covered <name> <pattern>... : a pattern equal to the name, or "*." over exactly one label
# of it [RFC 6125 sec 6.4.3].
name_covered() {
	_ncn=$1
	shift
	for _ncp in "$@"; do
		[ "$_ncn" = "$_ncp" ] && return 0
		case "$_ncp" in
		"*."*)
			case "$_ncn" in
			*.*)	[ "${_ncn#*.}" = "${_ncp#*.}" ] && return 0 ;;
			esac
			;;
		esac
	done
	return 1
}

cert_check() {
	_h=$1
	_p=$2
	_st=$3
	q_a "$_h"
	_ip=${_Q%%"$NL"*}
	left
	_t=$_LEFT
	[ "$_t" -eq 0 ] && { soft "$_h:$_p not tested, check deadline reached"; return 0; }
	if [ -n "$_st" ]; then
		_o=$(print Q | hn-timeout "$_t" openssl s_client -connect "${_ip:-$_h}:$_p" \
		    -starttls "$_st" -servername "$_h" 2>/dev/null)
	else
		_o=$(print Q | hn-timeout "$_t" openssl s_client -connect "${_ip:-$_h}:$_p" \
		    -servername "$_h" 2>/dev/null)
	fi
	[ -z "$_o" ] && { bad "$_h:$_p no TLS answer, port closed or service down"; return 0; }
	# One x509 for the four answers: it prints subject, notAfter and the text form in the order
	# of the options, then runs -checkend and leaves 1 on an expired, undated or unreadable
	# certificate [verifie:openbsd/src usr.bin/openssl/x509.c, print loop then cfg.checkend].
	# This x509 has no -ext: the SAN is the line after the extension name in the text form
	# [verifie:man.openbsd.org/openssl.1].
	_x=$(print -r -- "$_o" | openssl x509 -noout -subject -enddate -text -checkend 0 2>/dev/null)
	_xrc=$?
	_subj=
	_end=
	_cov=
	_san=0
	while IFS= read -r _xl; do
		if [ "$_san" -eq 1 ]; then
			_san=0
			san_dns "$_xl"
			_cov=$_cov$_SD
			continue
		fi
		case $_xl in
		subject=*)	lspace "${_xl#subject=}"; _subj=$_LS
				subject_cn "$_subj"
				[ -n "$_CN" ] && _cov="$_cov $_CN" ;;
		notAfter=*)	_end=${_xl#notAfter=} ;;
		*"X509v3 Subject Alternative Name"*)	_san=1 ;;
		esac
	done <<EOF
$_x
EOF
	if [ "$_xrc" -ne 0 ]; then
		bad "$_h:$_p cert expired or unreadable, end ${_end:-unknown}"
		return 0
	fi
	# The client checks the name it connects to against the certificate: TLS that verifies
	# elsewhere is a failure here [verifie:RFC 6125 sec 6.4].
	lower "$_h"
	_hl=$_LOW
	lower "$_cov"
	set -f
	set -- $_LOW
	set +f
	if [ $# -eq 0 ]; then
		bad "$_h:$_p cert carries no CN nor DNS name, $_subj"
		return 0
	fi
	if ! name_covered "$_hl" "$@"; then
		bad "$_h:$_p cert does not cover $_h, names $*"
		return 0
	fi
	# The last one, empty or not: sed -n 's/.*Verify return code: //p' | tail -1.
	_vr=
	while IFS= read -r _xl; do
		case $_xl in
		*'Verify return code: '*)	_vr=${_xl##*'Verify return code: '} ;;
		esac
	done <<EOF
$_o
EOF
	if [ -z "$_vr" ]; then
		good "$_h:$_p cert valid, chain not evaluated, $_subj, exp $_end"
	elif [ "${_vr%% *}" = 0 ]; then
		good "$_h:$_p cert valid and chain ok, $_subj, exp $_end"
	else
		soft "$_h:$_p chain verify ko ($_vr), $_subj, missing intermediate"
	fi
	return 0
}

# ---- checks ----

chk_mx() {
	mx_target
	_mx=$_MX
	[ -z "$_mx" ] && { bad "no MX record, nobody can write to the domain"; return 0; }
	good "MX $_mx"
	# An MX must not be a CNAME [RFC 2181 sec 10.3].
	_cn=$(hq -t CNAME "$_mx")
	case $_cn in
	*[Ii][Ss]" "[Aa][Nn]" "[Aa][Ll][Ii][Aa][Ss]" "[Ff][Oo][Rr]*)
		soft "MX $_mx is a CNAME, violates RFC 2181" ;;
	esac
	q_a "$_mx"
	_ip=$_Q
	if [ -n "$_ip" ]; then
		subst "$NL" ' ' "$_ip"
		good "A $_mx $_SB"
	else
		bad "no A record for the MX $_mx"
	fi
	return 0
}

chk_ptr() {
	mx_target
	_mx=$_MX
	[ -z "$_mx" ] && { skip "no MX"; return 0; }
	q_a "$_mx"
	_ip=${_Q%%"$NL"*}
	[ -z "$_ip" ] && { skip "no A for $_mx"; return 0; }
	q_ptr "$_ip"
	_ptr=${_Q%%"$NL"*}
	[ -z "$_ptr" ] && { soft "no PTR for $_ip, mail gets classed as spam"; return 0; }
	good "PTR $_ip $_ptr"
	q_a "${_ptr%.}"
	case "$NL$_Q$NL" in
	*"$NL$_ip$NL"*)	good "FCrDNS confirmed, ${_ptr%.} resolves back to $_ip" ;;
	*)		soft "FCrDNS ko, ${_ptr%.} does not resolve back to $_ip" ;;
	esac
	return 0
}

chk_spf() {
	q_txt "$DOMAIN"
	grepi "$_Q" '*[Vv]=[Ss][Pp][Ff]1*'
	_n=$_GN
	_spf=${_G%%"$NL"*}
	[ -z "$_spf" ] && { bad "no SPF record, anyone can send on behalf of the domain"; return 0; }
	good "SPF $_spf"
	# More than one v=spf1 is a PermError [RFC 7208 sec 4.5].
	[ "$_n" -gt 1 ] && soft "$_n v=spf1 records, receivers answer PermError, merge into one TXT"
	spf_qualifier "$_spf"
	return 0
}

# spf_qualifier <record as host(1) prints it> -- the verdict on its final all.
spf_qualifier() {
	txt_join "$1"
	spf_all "$_TJ"
	case "$_SPFQ" in
	-)	good "SPF qualifier -all, strict" ;;
	"~")	good "SPF qualifier ~all, softfail" ;;
	"?")	soft "SPF qualifier ?all, neutral and ineffective" ;;
	+)	soft "SPF qualifier +all, the whole world may send for the domain" ;;
	*)	soft "SPF has no final all, end the record with -all" ;;
	esac
}

# SPF of the MX host name: the HELO identity a mail server presents is its own name, and a
# verifier is recommended to check it on its own [RFC 7208 sec 2.3]. Absent is a warning, never a
# failure: the record is a recommendation, and the MAIL FROM check still runs.
chk_spfhost() {
	mx_target
	_mx=$_MX
	[ -z "$_mx" ] && { skip "no MX"; return 0; }
	q_txt "$_mx"
	grepi "$_Q" '*[Vv]=[Ss][Pp][Ff]1*'
	_n=$_GN
	_spf=${_G%%"$NL"*}
	[ -z "$_spf" ] && { soft "no SPF record on $_mx, receivers checking the HELO identity get none"; return 0; }
	good "SPF $_mx $_spf"
	[ "$_n" -gt 1 ] && soft "$_n v=spf1 records on $_mx, receivers answer PermError, merge into one TXT"
	spf_qualifier "$_spf"
	return 0
}

# txt_join <TXT rdata as host(1) prints it> -> _TJ, its strings joined without a blank, quotes
# gone [verifie:RFC 7208 sec 3.3; openbsd/src usr.bin/dig/lib/dns/rdata/generic/txt_16.c, one
# blank between the strings].
txt_join() {
	subst '" "' '' "$1"
	subst '"' '' "$_SB"
	_TJ=$_SB
}

# spf_all <record> -> _SPFQ, the qualifier of the first all term, + for a bare all, empty
# without one. Terms are blank separated and those after all are never tested [verifie:RFC 7208
# sec 4.6.1, 5.1]: the all of include:spf-all.example is part of another term.
spf_all() {
	_SPFQ=
	set -f
	set -- $1
	set +f
	for _sat in "$@"; do
		lower "$_sat"
		case $_LOW in
		all)		_SPFQ=+; return 0 ;;
		[-~?+]all)	_SPFQ=${_LOW%all}; return 0 ;;
		esac
	done
	return 0
}

# dmarc_tag <record> <tag> -> _DT, the value of that tag lowercased, empty when absent: tags
# split on ;, blanks around the name, the = and the value dropped [verifie:RFC 7489 sec 6.4,
# dmarc-sep, *WSP "=" *WSP].
dmarc_tag() {
	_DT=
	_dtr=$1
	while :; do
		_dtf=${_dtr%%";"*}
		case $_dtf in
		*=*)	trim "${_dtf%%=*}"
			lower "$_trim"
			if [ "$_LOW" = "$2" ]; then
				trim "${_dtf#*=}"
				lower "$_trim"
				_DT=$_LOW
				return 0
			fi ;;
		esac
		case $_dtr in
		*";"*)	_dtr=${_dtr#*";"} ;;
		*)	return 0 ;;
		esac
	done
}

chk_dmarc() {
	q_txt "_dmarc.$DOMAIN"
	grepi "$_Q" '*[Vv]=[Dd][Mm][Aa][Rr][Cc]1*'
	_n=$_GN
	_d=${_G%%"$NL"*}
	[ -z "$_d" ] && { bad "no DMARC record on _dmarc.$DOMAIN"; return 0; }
	good "DMARC $_d"
	# More than one v=DMARC1 and the policy is ignored [RFC 7489 sec 6.6.3].
	[ "$_n" -gt 1 ] && soft "$_n v=DMARC1 records, the policy is ignored, keep one TXT"
	# A p= missing or out of the three values, or an sp= out of them: a rua= makes receivers
	# read p=none, its absence leaves no DMARC at all [verifie:RFC 7489 sec 6.6.3, step 6]. A
	# rua= present stands for a valid URI here, chk_dmarc_rua judges the URIs.
	txt_join "$_d"
	dmarc_tag "$_TJ" p
	_dp=$_DT
	dmarc_tag "$_TJ" sp
	_dsp=$_DT
	dmarc_tag "$_TJ" rua
	_drua=$_DT
	_dok=yes
	case $_dp in none|quarantine|reject) ;; *) _dok=no ;; esac
	case $_dsp in ''|none|quarantine|reject) ;; *) _dok=no ;; esac
	if [ "$_dok" = no ] && [ -n "$_drua" ]; then
		soft "DMARC without a valid p= or sp=, receivers read p=none, spoofed mail is not blocked"
	elif [ "$_dok" = no ]; then
		bad "DMARC without a valid p= or sp= and without rua=, receivers apply no DMARC"
	elif [ "$_dp" = none ]; then
		soft "DMARC p=none, monitoring only, spoofed mail is not blocked"
	else
		good "DMARC enforcement, p=$_dp"
	fi
	chk_dmarc_rua "$_d"
	return 0
}

# rua=: where aggregate reports go. Absent, nobody sees who sends under the domain. Present but
# on a foreign domain without the external authorization record [RFC 7489 sec 7.1], most
# receivers drop the reports rather than send mail to a domain that never asked for them --
# silently, no error the domain owner would ever see.
chk_dmarc_rua() {
	# The first ;-separated field that reads rua= once its leading blanks are gone. The TXT
	# record arrives quoted whole ("v=DMARC1; ...; rua=..."): when rua is the last field, the
	# closing quote lands on this value and must go with it. The semicolon quoted: unquoted in
	# ${} it reads as a command separator to some shells.
	_rua=
	_rr=$1
	while :; do
		_rf=${_rr%%";"*}
		while :; do
			case $_rf in
			[[:space:]]*)	_rf=${_rf#?} ;;
			*)		break ;;
			esac
		done
		case $_rf in
		rua=*)	_rua=${_rf#rua=}; break ;;
		esac
		case $_rr in
		*";"*)	_rr=${_rr#*";"} ;;
		*)	break ;;
		esac
	done
	_rua=${_rua%\"}
	if [ -z "$_rua" ]; then
		soft "DMARC has no rua=, aggregate reports go nowhere"
		return 0
	fi
	_oldifs=$IFS
	IFS=,
	set -- $_rua
	IFS=$_oldifs
	for _addr in "$@"; do
		_addr=${_addr%%!*}		# strip the optional !max-size suffix
		case $_addr in
		mailto:*)	_addr=${_addr#mailto:} ;;
		*)		soft "DMARC rua $_addr is not a mailto: URI"; continue ;;
		esac
		_ruadom=${_addr#*@}
		case $DOMAIN in
		"$_ruadom"|*".$_ruadom")
			good "DMARC rua $_addr, same organizational domain" ;;
		*)
			q_txt "$DOMAIN._report._dmarc.$_ruadom"
			grepi "$_Q" '*[Vv]=[Dd][Mm][Aa][Rr][Cc]1*'
			if [ -n "$_G" ]; then
				good "DMARC rua $_addr, authorized by $DOMAIN._report._dmarc.$_ruadom"
			else
				bad "DMARC rua $_addr on a foreign domain, no $DOMAIN._report._dmarc.$_ruadom, most receivers drop the reports"
			fi
			;;
		esac
	done
	return 0
}

dkim_one() {
	_s=$1
	q_txt "$_s._domainkey.$DOMAIN"
	grepi "$_Q" '*[Vv]=[Dd][Kk][Ii][Mm]1*' '*[Pp]=*'
	[ -z "$_G" ] && return 1
	case $_G in
	*[Pp]=[A-Za-z0-9+/]*)	good "DKIM selector $_s, key published" ;;
	*)			soft "DKIM selector $_s has an empty p=, revoked key" ;;
	esac
	return 0
}

chk_dkim() {
	if [ -n "$CF_DKIM_SELECTOR" ]; then
		dkim_one "$CF_DKIM_SELECTOR" \
		    || bad "no DKIM key on $CF_DKIM_SELECTOR._domainkey.$DOMAIN"
		return 0
	fi
	_found=
	for _s in $DKIM_COMMON; do
		dkim_one "$_s" && _found=1
	done
	[ -z "$_found" ] && soft "no common DKIM selector found, set dkim_selector in the config"
	return 0
}

# Which CA may ask for a certificate. rdata: flags tag "value". A value of ";" alone names NO
# issuer and forbids every CA [verifie:RFC 8659 section 4.3]; 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.
caa_issue() {
	# The seventh blank separated field of each issue or issuewild answer, quotes dropped,
	# folded, sorted and each once: what awk, tr and sort -u gave.
	_cav=
	while read -r _c1 _c2 _c3 _c4 _c5 _c6 _c7 _crest; do
		case $_c6 in
		issue|issuewild)	;;
		*)			continue ;;
		esac
		subst '"' '' "$_c7"
		lower "$_SB"
		[ -n "$_LOW" ] && _cav="$_cav $_LOW"
	done <<EOF
$_caa
EOF
	set -f
	set -- $_cav
	set +f
	sortu "$@"
	_raw=$_SU
	[ -z "$_raw" ] && { bad "CAA present but no issue record, any CA may issue"; return 0; }
	_deny=0
	_issue=
	for _v in $_raw; do
		case $_v in
		';'*)	_deny=1 ;;
		# The semicolon quoted: unquoted in ${} it reads as a command separator to some shells.
		*)	_issue="$_issue ${_v%%";"*}" ;;
		esac
	done
	# 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" -eq 1 ] && [ -n "${_issue# }" ]; then
		bad "CAA forbids every issuer and names${_issue} at the same apex"
		return 0
	fi
	[ "$_deny" -eq 1 ] && { good "CAA issue \";\", no CA may issue"; return 0; }
	if [ -z "$CF_CAA_ISSUERS" ]; then
		soft "caa_issuers empty in the configuration, issuers not compared"
		return 0
	fi
	# One direction only, the one that carries the risk: a CA published and not approved may
	# issue without anyone having decided it. An approved CA missing from the set fails at the
	# next renewal, where the certificate check says so.
	for _i in $_issue; do
		_ok=0
		for _a in $CF_CAA_ISSUERS; do
			[ "$_i" = "$_a" ] && _ok=1
		done
		if [ "$_ok" -eq 1 ]; then
			good "CAA issuer $_i allowed"
		else
			bad "CAA issuer $_i not in caa_issuers"
		fi
	done
	return 0
}

# Who hears when a CA is asked and refuses. An `issue` set says nothing about that: it is the
# iodef tag, and without it an illegitimate request reaches nobody [RFC 8659 section 4.4].
# Absence is a weakness, not a breakage, hence a warning.
caa_report() {
	case $_caa in
	*iodef*)	;;
	*)		soft "CAA published without an iodef, a refused issuance reaches nobody"
			return 0 ;;
	esac
	[ -z "$CF_CAA_IODEF" ] && return 0
	# Configured: the address published must be the one that is read. A different one sends the
	# report of a refused issuance somewhere nobody watches, which reads as silence.
	case $_caa in
	*"iodef \"$CF_CAA_IODEF\""*)	good "CAA iodef $CF_CAA_IODEF" ;;
	*)				bad "CAA iodef is not $CF_CAA_IODEF, reports go elsewhere" ;;
	esac
	return 0
}

chk_caa() {
	q_caa "$DOMAIN"
	_caa=$_Q
	if [ -z "$_caa" ]; then
		soft "no CAA record, any CA may issue for the domain"
		return 0
	fi
	subst "$NL" ';' "$_caa"
	good "CAA $_SB"
	caa_issue
	caa_report
	return 0
}

# policy_field <glob> <prefix length> <text> -> _PF, one per line: the value of each policy
# line the glob matches, prefix and leading spaces cut, CR dropped -- sed -n 's/^key: *//p'
# then tr -d '\r'.
# shellcheck disable=SC2254  # $1 is a glob, matched as such
policy_field() {
	_PF=
	_pfn=0
	while IFS= read -r _pfl; do
		case $_pfl in
		$1)	;;
		*)	continue ;;
		esac
		_pfv=$_pfl
		_pfi=0
		while [ "$_pfi" -lt "$2" ]; do
			_pfv=${_pfv#?}
			_pfi=$((_pfi + 1))
		done
		lspace "$_pfv"
		subst "$CR" '' "$_LS"
		if [ "$_pfn" -eq 0 ]; then
			_PF=$_SB
		else
			_PF=$_PF$NL$_SB
		fi
		_pfn=$((_pfn + 1))
	done <<EOF
$3
EOF
	chomp "$_PF"
	_PF=$_CH
}

chk_mtasts() {
	q_txt "_mta-sts.$DOMAIN"
	grepi "$_Q" '*[Vv]=[Ss][Tt][Ss][Vv]1*'
	_sts=$_G
	[ -z "$_sts" ] && { soft "no _mta-sts TXT, incoming TLS is not enforced"; return 0; }
	good "MTA-STS DNS $_sts"
	_pol=$(fetch "https://mta-sts.$DOMAIN/.well-known/mta-sts.txt")
	case $_pol in
	*[Ss][Tt][Ss][Vv]1*)	;;
	*)	bad "MTA-STS policy not served on https://mta-sts.$DOMAIN/.well-known/mta-sts.txt"
		return 0 ;;
	esac
	need_cr
	policy_field '[Mm]ode:*' 5 "$_pol"
	_mode=$_PF
	good "MTA-STS policy served, mode ${_mode:-unknown}"
	# Every real DNS MX must match an mx: pattern of the policy. The mx: may point outside the
	# domain, shared mail hosting; the only criterion is matching the DNS MX [RFC 8461 sec 3.2].
	# A *.x wildcard matches exactly one label [sec 4.1]. Case normalised, DNS is insensitive.
	# Folded, then one trailing dot cut, line by line.
	policy_field '[Mm]x:*' 3 "$_pol"
	lower "$_PF"
	_pat=
	while IFS= read -r _pl; do
		_pat=$_pat$NL${_pl%.}
	done <<EOF
$_LOW
EOF
	# The DNS MX, sorted, priority cut (sed 's/^[0-9]* //'), trailing dot cut, folded.
	q_mx "$DOMAIN"
	_real=
	if [ -n "$_Q" ]; then
		while IFS= read -r _rl; do
			_rd=${_rl%%[!0-9]*}
			_rr=${_rl#"$_rd"}
			case $_rr in
			' '*)	_rl=${_rr# } ;;
			esac
			if [ -z "$_real" ]; then
				_real=${_rl%.}
			else
				_real=$_real$NL${_rl%.}
			fi
		done <<EOF
$_Q
EOF
	fi
	lower "$_real"
	_real=$_LOW
	for _m in $_real; do
		_hit=
		for _p in $_pat; do
			if [ "$_m" = "$_p" ]; then
				_hit=1
				break
			fi
			case "$_p" in
			"*."*)
				[ "${_m#*.}" = "${_p#*.}" ] && _hit=1
				;;
			esac
			[ -n "$_hit" ] && break
		done
		if [ -n "$_hit" ]; then
			good "MX $_m covered by the policy"
		else
			bad "MX $_m not covered by the mx: policy, enforcing senders refuse it"
		fi
	done
	# The other way round: a non wildcard mx: without a matching DNS MX is a stale policy.
	for _p in $_pat; do
		case "$_p" in
		"*."*)	continue ;;
		esac
		case "$NL$_real$NL" in
		*"$NL$_p$NL"*)	;;
		*)		soft "mx $_p of the policy has no matching DNS MX, stale pattern" ;;
		esac
	done
	return 0
}

chk_tlsrpt() {
	q_txt "_smtp._tls.$DOMAIN"
	grepi "$_Q" '*[Vv]=[Tt][Ll][Ss][Rr][Pp][Tt][Vv]1*'
	_r=$_G
	if [ -n "$_r" ]; then
		good "TLS-RPT $_r"
	else
		soft "no _smtp._tls TXT, no TLS failure report is received"
	fi
	return 0
}

# under_domain <name> -- true when name is $DOMAIN or a subdomain of it. What decides whether an
# autoconfig hostname or an autodiscover Server sends the client somewhere legitimate.
under_domain() {
	case "${1%.}" in
	*."$DOMAIN"|"$DOMAIN")	return 0 ;;
	*)			return 1 ;;
	esac
}

# Client-facing settings: what an MUA needs to find and trust the service on its own, none of it
# covered by the checks above, which are about the mail SYSTEM rather than the person using it.
# SRV (RFC 6186 imaps/submission, and the Microsoft _autodiscover._tcp convention) and the two
# autoconfig/autodiscover XML documents (Mozilla and Microsoft's own discovery mechanisms). The
# certificate a client actually reaches is left to chk_tls's own `endpoints`: a deployment names
# the real host there (imap.example.com:993), which a client SRV, when published at all, only
# repeats -- one certificate, checked once.
chk_client() {
	for _e in "_imaps._tcp:993" "_submission._tcp:587,465" "_autodiscover._tcp:443"; do
		_name=${_e%:*}; _exp=${_e#*:}
		q_srv "$_name.$DOMAIN"
		_r=${_Q%%"$NL"*}
		if [ -z "$_r" ]; then
			soft "no SRV $_name, clients configure themselves by hand"
			continue
		fi
		read -r _f1 _f2 _port _frest <<EOF
$_r
EOF
		case ",$_exp," in
		*",$_port,"*)	good "SRV $_name $_r" ;;
		*)		soft "SRV $_name $_r, unexpected port (expected $_exp)" ;;
		esac
	done
	if [ "$CF_POP3" = yes ]; then
		q_srv "_pop3s._tcp.$DOMAIN"
		_r=${_Q%%"$NL"*}
		if [ -z "$_r" ]; then
			soft "no SRV _pop3s._tcp, clients configure themselves by hand"
		else
			read -r _f1 _f2 _port _frest <<EOF
$_r
EOF
			[ "$_port" = 995 ] && good "SRV _pop3s._tcp $_r" \
			    || soft "SRV _pop3s._tcp $_r, unexpected port (expected 995)"
		fi
	fi

	for _h in "autoconfig.$DOMAIN" "autodiscover.$DOMAIN"; do
		q_a "$_h"
		if [ -n "$_Q" ]; then
			good "A $_h"
		else
			soft "no A for $_h, clients will not find it automatically"
		fi
	done

	_xml=$(fetch "https://autoconfig.$DOMAIN/mail/config-v1.1.xml")
	case $_xml in
	*[Cc][Ll][Ii][Ee][Nn][Tt][Cc][Oo][Nn][Ff][Ii][Gg]*)
		good "autoconfig XML served"
		# grep -Fi: both sides folded, the needle taken literally.
		lower "$_xml"
		_lx=$_LOW
		lower "<domain>$DOMAIN</domain>"
		case $_lx in
		*"$_LOW"*)	good "autoconfig <domain> matches" ;;
		*)		bad "autoconfig <domain> does not match $DOMAIN" ;;
		esac
		between '<hostname>' '</hostname>' "$_xml"
		for _hn in $_BT; do
			under_domain "$_hn" && good "autoconfig hostname $_hn under $DOMAIN" \
			    || bad "autoconfig hostname $_hn OUTSIDE $DOMAIN"
		done
		;;
	*)
		soft "no autoconfig XML (https://autoconfig.$DOMAIN/mail/config-v1.1.xml), Thunderbird will not self-configure"
		;;
	esac

	_adx=$(fetch "https://autodiscover.$DOMAIN/autodiscover/autodiscover.xml")
	case $_adx in
	*[Aa][Uu][Tt][Oo][Dd][Ii][Ss][Cc][Oo][Vv][Ee][Rr]*)
		good "autodiscover XML served"
		between '<Server>' '</Server>' "$_adx"
		for _sv in $_BT; do
			under_domain "$_sv" && good "autodiscover Server $_sv under $DOMAIN" \
			    || bad "autodiscover Server $_sv OUTSIDE $DOMAIN"
		done
		;;
	*)
		soft "no autodiscover XML (https://autodiscover.$DOMAIN/autodiscover/autodiscover.xml), Outlook will not self-configure"
		;;
	esac

	# Outlook queries by POST, not GET -- the two are served independently, a vhost can answer one
	# and not the other. Same TLS dialogue chk_relay uses to reach a server by name: the ADDRESS at
	# the connect, the domain name at the SNI and in the Host header.
	q_a "autodiscover.$DOMAIN"
	_adip=${_Q%%"$NL"*}
	left
	_t=$_LEFT
	if [ "$_t" -eq 0 ]; then
		soft "autodiscover POST not tried, check deadline reached"
	else
		need_cr
		_req='<Autodiscover xmlns="http://schemas.microsoft.com/exchange/autodiscover/outlook/requestschema/2006"><Request><EMailAddress>probe@'"$DOMAIN"'</EMailAddress></Request></Autodiscover>'
		# ${NL}, braced: after a line continuation $NL would run into the next word and name
		# another variable ($NLHost), empty, so the headers would run together and httpd answers 400.
		_post=$(print -rn -- "POST /autodiscover/autodiscover.xml HTTP/1.1$CR${NL}\
Host: autodiscover.$DOMAIN$CR${NL}\
Content-Type: text/xml$CR${NL}\
Content-Length: ${#_req}$CR${NL}\
Connection: close$CR${NL}$CR${NL}$_req" \
		    | hn-timeout "$_t" openssl s_client -quiet \
		      -connect "${_adip:-autodiscover.$DOMAIN}:443" \
		      -servername "autodiscover.$DOMAIN" 2>/dev/null)
		case $_post in
		*"<"[Aa][Uu][Tt][Oo][Dd][Ii][Ss][Cc][Oo][Vv][Ee][Rr]*)
			good "autodiscover POST answered (the method Outlook actually uses)" ;;
		*)	soft "autodiscover POST: no XML back, Outlook may fail where a GET would pass" ;;
		esac
	fi

	return 0
}

# Mirror image of an approved list: offer everything the local library holds EXCEPT it, and a
# handshake that still completes means the server negotiates a suite outside the list. TLSv1.2 is
# forced, -cipher not reaching the TLSv1.3 suites, which are fixed. What the test can see is
# bounded by what this LibreSSL implements: a suite it dropped years ago cannot be offered here.
cipher_check() {
	_h=$1
	_p=$2
	_st=$3
	q_a "$_h"
	_ip=${_Q%%"$NL"*}
	left
	_t=$_LEFT
	[ "$_t" -eq 0 ] && { soft "$_h:$_p suites not read, check deadline reached"; return 0; }
	subst : ':!' "$CF_CIPHERS"
	_rej="ALL:!aNULL:!eNULL:!$_SB"
	if [ -n "$_st" ]; then
		_co=$(print Q | hn-timeout "$_t" openssl s_client -tls1_2 -starttls "$_st" \
		    -servername "$_h" -connect "${_ip:-$_h}:$_p" -cipher "$_rej" 2>/dev/null)
	else
		_co=$(print Q | hn-timeout "$_t" openssl s_client -tls1_2 \
		    -servername "$_h" -connect "${_ip:-$_h}:$_p" -cipher "$_rej" 2>/dev/null)
	fi
	# The last field of the last line reading "New, " or " *Cipher *:", as the awk did.
	_neg=
	while IFS= read -r _cl; do
		_cm=
		case $_cl in
		"New, "*)	_cm=1 ;;
		*)
			lspace "$_cl"
			case $_LS in
			Cipher*)
				lspace "${_LS#Cipher}"
				case $_LS in :*) _cm=1 ;; esac
				;;
			esac
			;;
		esac
		[ -n "$_cm" ] || continue
		lastfield "$_cl"
		_neg=$_LF
	done <<EOF
$_co
EOF
	case $_neg in
	''|0000|'(NONE)')	good "$_h:$_p offers nothing outside the approved suites" ;;
	*)			bad "$_h:$_p negotiates $_neg, outside the approved suites" ;;
	esac
	return 0
}

chk_tls() {
	mx_target
	_mx=$_MX
	for _e in $CF_ENDPOINTS; do
		ep_split "$_e" "$_mx"
		[ -z "$EP_HOST" ] && { skip "no MX, port $EP_PORT not read"; continue; }
		cert_check "$EP_HOST" "$EP_PORT" "$EP_STARTTLS"
		# 25 excluded: incoming SMTP TLS is opportunistic, and a sender with no AEAD suite in
		# common falls back to cleartext, which is worse than a suite in phase out
		# [roles/sys/defaults/main.yml, roles/mail_smtpd/defaults/main.yml].
		if [ -n "$CF_CIPHERS" ] && [ "$EP_PORT" != 25 ]; then
			cipher_check "$EP_HOST" "$EP_PORT" "$EP_STARTTLS"
		fi
	done
	return 0
}

# dane_digest <pem> <selector> <matching> -> _DIG, digest of the served leaf for one TLSA
# selector/matching pair, as the hex sed 's/.*= *//' and tr 'A-Z' 'a-z' left of the dgst line.
# selector 0 = full certificate, 1 = SubjectPublicKeyInfo; matching 1 = sha256, 2 = sha512
# [RFC 6698 sec 2.1.2, 2.1.3]. Matching 0, the value itself, is not computed here.
dane_digest() {
	_DIG=
	case "$3" in
	1)	_dga=sha256 ;;
	2)	_dga=sha512 ;;
	*)	return 1 ;;
	esac
	case "$2" in
	1)
		_dgo=$(print -r -- "$1" | openssl x509 -noout -pubkey 2>/dev/null \
		    | openssl pkey -pubin -outform der 2>/dev/null \
		    | openssl dgst -"$_dga" 2>/dev/null)
		;;
	0)
		_dgo=$(print -r -- "$1" | openssl x509 -outform der 2>/dev/null \
		    | openssl dgst -"$_dga" 2>/dev/null)
		;;
	*)
		return 1
		;;
	esac
	_dgn=0
	while IFS= read -r _dgl; do
		lspace "${_dgl##*=}"
		if [ "$_dgn" -eq 0 ]; then
			_DIG=$_LS
		else
			_DIG=$_DIG$NL$_LS
		fi
		_dgn=$((_dgn + 1))
	done <<EOF
$_dgo
EOF
	chomp "$_DIG"
	_DIG=$_CH
	lower "$_DIG"
	_DIG=$_LOW
	return 0
}

# hexlower <text> -> _HL, A-F folded and nothing else: tr 'A-F' 'a-f'.
hexlower() {
	_HL=$1
	for _hlp in A:a B:b C:c D:d E:e F:f; do
		subst "${_hlp%:*}" "${_hlp#*:}" "$_HL"
		_HL=$_SB
	done
}

# dane_one <host> <port> <starttls> -- one TLSA name, one certificate fetched, one verdict.
# The name read is _<port>._tcp.<host>. For incoming SMTP that host is the MX, which is where
# RFC 7672 puts the record; on the ports a client chooses -- submission, imaps, pop3s -- it is the
# name the client connects to, which the DNS of the domain does not give and the caller passes.
dane_one() {
	_dh=$1
	_dp=$2
	_dst=$3
	q_tlsa "_${_dp}._tcp.$_dh"
	_recs=$_Q
	if [ -z "$_recs" ]; then
		# Port 25 is the transport nobody chooses: with no TLSA there, MTA-STS is all that is
		# left, and the mtasts check says whether it is. Elsewhere DANE is an offer, and its
		# absence is neither a fault nor a measure that failed.
		case $_dp in
		25)	soft "no TLSA on _25._tcp.$_dh, MTA-STS already covers the need" ;;
		*)	note "no TLSA on _${_dp}._tcp.$_dh, DANE is not published there" ;;
		esac
		return 0
	fi
	q_a "$_dh"
	_ip=${_Q%%"$NL"*}
	left
	_t=$_LEFT
	[ "$_t" -eq 0 ] && { soft "$_dh:$_dp TLSA published, cert not fetched, deadline reached"; return 0; }
	if [ -n "$_dst" ]; then
		_pem=$(print Q | hn-timeout "$_t" openssl s_client -connect "${_ip:-$_dh}:$_dp" \
		    -starttls "$_dst" -servername "$_dh" 2>/dev/null)
	else
		_pem=$(print Q | hn-timeout "$_t" openssl s_client -connect "${_ip:-$_dh}:$_dp" \
		    -servername "$_dh" 2>/dev/null)
	fi
	[ -z "$_pem" ] && { bad "TLSA published on _${_dp}._tcp.$_dh but $_dp serves no certificate"; return 0; }
	_hit=
	_eval=0
	# read splits the record itself. The hash can arrive split in several fields, which is why
	# the rest of the line is taken whole and the spaces dropped.
	while read -r _u _s _m _hx; do
		[ -z "${_hx:-}" ] && continue
		# Usage 0 and 2 pin a CA, not the served leaf: comparing them here would be wrong.
		case $_u in
		1|3)	;;
		*)	continue ;;
		esac
		subst ' ' '' "$_hx"
		hexlower "$_SB"
		_want=$_HL
		dane_digest "$_pem" "$_s" "$_m" || continue
		_got=$_DIG
		[ -z "$_got" ] && continue
		_eval=$((_eval + 1))
		if [ "$_got" = "$_want" ]; then
			_hit="$_u $_s $_m"
			break
		fi
	done <<EOF
$_recs
EOF
	if [ -n "$_hit" ]; then
		good "TLSA $_hit matches the certificate served on $_dh:$_dp"
	elif [ "$_eval" -eq 0 ]; then
		soft "$_dh:$_dp TLSA present but no record with a usage and selector this probe evaluates"
	else
		bad "TLSA hash mismatch on $_dh:$_dp, the published record pins another key"
	fi
	return 0
}

# ep_split <entry> <mx> -- EP_HOST, EP_PORT and EP_STARTTLS of one endpoint, empty EP_HOST when
# the entry names no host and the domain has no MX. STARTTLS is not configurable: these port
# numbers are assigned to one usage each, 25 and 587 carrying the upgrade and 465, 993 and 995
# being TLS from the first byte. Set in globals rather than printed: a function read through a
# substitution pays a subshell and a process for what the caller can read directly [docs/KSH.md].
ep_split() {
	case $1 in
	*:*)	EP_HOST=${1%:*}; EP_PORT=${1##*:} ;;
	*)	EP_HOST=$2; EP_PORT=$1 ;;
	esac
	case $EP_PORT in
	25|587)	EP_STARTTLS=smtp ;;
	*)	EP_STARTTLS= ;;
	esac
}

chk_dane() {
	mx_target
	_mx=$_MX
	for _e in $CF_ENDPOINTS; do
		ep_split "$_e" "$_mx"
		[ -z "$EP_HOST" ] && { skip "no MX, port $EP_PORT not read"; continue; }
		dane_one "$EP_HOST" "$EP_PORT" "$EP_STARTTLS"
	done
	return 0
}

relay_verdict() {
	case "$2" in
	2*)
		if [ "$_same" = yes ]; then
			soft "$1 accepted ($2) but the probe runs on the MX, rerun from outside"
		else
			bad "$1 accepted ($2), OPEN RELAY"
		fi
		;;
	4*|5*)	good "$1 refused ($2)" ;;
	*)	soft "$1 unexpected code ${2:-none}" ;;
	esac
	return 0
}

# The zone that holds a record is what signs it. Two zones matter here and they are rarely the
# same: the TLSA of DANE sit on the MX name, the MX record itself sits in the tested domain. An
# unsigned MX zone makes the TLSA forgeable, so DANE is void; an unsigned tested domain leaves
# the MX lookup unauthenticated, and "active attacks that tamper with the insecure MX RRset are
# of course still possible" [verifie:RFC 7672 section 2.2.1]. Both are failures.
# Signed means a DNSKEY at the apex and a DS for it at the parent: without the DS no chain of
# trust reaches the key, and a validating resolver treats the zone as insecure [verifie:RFC 4035
# section 4.3].
# signed_zone <name> -> _ZONE, the closest enclosing zone with a DNSKEY and a DS, empty and rc 1
# if none; _KEYED, that zone when its DNSKEY has no DS.
signed_zone() {
	_ZONE=
	_KEYED=
	_sz=$1
	while :; do
		case "$_sz" in
		*.*)	;;
		*)	return 1 ;;
		esac
		q_dnskey "$_sz"
		if [ -n "$_Q" ]; then
			q_ds "$_sz"
			if [ -z "$_Q" ]; then
				_KEYED=$_sz
				return 1
			fi
			_ZONE=$_sz
			return 0
		fi
		_sz=${_sz#*.}
	done
}

chk_dnssec() {
	mx_target
	_mx=$_MX
	[ -z "$_mx" ] && { skip "no MX"; return 0; }
	q_tlsa "_25._tcp.$_mx"
	_recs=$_Q
	if [ -z "$_recs" ]; then
		# skip and not none: none means the probe could not measure, which this probe
		# carries to rc 2, and a domain publishing no TLSA has nothing to sign.
		skip "no TLSA on _25._tcp.$_mx, and no DNSKEY that would be needed for one"
		return 0
	fi
	signed_zone "$_mx"
	_zmx=$_ZONE
	_kmx=$_KEYED
	signed_zone "$DOMAIN"
	_zd=$_ZONE
	_kd=$_KEYED
	if [ -n "$_zmx" ]; then
		good "the TLSA of $_mx are signed, DNSKEY and DS on $_zmx"
	elif [ -n "$_kmx" ]; then
		bad "TLSA published on _25._tcp.$_mx but no DS for the DNSKEY of $_kmx, DANE is void"
	else
		bad "TLSA published on _25._tcp.$_mx but no DNSKEY up to its apex, DANE is void"
	fi
	if [ -n "$_zd" ]; then
		good "the MX lookup of $DOMAIN is authenticated, DNSKEY and DS on $_zd"
	elif [ -n "$_kd" ]; then
		bad "no DS for the DNSKEY of $_kd, the MX lookup of $DOMAIN is not authenticated"
	else
		bad "no DNSKEY up to the apex of $DOMAIN, the MX lookup is not authenticated"
	fi
	return 0
}

chk_relay() {
	mx_target
	_mx=$_MX
	[ -z "$_mx" ] && { skip "no MX"; return 0; }
	# From the server itself, or from a relay authorised network, relaying is legitimate and an
	# accepted external recipient is a false positive. A local IP equal to an MX IP detects the
	# first case and degrades the critical to a warning [hn-check-mail-addresses.sh].
	_same=no
	if command -v ifconfig >/dev/null 2>&1; then
		q_a "$_mx"
		_mips=$_Q
		q_aaaa "$_mx"
		_mips=$_mips$NL$_Q
		# The second field of every inet and inet6 line, as awk read it.
		_ifo=$(ifconfig 2>/dev/null)
		_lips=
		while read -r _if1 _if2 _ifr; do
			case $_if1 in
			inet|inet6)	_lips="$_lips $_if2" ;;
			esac
		done <<EOF
$_ifo
EOF
		for _l in $_lips; do
			for _i in $_mips; do
				[ "$_l" = "$_i" ] && _same=yes
			done
		done
	fi
	# Two directions through one connection: foreign sender then spoofed local sender, both
	# towards a foreign recipient. QUIT before DATA, no mail is sent.
	smtp_dialogue "$_mx" \
	    "EHLO $HELO" \
	    "MAIL FROM:<$RELAY_FROM>" \
	    "RCPT TO:<$RELAY_RCPT>" \
	    "RSET" \
	    "MAIL FROM:<postmaster@$DOMAIN>" \
	    "RCPT TO:<$RELAY_RCPT>" \
	    "QUIT"
	_codes=$SMTP_CODES
	_n=$SMTP_N
	if [ "$_n" -ne 7 ]; then
		none "no usable SMTP dialogue on $_mx:25, $_n replies for 7 commands"
		return 0
	fi
	nth "$_codes" 1
	case "$_NTH" in
	2*)	;;
	*)	soft "EHLO refused on $_mx:25, relay not tested"; return 0 ;;
	esac
	relay_direction 2 "foreign sender to foreign recipient"
	relay_direction 5 "local sender to foreign recipient"
	return 0
}

# relay_direction <n> <label>: the n-th reply answers MAIL FROM, the next one RCPT TO. A refused
# MAIL leaves the RCPT refused for want of a sender, which says nothing of relaying.
relay_direction() {
	nth "$_codes" "$1"
	case "$_NTH" in
	2*)	;;
	*)	soft "$2 not tested, MAIL FROM refused (${_NTH:-none})"; return 0 ;;
	esac
	nth "$_codes" $(($1 + 1))
	relay_verdict "$2" "$_NTH"
}

chk_rfc() {
	mx_target
	_mx=$_MX
	[ -z "$_mx" ] && { skip "no MX"; return 0; }
	q_txt "_dmarc.$DOMAIN"
	grepi "$_Q" '*[Vv]=[Dd][Mm][Aa][Rr][Cc]1*'
	_dmarc=$_G
	q_txt "_smtp._tls.$DOMAIN"
	grepi "$_Q" '*[Vv]=[Tt][Ll][Ss][Rr][Pp][Tt][Vv]1*'
	_tlsr=$_G
	emails_from "$_dmarc"
	_ruas=$_EM
	emails_from "$_tlsr"
	_ruas="$_ruas $_EM"
	_list=
	for _a in postmaster abuse hostmaster webmaster www security noc; do
		_list="$_list $_a@$DOMAIN"
	done
	# A rua under another domain needs its own MX and its own connection: out of scope for a
	# probe of one domain, the roles of $DOMAIN are what this check is about.
	for _e in $_ruas; do
		case "$_e" in
		*"@$DOMAIN")	;;
		*)		continue ;;
		esac
		case " $_list " in
		*" $_e "*)	continue ;;
		esac
		_list="$_list $_e"
	done
	# MAIL FROM:<> is the null sender, which avoids SPF; QUIT before DATA, no mail is sent.
	# One connection for every address rather than one connection each.
	set -- "EHLO $HELO" "MAIL FROM:<>"
	for _a in $_list; do
		set -- "$@" "RCPT TO:<$_a>"
	done
	set -- "$@" "QUIT"
	_want=$#
	smtp_dialogue "$_mx" "$@"
	_codes=$SMTP_CODES
	_n=$SMTP_N
	if [ "$_n" -ne "$_want" ]; then
		none "no usable SMTP dialogue on $_mx:25, $_n replies for $_want commands"
		return 0
	fi
	nth "$_codes" 2
	case "$_NTH" in
	2*)	;;
	*)	soft "MAIL FROM refused on $_mx:25, addresses not tested"; return 0 ;;
	esac
	_i=3
	for _a in $_list; do
		nth "$_codes" "$_i"
		_code=$_NTH
		case "$_code" in
		2*)		good "$_a accepted ($_code)" ;;
		550|551|553)	bad "$_a refused ($_code), the alias does not exist" ;;
		5*)		soft "$_a protocol refusal ($_code), not a missing alias" ;;
		4*)		soft "$_a temporary ($_code), greylisting, rerun in 30 min" ;;
		*)		soft "$_a no answer" ;;
		esac
		_i=$((_i + 1))
	done
	return 0
}

# A Spamhaus DQS zone carries its subscription key as a 26-character leftmost label, required on
# every query, the sanity test included [verifie:docs.spamhaus.com data-query-service 020-dqs-auth,
# 060-dqs-testing]. Such a name never goes to host(1), whose argv any local account reads: it goes
# to q_a_env, which passes it through the environment. Everything else keeps host(1), which also
# answers TXT.
dqs_zone() {
	case $1 in
	*.dq.spamhaus.net)
		_k=${1%%.*}
		case $_k in
		*[!A-Za-z0-9]*)	return 1 ;;
		*)	[ ${#_k} -eq 26 ] && return 0 ;;
		esac ;;
	esac
	return 1
}

chk_bl() {
	# case, not printf: printf is no builtin of the target ksh, and its argv would carry a DQS key
	# [verifie:openbsd/src bin/ksh/c_sh.c, c_ksh.c, builtin tables].
	case $CF_DNSBL in *[!\ ]*) ;; *) skip "no dnsbl zone configured"; return 0 ;; esac
	mx_target
	_mx=$_MX
	[ -z "$_mx" ] && { skip "no MX"; return 0; }
	q_a "$_mx"
	_ip=${_Q%%"$NL"*}
	[ -z "$_ip" ] && { skip "no A for $_mx"; return 0; }
	# The four dot separated fields reversed, as awk -F. printed $4.$3.$2.$1.
	IFS=. read -r _o1 _o2 _o3 _o4 _orest <<EOF
$_ip
EOF
	_rev=$_o4.$_o3.$_o2.$_o1
	_checked=0
	for _z in $CF_DNSBL; do
		if dqs_zone "$_z"; then
			_q=q_a_env
			# The zone name carries the key, so it never reaches a message either.
			_name="Spamhaus DQS ${_z#*.}"
		else
			_q=q_a
			_name=$_z
		fi
		# 127.0.0.2 must be listed and 127.0.0.1 must not [RFC 5782 sec 5]. Otherwise the
		# zone is dead or wildcard and is ignored: never a false LISTED. A DQS zone answers
		# this pair too, which is what tells a live subscription from an expired one. The
		# second name is asked only when the first answered.
		$_q "2.0.0.127.$_z"
		_q2=$_Q
		_q1=
		if [ -n "$_q2" ]; then
			$_q "1.0.0.127.$_z"
			_q1=$_Q
		fi
		if [ -z "$_q2" ] || [ -n "$_q1" ]; then
			soft "$_name ignored, sanity test ko, dead zone, wildcard or expired key"
			continue
		fi
		_checked=$((_checked + 1))
		$_q "$_rev.$_z"
		if [ -n "$_Q" ]; then
			# No TXT on the DQS route: gethostbyname(3) answers addresses alone, and the
			# reason Spamhaus gives would need a query of its own.
			if [ "$_q" = q_a ]; then
				q_txt "$_rev.$_z"
				_txt=${_Q%%"$NL"*}
				bad "$_ip listed on $_name ${_txt}"
			else
				bad "$_ip listed on $_name"
			fi
		else
			good "$_ip not listed on $_name"
		fi
	done
	[ "$_checked" -eq 0 ] && skip "no usable dnsbl zone"
	return 0
}

# ---- run ----

# 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 watchdog it asked
# for. Detail lines go to ${OUTF}, so neither descriptor carries anything.
#
# The name of the check stays in $1, a positional of this function that a callee cannot reach:
# a chk_ using the same variable as its caller once sent a status to $TMPD/250.st, and the check
# read as silent.
run_one_check() {
	if [ -n "$DEBUG" ]; then
		exec 2>"$TMPD/$1.err" 1>/dev/null
	else
		exec 2>/dev/null 1>/dev/null
	fi
	OUTF="$TMPD/$1.txt"
	: > "$OUTF"
	ST=ok
	DL=$(( ${SECONDS%%.*} + CF_TIMEOUT ))
	"chk_$1"
	# Written last: a check that never got there is reported as silent, not as green.
	print -r -- "$ST" > "$TMPD/$1.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 MX lookup, ahead of the fan-out ----

# Nine call sites read mx_first "$DOMAIN" (eight, mx_target) or q_mx "$DOMAIN" directly (chk_mtasts), each
# paying its own host+hn-timeout pair for the identical MX RRset. One lookup here instead, under
# the same per-check deadline discipline left()/hq() give any check its own first query. The
# deadline is the smaller of CF_TIMEOUT and CF_BUDGET: this lookup runs BEFORE the hn-timeout
# that bounds the fan-out below, so CF_TIMEOUT alone could let it eat into, or past, a budget
# shorter than a single check's own timeout -- the budget bounds the wall clock of the whole
# run, not only the verdict [test-probe-mailserver.sh, "the budget expires"]. CF_MX_CACHE
# carries the sorted, NL-joined answer q_mx leaves in _Q -- q_mx reads it back in a -C child,
# and falls back to its own query unchanged when the child runs alone, without this cache in
# its environment [docs/PROBES.md, the -C contract]. Empty is a real answer, no MX record, not
# a failure to look up.
# Already set by the resolution of the argument; only -s reaches here without it.
if [ -z "${CF_MX_CACHE+set}" ]; then
	_predl=$CF_TIMEOUT
	[ "$CF_BUDGET" -lt "$_predl" ] && _predl=$CF_BUDGET
	DL=$(( ${SECONDS%%.*} + _predl ))
	q_mx "$DOMAIN"
	CF_MX_CACHE=$_Q
	export CF_MX_CACHE
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 loop, no sleep, and no
# clock read per tick.
#
# Cost of one target: about 710 execve before the text handling moved into the shell, ktrace
# of one pass on hors-norme.fr [mesure:mail 2026-09-27: printf 286, sed 93, timeout 77, host 56,
# grep 54, openssl 38, head 31, tr 21, hostname 16, ksh 15, cat 15, sort 10]. After: what is
# left is the network (hn-timeout, host, openssl, ftp, perl), one ksh per check, and hostname,
# mktemp, xargs, ifconfig and rm once -- about 190 for the same pass [infere, until a ktrace on
# mail], 116 for the sixteen checks in the stub run of the port test [mesure:poste 2026-10-30]; the ceiling constant stays
# 126, headroom for what caching the MX lookup does not touch.
PROBE_MAILSERVER_TMPD=$TMPD
export PROBE_MAILSERVER_TMPD
# 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. Read by nothing else -- but a check
# that dies in its preamble leaves its reason there and nowhere else.
# The interpreter is named, and it is the one of the shebang: the child is this same file, so
# it needs the shell the file is written for, and whether the file carries the execute bit is
# the caller's business -- the installed one does, a copy a test made with a redirection does
# not.
NCHK=0
_list=
for _ck in $SEL; do
	NCHK=$((NCHK + 1))
	_list=$_list$_ck$NL
done
FIRED=0
print -rn -- "$_list" | hn-timeout "$CF_BUDGET" \
    xargs -n1 -P "$NCHK" /bin/ksh "$SELF" -C -f "$CONF" "$DOMAIN" 2>>"$TMPD/launch.err"
# 124 is what hn-timeout reports when it fired [verifie:man.openbsd.org/timeout.1, EXIT STATUS].
[ $? -eq 124 ] && FIRED=1

# ---- verdict ----

TOTAL=0
NKO=0
KO=
SILENT=
for _ck in $SEL; do
	TOTAL=$((TOTAL + 1))
	if [ -f "$TMPD/$_ck.st" ]; then
		_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 <names> -> _JN, leading spaces cut and every run of spaces made ", ".
join_names() {
	lspace "$1"
	_jr=$_LS
	_JN=
	while :; do
		case $_jr in
		*' '*)	_JN="$_JN${_jr%%' '*}, "
			lspace "${_jr#*' '}"
			_jr=$_LS ;;
		*)	_JN=$_JN$_jr
			return 0 ;;
		esac
	done
}

# sum_line -> _SUM.
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, ..."
		_SUM="$_base ($_names)"
	else
		_SUM=$_base
	fi
}

# detail_of <check> -> _DET, the lines of its result joined by "; ".
detail_of() {
	_DET=
	[ -f "$TMPD/$1.txt" ] || return 0
	_dsep=
	while IFS= read -r _dl || [ -n "$_dl" ]; do
		_DET=$_DET$_dsep$_dl
		_dsep='; '
	done < "$TMPD/$1.txt"
}

# status_of <check> -> _STAT.
status_of() {
	if [ -f "$TMPD/$1.st" ]; then
		_STAT=$(< "$TMPD/$1.st")
	else
		_STAT=silent
	fi
}

# jesc <text> -> _JE: backslash and double quote escaped, tab, newline and CR made spaces.
jesc() {
	need_cr
	subst '\' '\\' "$1"
	subst '"' '\"' "$_SB"
	subst "$TAB" ' ' "$_SB"
	subst "$NL" ' ' "$_SB"
	subst "$CR" ' ' "$_SB"
	_JE=$_SB
}

# pad7 <name> -> _PAD, space padded to seven: printf '%-7s'.
pad7() {
	_PAD=$1
	while [ ${#_PAD} -lt 7 ]; do
		_PAD="$_PAD "
	done
}

# The detail of every check, as `printf '%-7s %s\n'` then `sed 's/^/    /'` wrote it: a last
# line without its newline stays without, as sed(1) leaves it [verifie:openbsd/src
# usr.bin/sed/main.c, mf_fgets, append_newline].
all_details() {
	for _ck in $SEL; do
		status_of "$_ck"
		pad7 "$_ck"
		_OUT="$_OUT$_PAD $_STAT$NL"
		[ -f "$TMPD/$_ck.txt" ] || continue
		_il=
		while IFS= read -r _il; do
			_OUT="$_OUT    $_il$NL"
		done < "$TMPD/$_ck.txt"
		[ -n "$_il" ] && _OUT="$_OUT    $_il"
	done
}

# Accumulated, then written once.
_OUT=
if [ "$FORMAT" = json ]; then
	# argument and host: what the user typed, and the MX host it forced; null without one.
	_jh=null
	[ -n "$CF_MXHOST" ] && _jh="\"$CF_MXHOST\""
	_OUT="{\"domain\":\"$DOMAIN\",\"argument\":\"${ARGUMENT:-$DOMAIN}\",\"host\":$_jh"
	for _ck in $SEL; do
		status_of "$_ck"
		detail_of "$_ck"
		jesc "$_DET"
		_OUT="$_OUT,\"$_ck\":{\"status\":\"$_STAT\",\"detail\":\"$_JE\"}"
	done
	_arr=
	for _k in $KO; do
		if [ -z "$_arr" ]; then
			_arr="\"$_k\""
		else
			_arr="$_arr,\"$_k\""
		fi
	done
	_OUT="$_OUT,\"verdict\":{\"ko\":$NKO,\"total\":$TOTAL,\"failed\":[$_arr],\"rc\":$RC}}$NL"
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.
	[ -n "$NOTICE" ] && _OUT="notice: $NOTICE$NL"
	all_details
	if [ "$RC" -eq 0 ]; then
		_OUT="$_OUT$DOMAIN: $TOTAL/$TOTAL ok$NL"
	else
		sum_line
		_OUT="$_OUT$DOMAIN: $_SUM$NL"
	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
		[ -n "$NOTICE" ] && _OUT="notice: $NOTICE$NL"
		all_details
	fi
fi
[ -n "$_OUT" ] && print -rn -- "$_OUT"

# 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
	join_names "$SILENT"
	if [ "$FIRED" -eq 1 ]; then
		emit "could not test, budget ${CF_BUDGET}s expired, no result for $_JN"
	else
		emit "could not test, no result for $_JN"
	fi
elif [ "$RC" -eq 1 ]; then
	sum_line
	emit "$_SUM"
fi

exit "$RC"
