#!/bin/ksh
#
# probe-webserver [-o reed|text|json] [-f config] <domain> [port]
#
# Configuration: what -f names, else /etc/probe-webserver/<domain>.conf on port 443 (the
# default, unchanged from before ports existed here), else /etc/probe-webserver/<domain>_<port>.conf
# on any other port, else /etc/probe-webserver.conf, else the defaults below. A domain probed
# behind two different ports (a vhost on 443, a relay terminating TLS elsewhere) gets one file
# each rather than one overwriting the other. port also seeds CF_PORT, so a caller that never
# terminates anywhere but 443 never has to name it.
#
# One verdict for a web domain: DNS and reachability, the redirect off port 80, the served
# certificate and its chain, the protocol versions, HSTS, DANE, CAA against the real issuer,
# IPv6, the alias names, OCSP stapling, the security headers and the TLS suites negotiated.
# 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.

set -u
export LC_ALL=C			# C collation for the bracket ranges of the patterns below

# The PATH of the caller is kept, in its order: the port test puts its host, openssl and nc
# 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-webserver
# 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="dns http tls proto hsts dane dnssec caa v6 alias ocsp hdr ciphers"

FORMAT=reed
# Empty until the domain is known: the per-domain path is built from the argument.
CONF=
CONFDIR=/etc/probe-webserver
CONFFILE=/etc/probe-webserver.conf
# Fleet-wide defaults shared with sysutils/probe-mailserver, 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=
PORT=443

# Separators of the pattern work below. CR has no quoting of its own in this shell: set after
# the options.
NL='
'
TAB='	'
CR=

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

# Text handling is done by the shell: printf, sed, tr, grep, head, cat are processes here, print
# and read are not [verifie:openbsd/src bin/ksh/c_sh.c:884, c_ksh.c:1404-1425, builtin tables],
# and this script runs once per target and once more per check. Helpers set a variable instead
# of printing into a $(...).

# _SUB: $1 with every $2 replaced by $3. No ${x//a/b} in this shell
# [verifie:openbsd/src bin/ksh/ksh.1, parameter substitution]. Empty $2: unchanged, the loop
# would never end.
subst() {
	_SUB=
	_sb_s=$1
	[ -n "$2" ] || { _SUB=$1; return 0; }
	while :; do
		case $_sb_s in
		*"$2"*)	_SUB=$_SUB${_sb_s%%"$2"*}$3
			_sb_s=${_sb_s#*"$2"} ;;
		*)	_SUB=$_SUB$_sb_s
			return 0 ;;
		esac
	done
}

# _LOWER: $1 in lower case. typeset in a function is local to it [verifie:ksh.1, typeset].
lower() {
	typeset -l _lw_v="$1"
	_LOWER=$_lw_v
}

# 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, returns and tabs folded to a space, single quotes
	# dropped, 120 bytes through the precision of %s [verifie:man.openbsd.org/printf.1] -- the
	# one printf of a run, on the way out only.
	subst "$1" "'" ''
	subst "$_SUB" "$NL" ' '
	subst "$_SUB" "$CR" ' '
	subst "$_SUB" "$TAB" ' '
	printf '%.120s\n' "$_SUB" >&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:ho: _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 ;;
	*)	usage ;;
	esac
done
shift $((OPTIND - 1))
# -C takes the domain and the name of the check, both required; the configuration comes from
# the parent [see the xargs call below]. Anything else takes the domain alone, with an optional
# port as $2 -- never confused with CHECKNAME, RUNCHECK picks which one $2 means before either
# is read.
if [ "$RUNCHECK" -eq 1 ]; then
	[ $# -eq 2 ] || usage
	CHECKNAME=$2
else
	CHECKNAME=
	case $# in
	1)	;;
	2)	PORT=$2 ;;
	*)	usage ;;
	esac
fi
DOMAIN=$1

# print is a builtin: a fork, no exec. The checks get the parent's through the environment.
# Under a shell without print, the sh of a workstation, CR stays empty and subst skips it.
if [ "$RUNCHECK" -eq 1 ]; then
	CR=${PROBE_WEBSERVER_CR:-}
else
	CR=$(print -n '\r' 2>/dev/null)
fi

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

# Shape of a name already restricted to [A-Za-z0-9.-]: non-empty labels separated by '.', no
# label starting or ending with '-'. Same set as the ERE
# ^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*$
domain_shape() {
	case $1 in
	.* | *. | *..* | -* | *- | *.-* | *-.*)	return 1 ;;
	esac
	return 0
}

# The character class first, on the whole string: also what keeps a quote, a newline or a shell
# metacharacter out of the stderr line below.
case "$DOMAIN" in
'' | *[!A-Za-z0-9.-]*) cannot "the argument is not a domain name" ;;
esac
domain_shape "$DOMAIN" || cannot "the argument is not a domain name"

case "$PORT" in
''|*[!0-9]*)	cannot "the port is not numeric" ;;
esac
[ "$PORT" -ge 1 ] && [ "$PORT" -le 65535 ] || cannot "the port is out of range"

# ---- 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].
	_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: of each line of $1 holding $2, what follows its LAST occurrence -- ## is the greedy .* of
# sed -n 's/.*<marker>//p' -- one value per line, trailing newlines dropped like a $(...).
lines_after() {
	_Q=
	_la_rest=$1
	while :; do
		_la_ln=${_la_rest%%"$NL"*}
		case $_la_ln in
		*"$2"*)	_Q=$_Q${_la_ln##*"$2"}$NL ;;
		esac
		case $_la_rest in
		*"$NL"*)	_la_rest=${_la_rest#*"$NL"} ;;
		*)		break ;;
		esac
	done
	while :; do
		case $_Q in
		*"$NL")	_Q=${_Q%"$NL"} ;;
		*)	return 0 ;;
		esac
	done
}

# The answer goes through a file of the check and $(< file), which starts no process
# [verifie:ksh.1, "$(< foo)"]: a $(hq) would fork a subshell to run the function in. The
# redirection truncates the file even when hq gives up before the query.
dnsq() {
	hq -t "$1" "$2" > "$WD.q"
	lines_after "$(< "$WD.q")" "$3"
}

# q_a alone reads a cache: twelve checks call it with the same argument, $DOMAIN, and the
# parent has already paid for that one answer before the fan-out. Three cases on CF_A_CACHE
# [docs/PROBES.md, the -C contract]: exported and non-empty, used as is; exported and empty,
# which IS the answer (no A 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_a() {
	if [ "$1" = "$DOMAIN" ] && [ -n "${CF_A_CACHE+set}" ]; then
		_Q=$CF_A_CACHE
		return 0
	fi
	dnsq A "$1" ' has address '
}
q_aaaa() { dnsq AAAA "$1" ' has IPv6 address '; }
q_caa()  { dnsq CAA "$1" ' has CAA record '; }
q_tlsa() { dnsq TLSA "$1" ' has TLSA record '; }
q_dnskey() { dnsq 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()     { dnsq DS "$1" ' has DS record '; }

# ---- network ----

# _CNX: addr:port for IPv4 and for a name, addr/port for a numeric IPv6: the forward-slash form
# is the one -connect takes for those [verifie:man.openbsd.org/openssl.1].
cnx() {
	case "$1" in
	*:*)	_CNX=$1/$2 ;;
	*)	_CNX=$1:$2 ;;
	esac
}

# nc -z scans for a listening daemon without sending it anything
# [verifie:man.openbsd.org/nc.1].
tcp_open() {
	left
	_t=$_LEFT
	[ "$_t" -eq 0 ] && return 1
	_w=5
	[ "$_t" -lt 5 ] && _w=$_t
	hn-timeout "$_t" nc -z -w "$_w" "$1" "$2" >/dev/null 2>&1
}

# _REQ: the HEAD request both head80 and head_tls send, CRLF line ends.
http_head() {
	_REQ="HEAD / HTTP/1.1$CR${NL}Host: $1$CR${NL}User-Agent: $PROG$CR${NL}"
	_REQ="${_REQ}Connection: close$CR$NL$CR$NL"
}

# ftp(1) prints neither the status line nor the headers [verifie:man.openbsd.org/ftp.1], and
# both are what this probe reads, so port 80 is spoken to directly. -N shuts the socket down
# after EOF on the input, which is what makes the server answer and leave
# [verifie:man.openbsd.org/nc.1].
head80() {
	left
	_t=$_LEFT
	[ "$_t" -eq 0 ] && return 1
	http_head "$1"
	print -rn -- "$_REQ" | hn-timeout "$_t" nc -N -w "$_t" "$2" 80 2>/dev/null
}

# -quiet inhibits the session and certificate information, so stdout carries the answer of the
# server alone [verifie:man.openbsd.org/openssl.1]. Connects on CF_PORT, not a fixed 443: a
# vhost behind a relay can serve TLS on any port.
head_tls() {
	left
	_t=$_LEFT
	[ "$_t" -eq 0 ] && return 1
	http_head "$1"
	cnx "$2" "$CF_PORT"
	print -rn -- "$_REQ" | hn-timeout "$_t" openssl s_client -quiet \
	    -connect "$_CNX" -servername "$1" 2>/dev/null
}

# _HV: of the first line of $1 that starts with the pattern $2, the rest, leading blanks
# dropped, every CR removed: sed -n 's/^<p>[[:space:]]*//p' | head -1 | tr -d '\r'.
first_header() {
	_HV=
	_fh_rest=$1
	while :; do
		_fh_ln=${_fh_rest%%"$NL"*}
		case $_fh_ln in
		$2*)	_fh_ln=${_fh_ln#$2}
			_fh_ln=${_fh_ln#"${_fh_ln%%[![:space:]]*}"}
			subst "$_fh_ln" "$CR" ''
			_HV=$_SUB
			return 0 ;;
		esac
		case $_fh_rest in
		*"$NL"*)	_fh_rest=${_fh_rest#*"$NL"} ;;
		*)		return 0 ;;
		esac
	done
}

# fetch_chain <name> <addr> <prefix> [s_client option...] -> <prefix>.raw.txt and
# <prefix>.cNN.pem, one file per certificate of the served chain; _NC their count as the
# c[0-9][0-9] names can hold it, _VR the last "Verify return code". -showcerts displays the
# whole chain, normally only the server certificate itself [verifie:man.openbsd.org/openssl.1].
# A prefix is used once per run, so nothing stale lies under it: no mkdir, no rm.
#
# 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.
fetch_chain() {
	_fc_h=$1
	_fc_a=$2
	_fc_d=$3
	shift 3
	_NC=0
	_VR=
	left
	_t=$_LEFT
	[ "$_t" -eq 0 ] && return 1
	cnx "$_fc_a" "$CF_PORT"
	print -r -- Q | hn-timeout "$_t" openssl s_client -showcerts \
	    -connect "$_CNX" -servername "$_fc_h" "$@" > "$_fc_d.raw.txt" 2>/dev/null
	# From a BEGIN line to the next END line, both included, one file per BEGIN.
	_fc_n=0
	_fc_in=0
	_fc_pem=
	while IFS= read -r _fc_ln || [ -n "$_fc_ln" ]; do
		case $_fc_ln in
		*"-----BEGIN CERTIFICATE-----"*)
			[ "$_fc_in" -eq 1 ] && pem_out
			_fc_n=$((_fc_n + 1))
			_fc_in=1
			_fc_pem= ;;
		esac
		[ "$_fc_in" -eq 1 ] && _fc_pem=$_fc_pem$_fc_ln$NL
		case $_fc_ln in
		*"-----END CERTIFICATE-----"*)
			[ "$_fc_in" -eq 1 ] && pem_out
			_fc_in=0 ;;
		esac
		case $_fc_ln in
		*"Verify return code: "*)	_VR=${_fc_ln##*"Verify return code: "} ;;
		esac
	done < "$_fc_d.raw.txt"
	[ "$_fc_in" -eq 1 ] && pem_out
	_NC=$_fc_n
	[ "$_NC" -gt 99 ] && _NC=99
	[ -s "$_fc_d.c01.pem" ]
}

# Certificate _fc_n of fetch_chain, named %02d.
pem_out() {
	_fc_f=$_fc_n
	[ "$_fc_n" -lt 10 ] && _fc_f=0$_fc_n
	print -rn -- "$_fc_pem" > "$_fc_d.c$_fc_f.pem"
}

# ---- certificates ----

# The x509 lines read here are one line each: the value after its label.
cert_end() {
	_END=$(openssl x509 -in "$1" -noout -enddate 2>/dev/null)
	_END=${_END#notAfter=}
}

cert_subject() {
	_SUBJ=$(openssl x509 -in "$1" -noout -subject 2>/dev/null)
	case $_SUBJ in
	subject=*)	_SUBJ=${_SUBJ#subject=}
			_SUBJ=${_SUBJ#"${_SUBJ%%[! ]*}"} ;;
	esac
}

cert_issuer() {
	_ISS=$(openssl x509 -in "$1" -noout -issuer 2>/dev/null)
	case $_ISS in
	issuer=*)	_ISS=${_ISS#issuer=}
			_ISS=${_ISS#"${_ISS%%[! ]*}"} ;;
	esac
}

# _COV: CN of the subject plus every DNS entry of the SAN, lowercased, one per line. This x509
# has no -ext [verifie:man.openbsd.org/openssl.1], so the SAN is read from the -text rendering,
# whose value sits on the line after the extension name.
cert_names() {
	_cn_acc=
	# The CN: after the last "CN<blanks>=<blanks>" of a line, up to the first comma.
	_cn_rest=$(openssl x509 -in "$1" -noout -subject 2>/dev/null)
	while :; do
		_cn_ln=${_cn_rest%%"$NL"*}
		_cn_v=
		_cn_hit=0
		while :; do
			case $_cn_ln in
			*CN*)	;;
			*)	break ;;
			esac
			_cn_ln=${_cn_ln#*CN}
			_cn_t=${_cn_ln#"${_cn_ln%%[![:space:]]*}"}
			case $_cn_t in
			=*)	_cn_t=${_cn_t#=}
				_cn_v=${_cn_t#"${_cn_t%%[![:space:]]*}"}
				_cn_hit=1 ;;
			esac
		done
		[ "$_cn_hit" -eq 1 ] && _cn_acc=$_cn_acc${_cn_v%%,*}$NL
		case $_cn_rest in
		*"$NL"*)	_cn_rest=${_cn_rest#*"$NL"} ;;
		*)		break ;;
		esac
	done
	# The SAN: the line after each extension name, split on commas, the DNS: entries kept.
	_cn_rest=$(openssl x509 -in "$1" -noout -text 2>/dev/null)
	_cn_next=0
	while :; do
		_cn_ln=${_cn_rest%%"$NL"*}
		if [ "$_cn_next" -eq 1 ]; then
			_cn_next=0
			while :; do
				_cn_t=${_cn_ln%%,*}
				_cn_t=${_cn_t#"${_cn_t%%[![:space:]]*}"}
				case $_cn_t in
				DNS:*)	_cn_t=${_cn_t#DNS:}
					_cn_acc=$_cn_acc${_cn_t#"${_cn_t%%[![:space:]]*}"}$NL ;;
				esac
				case $_cn_ln in
				*,*)	_cn_ln=${_cn_ln#*,} ;;
				*)	break ;;
				esac
			done
		else
			case $_cn_ln in
			*"X509v3 Subject Alternative Name"*)	_cn_next=1 ;;
			esac
		fi
		case $_cn_rest in
		*"$NL"*)	_cn_rest=${_cn_rest#*"$NL"} ;;
		*)		break ;;
		esac
	done
	# Trailing blanks off each name, empty names dropped, one case.
	_COV=
	while [ -n "$_cn_acc" ]; do
		_cn_t=${_cn_acc%%"$NL"*}
		_cn_acc=${_cn_acc#*"$NL"}
		_cn_t=${_cn_t%"${_cn_t##*[![:space:]]}"}
		[ -n "$_cn_t" ] && _COV=$_COV$_cn_t$NL
	done
	_COV=${_COV%"$NL"}
	lower "$_COV"
	_COV=$_LOWER
}

# A '*' matches exactly one label [RFC 6125 sec 6.4.3].
name_covered() {
	_n=$1
	shift
	for _p in "$@"; do
		[ "$_n" = "$_p" ] && return 0
		case "$_p" in
		"*."*)
			case "$_n" in
			*.*)	[ "${_n#*.}" = "${_p#*.}" ] && return 0 ;;
			esac
			;;
		esac
	done
	return 1
}

# _DIG: the hex of an openssl dgst line, "(stdin)= <hex>", lowercased.
dgst_value() {
	_dv_x=${1##*=}
	_dv_x=${_dv_x#"${_dv_x%%[! ]*}"}
	lower "$_dv_x"
	_DIG=$_LOWER
}

leaf_digest() {
	dgst_value "$(openssl x509 -in "$1" -outform der 2>/dev/null |
	    openssl dgst -sha256 2>/dev/null)"
}

# DER of the certificate (selector 0) or of its SubjectPublicKeyInfo (selector 1)
# [RFC 6698 sec 2.1.2].
tlsa_der() {
	if [ "$2" = 0 ]; then
		openssl x509 -in "$1" -outform der 2>/dev/null
	else
		openssl x509 -in "$1" -pubkey -noout 2>/dev/null |
		    openssl pkey -pubin -outform der 2>/dev/null
	fi
}

# _TD: matching 0 is the value itself, 1 sha256, 2 sha512 [RFC 6698 sec 2.1.3]. rc 1: a
# selector or matching type this does not evaluate.
tlsa_digest() {
	_TD=
	case "$2" in
	0|1)	;;
	*)	return 1 ;;
	esac
	case "$3" in
	0)	_TD=$(tlsa_der "$1" "$2" | hexdump -ve '1/1 "%02x"') ;;
	1)	dgst_value "$(tlsa_der "$1" "$2" | openssl dgst -sha256 2>/dev/null)"
		_TD=$_DIG ;;
	2)	dgst_value "$(tlsa_der "$1" "$2" | openssl dgst -sha512 2>/dev/null)"
		_TD=$_DIG ;;
	*)	return 1 ;;
	esac
	return 0
}

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

# Detail lines go to descriptor 3, opened once per check on its .txt file: a builtin print, no
# process, and each line on disk as soon as it is known -- a check the budget kills keeps what
# it had measured.
WD=
ST=ok
good() { print -r -u3 -- "OK   $*"; }
bad()  { print -r -u3 -- "FAIL $*"; ST=fail; }
# Advice, never a verdict: hdr is the only check that calls it, and a warn leaves rc 0.
soft() { print -r -u3 -- "WARN $*"; [ "$ST" = fail ] || ST=warn; }
# Nothing to measure, and that is not a defect of the domain: no TLSA published, no AAAA, no
# alias configured, ocsp not required. Neutral for the exit code.
none() { print -r -u3 -- "UNTESTED $*"; [ "$ST" = ok ] && ST=untested; return 0; }
# A line the reader wants without a verdict attached to it.
note() { print -r -u3 -- "INFO $*"; }

# ---- checks ----

chk_dns() {
	q_a "$DOMAIN"
	_a4=$_Q
	if [ -z "$_a4" ]; then
		bad "no A record, the name does not resolve"
		return 0
	fi
	subst "$_a4" "$NL" ' '
	good "A $_SUB"
	q_aaaa "$DOMAIN"
	_a6=$_Q
	if [ -n "$_a6" ]; then
		subst "$_a6" "$NL" ' '
		good "AAAA $_SUB"
	else
		# The v6 check calls a missing AAAA untested, so it is not a failure here either.
		note "no AAAA record, the name is reachable over IPv4 only"
	fi
	_ip=${_a4%%"$NL"*}
	if tcp_open "$_ip" "$CF_PORT"; then
		good "$_ip answers on $CF_PORT"
	else
		bad "$_ip does not answer on $CF_PORT"
	fi
	return 0
}

# _CODE: the status of line 1, sed '1s|^HTTP/[0-9.]* *\([0-9][0-9][0-9]\).*|\1|p': the
# [0-9.] run taken as long as three digits can still follow it.
status_code() {
	_CODE=
	_sc_l=${1%%"$NL"*}
	case $_sc_l in
	HTTP/*)	;;
	*)	return 0 ;;
	esac
	_sc_x=${_sc_l#HTTP/}
	_sc_pre=${_sc_x%%[!0-9.]*}
	while :; do
		_sc_r=${_sc_x#"$_sc_pre"}
		_sc_r=${_sc_r#"${_sc_r%%[! ]*}"}
		case $_sc_r in
		[0-9][0-9][0-9]*)
			_CODE=${_sc_r%"${_sc_r#???}"}
			return 0 ;;
		esac
		[ -z "$_sc_pre" ] && return 0
		_sc_pre=${_sc_pre%?}
	done
}

chk_http() {
	if [ "$CF_REDIRECT" = no ]; then
		none "redirect = no, port 80 answers for itself here"
		return 0
	fi
	q_a "$DOMAIN"
	_ip=${_Q%%"$NL"*}
	[ -z "$_ip" ] && { bad "no A record, port 80 not reachable"; return 0; }
	head80 "$DOMAIN" "$_ip" > "$WD.r"
	_r=$(< "$WD.r")
	[ -z "$_r" ] && { bad "no answer on $_ip:80, a visitor typing the name gets nothing"; return 0; }
	status_code "$_r"
	_code=$_CODE
	first_header "$_r" '[Ll]ocation:'
	_loc=$_HV
	case "$_code" in
	301|308)	;;
	200)		bad "port 80 answers 200, the page is served in clear"; return 0 ;;
	302|303|307)	bad "port 80 answers $_code, a temporary redirect is not a policy"; return 0 ;;
	'')		bad "port 80 answers no status line"; return 0 ;;
	*)		bad "port 80 answers $_code, no permanent redirect to https"; return 0 ;;
	esac
	[ -z "$_loc" ] && { bad "port 80 answers $_code without a Location header"; return 0; }
	good "80 answers $_code to $_loc"
	case "$_loc" in
	https://*)	;;
	*)		bad "the redirect target is not https, the browser comes back to 80"; return 0 ;;
	esac
	_rest=${_loc#https://}
	_host=${_rest%%/*}
	_path=/${_rest#*/}
	[ "$_rest" = "$_host" ] && _path=/
	_host=${_host%%:*}
	lower "$_host"
	_lh=$_LOWER
	lower "$DOMAIN"
	if [ "$_lh" != "$_LOWER" ]; then
		bad "the redirect leaves the host, $DOMAIN to $_host"
		return 0
	fi
	if [ "$_path" != "/" ]; then
		bad "the redirect changes the path, / to $_path"
		return 0
	fi
	good "same host and same path, no loop"
	return 0
}

chk_tls() {
	q_a "$DOMAIN"
	_ip=${_Q%%"$NL"*}
	[ -z "$_ip" ] && { bad "no A record, $CF_PORT not reachable"; return 0; }
	fetch_chain "$DOMAIN" "$_ip" "$WD" || { bad "no certificate served on $_ip:$CF_PORT"; return 0; }
	_vr=$_VR
	_nc=$_NC
	cert_end "$WD.c01.pem"
	_end=$_END
	cert_subject "$WD.c01.pem"
	good "cert $_SUBJ, exp $_end, $_nc served"
	if [ -z "$_vr" ]; then
		bad "no verify return code printed, the handshake did not complete"
	elif [ "${_vr%% *}" = 0 ]; then
		# The chain is verified from what the server sent plus the local store: no issuer
		# is fetched over the AIA of the certificate, so an intermediate the server keeps
		# to itself lands here as a verify failure [infere].
		good "chain verified against the system store, served chain sufficient"
	else
		bad "chain verify ko ($_vr), the served chain is not sufficient on its own"
	fi
	if ! openssl x509 -in "$WD.c01.pem" -noout -checkend 0 >/dev/null 2>&1; then
		bad "certificate expired, notAfter ${_end:-unknown}"
	elif ! openssl x509 -in "$WD.c01.pem" -noout -checkend $((CF_CERT_DAYS * 86400)) \
	    >/dev/null 2>&1; then
		bad "certificate expires within $CF_CERT_DAYS days, notAfter ${_end:-unknown}"
	else
		good "more than $CF_CERT_DAYS days left, notAfter ${_end:-unknown}"
	fi
	cert_names "$WD.c01.pem"
	_cov=$_COV
	lower "$DOMAIN"
	if [ -z "$_cov" ]; then
		bad "the certificate carries neither CN nor SAN"
	elif name_covered "$_LOWER" $_cov; then
		good "$DOMAIN covered by the certificate"
	else
		bad "$DOMAIN covered by neither the CN nor a SAN of the certificate served"
	fi
	return 0
}

chk_proto() {
	q_a "$DOMAIN"
	_ip=${_Q%%"$NL"*}
	[ -z "$_ip" ] && { bad "no A record, $CF_PORT not reachable"; return 0; }
	# This s_client has -tls1_2 and -tls1_3 and nothing below them
	# [verifie:man.openbsd.org/openssl.1]: whether the server refuses TLS 1.0 and 1.1 cannot
	# be measured from here, only that it accepts the two versions in use.
	for _v in tls1_2 tls1_3; do
		_lbl=${_v#tls}
		_lbl=${_lbl%%_*}.${_lbl#*_}
		if fetch_chain "$DOMAIN" "$_ip" "$WD.$_v" "-$_v"; then
			good "TLS $_lbl accepted"
		else
			bad "TLS $_lbl refused, the handshake returned no certificate"
		fi
	done
	return 0
}

chk_hsts() {
	q_a "$DOMAIN"
	_ip=${_Q%%"$NL"*}
	[ -z "$_ip" ] && { bad "no A record, $CF_PORT not reachable"; return 0; }
	head_tls "$DOMAIN" "$_ip" > "$WD.r"
	_r=$(< "$WD.r")
	[ -z "$_r" ] && { bad "no answer on $_ip:$CF_PORT, the header cannot be read"; return 0; }
	first_header "$_r" '[Ss]trict-[Tt]ransport-[Ss]ecurity:'
	_h=$_HV
	if [ "$CF_HSTS" = no ]; then
		# A vhost that only redirects, or a parking name, must not pin https on the whole
		# host: the pin outlives the vhost.
		if [ -z "$_h" ]; then
			good "no Strict-Transport-Security, which hsts = no asks for"
		else
			bad "Strict-Transport-Security served where the policy forbids it, $_h"
		fi
		return 0
	fi
	[ -z "$_h" ] && { bad "no Strict-Transport-Security header, https is not pinned"; return 0; }
	good "Strict-Transport-Security: $_h"
	# The digits after the LAST "max-age<blanks>=<blanks>" followed by one, as the greedy
	# .*[Mm]ax-[Aa]ge[[:space:]]*=[[:space:]]*\([0-9][0-9]*\) picks them.
	_age=
	_x=$_h
	while :; do
		case $_x in
		*[Mm]ax-[Aa]ge*)	;;
		*)			break ;;
		esac
		_x=${_x#*[Mm]ax-[Aa]ge}
		_y=${_x#"${_x%%[![:space:]]*}"}
		case $_y in
		=*)	_y=${_y#=}
			_y=${_y#"${_y%%[![:space:]]*}"}
			_y=${_y%%[!0-9]*}
			[ -n "$_y" ] && _age=$_y ;;
		esac
	done
	if [ -z "$_age" ]; then
		bad "the header carries no max-age, a browser ignores it"
	elif [ "$_age" -lt "$CF_HSTS_MIN_AGE" ]; then
		bad "max-age $_age below the configured minimum $CF_HSTS_MIN_AGE"
	else
		good "max-age $_age at or above $CF_HSTS_MIN_AGE"
	fi
	return 0
}

chk_dane() {
	q_tlsa "_${CF_PORT}._tcp.$DOMAIN"
	_recs=$_Q
	[ -z "$_recs" ] && { none "no TLSA on _${CF_PORT}._tcp.$DOMAIN, DANE is not published"; return 0; }
	q_a "$DOMAIN"
	_ip=${_Q%%"$NL"*}
	[ -z "$_ip" ] && { bad "TLSA published but the name has no A record"; return 0; }
	fetch_chain "$DOMAIN" "$_ip" "$WD" \
	    || { bad "TLSA published on _${CF_PORT}._tcp.$DOMAIN but $CF_PORT serves no certificate"; return 0; }
	_hit=
	_eval=0
	while read -r _u _s _m _hx; do
		[ -z "${_hx:-}" ] && continue
		# Blanks out, hex compared in one case: a letter past F matches no digest either way.
		subst "$_hx" ' ' ''
		lower "$_SUB"
		_want=$_LOWER
		# usage 1 and 3 pin the leaf, usage 0 and 2 pin a certificate of the chain
		# [RFC 6698 sec 2.1.1].
		case "$_u" in
		1|3)	_cands=$WD.c01.pem ;;
		0|2)	_cands=
			for _c in "$WD".c[0-9][0-9].pem; do
				[ -e "$_c" ] && _cands="$_cands $_c"
			done ;;
		*)	note "TLSA usage $_u not evaluated"; continue ;;
		esac
		for _c in $_cands; do
			tlsa_digest "$_c" "$_s" "$_m" || continue
			_got=$_TD
			[ -z "$_got" ] && continue
			_eval=$((_eval + 1))
			if [ "$_got" = "$_want" ]; then
				_hit="$_u $_s $_m"
				break
			fi
		done
		[ -n "$_hit" ] && break
	done <<EOF
$_recs
EOF
	if [ -n "$_hit" ]; then
		good "TLSA $_hit matches the certificate served on $DOMAIN:$CF_PORT"
	elif [ "$_eval" -eq 0 ]; then
		none "TLSA present but no record with a usage, selector and matching this evaluates"
	else
		bad "TLSA mismatch on _${CF_PORT}._tcp.$DOMAIN, the record pins another key"
	fi
	return 0
}

# A TLSA record on an unsigned zone proves nothing: a resolver has no way to tell it from one an
# attacker stripped or replaced, and a validating client ignores it. So DANE and DNSSEC are one
# question, asked as two checks -- this one fails only where the answer matters. 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].
chk_dnssec() {
	_z=$DOMAIN
	_keyed=
	# Up to the apex: the DNSKEY sits at the top of the zone, not on the name inside it.
	while :; do
		case "$_z" in
		*.*)	;;
		*)	break ;;
		esac
		q_dnskey "$_z"
		if [ -n "$_Q" ]; then
			_keyed=$_z
			break
		fi
		_z=${_z#*.}
	done
	_signed=
	if [ -n "$_keyed" ]; then
		q_ds "$_keyed"
		[ -n "$_Q" ] && _signed=$_keyed
	fi
	q_tlsa "_${CF_PORT}._tcp.$DOMAIN"
	_tlsa=$_Q
	if [ -n "$_signed" ]; then
		good "zone signed, DNSKEY on $_signed and its DS at the parent"
	elif [ -n "$_keyed" ] && [ -n "$_tlsa" ]; then
		bad "TLSA published on _${CF_PORT}._tcp.$DOMAIN but no DS for the DNSKEY of $_keyed, DANE is void"
	elif [ -n "$_keyed" ]; then
		none "DNSKEY on $_keyed but no DS at the parent, and no TLSA that would need one"
	elif [ -n "$_tlsa" ]; then
		bad "TLSA published on _${CF_PORT}._tcp.$DOMAIN but no DNSKEY up to the apex, DANE is void"
	else
		none "no DNSKEY up to the apex, and no TLSA that would need one"
	fi
	return 0
}

# _AN: $1 with every byte outside [a-z0-9] removed, tr -cd 'a-z0-9'.
alnum_only() {
	_AN=$1
	while :; do
		case $_AN in
		*[!a-z0-9]*)	_AN=${_AN%%[!a-z0-9]*}${_AN#*[!a-z0-9]} ;;
		*)		return 0 ;;
		esac
	done
}

chk_caa() {
	# The RRset is looked for on the name, then on each parent, the climb stopping before the
	# top level label [RFC 8659 sec 3].
	_z=$DOMAIN
	_caa=
	_at=
	while :; do
		q_caa "$_z"
		_caa=$_Q
		[ -n "$_caa" ] && { _at=$_z; break; }
		_up=${_z#*.}
		[ "$_up" = "$_z" ] && break
		case "$_up" in
		*.*)	;;
		*)	break ;;
		esac
		_z=$_up
	done
	[ -z "$_caa" ] && { bad "no CAA from $DOMAIN up to the apex, any authority may issue"; return 0; }
	subst "$_caa" "$NL" ';'
	good "CAA on $_at, $_SUB"
	# The value of each "<flags><blanks>issue<blanks>" line: issuewild is not issue, the blank
	# after the tag is required. Quotes out, cut at the first ';', trailing blanks off, empty
	# values dropped.
	_iss=
	_x=$_caa
	while :; do
		_y=${_x%%"$NL"*}
		_d=${_y%%[!0-9]*}
		_y=${_y#"$_d"}
		_b=${_y%%[![:space:]]*}
		_y=${_y#"$_b"}
		if [ -n "$_d" ] && [ -n "$_b" ]; then
			case $_y in
			issue*)
				_y=${_y#issue}
				_b=${_y%%[![:space:]]*}
				if [ -n "$_b" ]; then
					_y=${_y#"$_b"}
					subst "$_y" '"' ''
					_y=${_SUB%%";"*}
					_y=${_y%"${_y##*[![:space:]]}"}
					[ -n "$_y" ] && _iss=$_iss$_y$NL
				fi ;;
			esac
		fi
		case $_x in
		*"$NL"*)	_x=${_x#*"$NL"} ;;
		*)		break ;;
		esac
	done
	_iss=${_iss%"$NL"}
	if [ -z "$_iss" ]; then
		bad "CAA on $_at names no authority allowed to issue, yet a certificate is served"
		return 0
	fi
	q_a "$DOMAIN"
	_ip=${_Q%%"$NL"*}
	[ -z "$_ip" ] && { bad "CAA read but no A record, the issuer cannot be compared"; return 0; }
	fetch_chain "$DOMAIN" "$_ip" "$WD" || { bad "CAA read but $CF_PORT serves no certificate"; return 0; }
	cert_issuer "$WD.c01.pem"
	_dn=$_ISS
	[ -z "$_dn" ] && { bad "the certificate served carries no readable issuer"; return 0; }
	good "issuer $_dn"
	# The CAA value is a domain, the issuer a DN: "letsencrypt.org" against "O = Let's
	# Encrypt, CN = R11". Both sides are reduced to their letters and digits and a label of
	# the CAA value is looked for inside the DN. Labels under three characters are dropped,
	# they match anything. The last label of a value with several is its top level domain: under
	# four characters (com, org, net) a substring of Comodo, Organization, Internet in any DN,
	# dropped too; longer, a brand domain naming its owner, kept (pki.goog in Google).
	lower "$_dn"
	alnum_only "$_LOWER"
	_flat=$_AN
	_hit=
	for _cand in $_iss; do
		_l=$_cand
		while [ -n "$_l" ]; do
			_lab=${_l%%.*}
			case "$_l" in
			*.*)	_l=${_l#*.} ;;
			*)	_l=
				if [ "$_lab" != "$_cand" ] && [ ${#_lab} -lt 4 ]; then
					continue
				fi ;;
			esac
			lower "$_lab"
			alnum_only "$_LOWER"
			_lab=$_AN
			[ ${#_lab} -lt 3 ] && continue
			case "$_flat" in
			*"$_lab"*)	_hit=$_cand; break ;;
			esac
		done
		[ -n "$_hit" ] && break
	done
	if [ -n "$_hit" ]; then
		good "the issuer served matches the CAA issue value $_hit"
	else
		subst "$_iss" "$NL" ' '
		bad "the issuer served matches no CAA issue value, $_SUB"
	fi
	return 0
}

chk_v6() {
	q_aaaa "$DOMAIN"
	_a6=${_Q%%"$NL"*}
	[ -z "$_a6" ] && { none "no AAAA record, nothing to reach over IPv6"; return 0; }
	q_a "$DOMAIN"
	_a4=${_Q%%"$NL"*}
	[ -z "$_a4" ] && { bad "AAAA published but no A record to compare it against"; return 0; }
	fetch_chain "$DOMAIN" "$_a4" "$WD.v4" || { bad "no certificate over IPv4 on $_a4:$CF_PORT"; return 0; }
	fetch_chain "$DOMAIN" "$_a6" "$WD.v6" || { bad "no certificate over IPv6 on $_a6/$CF_PORT"; return 0; }
	leaf_digest "$WD.v4.c01.pem"
	_h4=$_DIG
	leaf_digest "$WD.v6.c01.pem"
	_h6=$_DIG
	if [ -z "$_h6" ] || [ -z "$_h4" ]; then
		bad "a certificate could not be read on one of the two addresses"
	elif [ "$_h4" = "$_h6" ]; then
		good "$_a6 answers on $CF_PORT with the certificate of $_a4"
	else
		bad "$_a6 serves another certificate than $_a4, the v6 vhost is not the v4 one"
	fi
	return 0
}

chk_alias() {
	case $CF_ALIAS in
	*[!\ ]*)	;;
	*)		none "no alias configured"; return 0 ;;
	esac
	q_a "$DOMAIN"
	_ip=${_Q%%"$NL"*}
	[ -z "$_ip" ] && { bad "no A record, the certificate cannot be read"; return 0; }
	fetch_chain "$DOMAIN" "$_ip" "$WD" || { bad "no certificate served on $_ip:$CF_PORT"; return 0; }
	cert_names "$WD.c01.pem"
	_cov=$_COV
	for _a in $CF_ALIAS; do
		lower "$_a"
		if name_covered "$_LOWER" $_cov; then
			good "$_a covered by the certificate served on $DOMAIN"
		else
			bad "$_a covered by neither the CN nor a SAN served on $DOMAIN"
		fi
	done
	return 0
}

chk_ocsp() {
	[ "$CF_OCSP" = yes ] || { none "ocsp = no, stapling is not required here"; return 0; }
	q_a "$DOMAIN"
	_ip=${_Q%%"$NL"*}
	[ -z "$_ip" ] && { bad "no A record, $CF_PORT not reachable"; return 0; }
	left
	_t=$_LEFT
	[ "$_t" -eq 0 ] && { bad "check deadline reached before the handshake"; return 0; }
	# -status sends a certificate status request and prints the response of the server
	# [verifie:man.openbsd.org/openssl.1].
	cnx "$_ip" "$CF_PORT"
	print -r -- Q | hn-timeout "$_t" openssl s_client -status \
	    -connect "$_CNX" -servername "$DOMAIN" > "$WD.o" 2>/dev/null
	_o=$(< "$WD.o")
	[ -z "$_o" ] && { bad "no answer on $_ip:$CF_PORT"; return 0; }
	case $_o in
	*'OCSP Response Status: successful'*)
		good "an OCSP response is stapled to the handshake" ;;
	*'no response sent'*)
		bad "no OCSP response stapled, every visitor queries the authority instead" ;;
	*)	bad "no usable OCSP response in the handshake" ;;
	esac
	return 0
}

chk_hdr() {
	q_a "$DOMAIN"
	_ip=${_Q%%"$NL"*}
	[ -z "$_ip" ] && { soft "no A record, the headers cannot be read"; return 0; }
	head_tls "$DOMAIN" "$_ip" > "$WD.r"
	_r=$(< "$WD.r")
	[ -z "$_r" ] && { soft "no answer on $_ip:$CF_PORT, the headers cannot be read"; return 0; }
	# A header name at the start of a line, in any case: grep -qi "^<name>:".
	lower "$_r"
	_rl=$NL$_LOWER
	for _hh in X-Content-Type-Options Referrer-Policy Content-Security-Policy; do
		lower "$_hh:"
		case $_rl in
		*"$NL$_LOWER"*)	good "$_hh served" ;;
		*)		soft "$_hh absent, advice, not a failure" ;;
		esac
	done
	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.
# Same idiom as sysutils/probe-mailserver cipher_check, ported here for a listener that has no
# STARTTLS to worry about.
chk_ciphers() {
	[ -z "$CF_CIPHERS" ] && { none "ciphers not configured, the suite check is skipped"; return 0; }
	q_a "$DOMAIN"
	_ip=${_Q%%"$NL"*}
	[ -z "$_ip" ] && { bad "no A record, $CF_PORT not reachable"; return 0; }
	left
	_t=$_LEFT
	[ "$_t" -eq 0 ] && { bad "check deadline reached before the handshake"; return 0; }
	subst "$CF_CIPHERS" ':' ':!'
	_rej="ALL:!aNULL:!eNULL:!$_SUB"
	cnx "$_ip" "$CF_PORT"
	print -r -- Q | hn-timeout "$_t" openssl s_client -tls1_2 \
	    -connect "$_CNX" -servername "$DOMAIN" -cipher "$_rej" > "$WD.o" 2>/dev/null
	# The last field, blanks as separators, of the last "New, " or "<blanks>Cipher<blanks>:"
	# line: awk's $NF of those two rules.
	_neg=
	while IFS= read -r _x || [ -n "$_x" ]; do
		_m=0
		case $_x in
		'New, '*)	_m=1 ;;
		esac
		_y=${_x#"${_x%%[! ]*}"}
		case $_y in
		Cipher*)
			_y=${_y#Cipher}
			_y=${_y#"${_y%%[! ]*}"}
			case $_y in
			:*)	_m=1 ;;
			esac ;;
		esac
		if [ "$_m" -eq 1 ]; then
			_y=${_x%"${_x##*[!	 ]}"}
			_neg=${_y##*[	 ]}
		fi
	done < "$WD.o"
	case "$_neg" in
	''|0000|'(NONE)')	good "offers nothing outside the approved suites" ;;
	*)			bad "negotiates $_neg, outside the approved suites" ;;
	esac
	return 0
}

# ---- one check ----

DEBUG=${PROBE_WEBSERVER_DEBUG:-}

# 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. Its files are $TMPD/<check>.*, WD the prefix of the scratch ones.
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"
	# Above 2, a descriptor opened by exec is not passed to the commands run
	# [verifie:ksh.1, exec].
	exec 3>"$OUTF"
	WD="$TMPD/$1"
	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. The tree belongs to the parent: a 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. The configuration was resolved and
# validated by the parent, and comes in the environment [the export before the xargs call].
if [ "$RUNCHECK" -eq 1 ]; then
	case " $ALL_CHECKS " in
	*" $CHECKNAME "*)	;;
	*)			cannot "unknown check $CHECKNAME" ;;
	esac
	TMPD=${PROBE_WEBSERVER_TMPD:-}
	[ -d "$TMPD" ] || cannot "no result directory for check $CHECKNAME"
	[ -n "${CF_TIMEOUT:-}" ] && [ -n "$CR" ] \
	    || cannot "no configuration from the parent for check $CHECKNAME"
	run_one_check "$CHECKNAME"
	exit 0
fi

# ---- configuration, parent only ----

# Resolved only now: the per-domain path carries both arguments, and a name that is not a
# domain must never pick the file that gets read. Port 443, the default, keeps the plain
# <domain>.conf that every site already has -- unchanged behavior for the common case, no
# rename owed to the callers that never terminate anywhere else. Any other port gets its own
# file, since the domain alone would otherwise collide with the vhost's.
if [ -z "$CONF" ]; then
	if [ "$PORT" -eq 443 ] && [ -f "$CONFDIR/$DOMAIN.conf" ]; then
		CONF=$CONFDIR/$DOMAIN.conf
	elif [ -f "$CONFDIR/${DOMAIN}_${PORT}.conf" ]; then
		CONF=$CONFDIR/${DOMAIN}_${PORT}.conf
	else
		CONF=$CONFFILE
	fi
fi

for _t in host openssl nc hn-timeout xargs mktemp hexdump; do
	command -v "$_t" >/dev/null 2>&1 || cannot "missing tool $_t"
done

CF_ALIAS=
CF_PORT=$PORT		# overridable per file, but the CLI argument already agrees with the filename
CF_REDIRECT=yes
CF_HSTS=yes
CF_HSTS_MIN_AGE=15552000
CF_OCSP=no
CF_CERT_DAYS=14
# TLSv1.2 suites the server may negotiate, colon separated. Empty: the suite check is skipped.
CF_CIPHERS=
CF_CHECKS=all
CF_SKIP=
CF_TIMEOUT=15
CF_BUDGET=50

# 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
}

# 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
		alias)		CF_ALIAS="$CF_ALIAS $_v" ;;
		port)		CF_PORT=$_v ;;
		redirect)	CF_REDIRECT=$_v ;;
		hsts)		CF_HSTS=$_v ;;
		hsts_min_age)	CF_HSTS_MIN_AGE=$_v ;;
		ocsp)		CF_OCSP=$_v ;;
		cert_days)	CF_CERT_DAYS=$_v ;;
		ciphers)	CF_CIPHERS=$_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_PORT in
''|*[!0-9]*|0*)	cannot "port is not a positive integer" ;;
esac
[ "$CF_PORT" -le 65535 ] || cannot "port is above 65535"
case "$CF_REDIRECT" in yes|no) ;; *) cannot "redirect is neither yes nor no" ;; esac
case $CF_CERT_DAYS    in ''|*[!0-9]*) cannot "cert_days is not a number of days" ;; esac
case $CF_HSTS_MIN_AGE in ''|*[!0-9]*) cannot "hsts_min_age is not a number of seconds" ;; esac
case "$CF_HSTS" in yes|no) ;; *) cannot "hsts is neither yes nor no" ;; esac
case "$CF_OCSP" in yes|no) ;; *) cannot "ocsp is neither yes nor no" ;; esac
# A wildcard alias has no testable name of its own; only concrete names are accepted.
for _a in $CF_ALIAS; do
	case "$_a" in
	*[!A-Za-z0-9.-]*)	cannot "alias holds a character a domain name cannot" ;;
	esac
	domain_shape "$_a" || cannot "alias is not a domain name, $_a"
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 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

# ---- resolver preflight ----

# 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: 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"

TMPD=$(mktemp -d "${TMPDIR:-/tmp}/probe-webserver.XXXXXXXXXX") || cannot "mktemp failed"
# PROBE_WEBSERVER_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_WEBSERVER_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-webserver: debug tree kept in $TMPD" >&2' EXIT HUP INT TERM
elif [ -n "${PROBE_WEBSERVER_KEEP:-}" ]; then
	trap ':' EXIT HUP INT TERM
else
	trap 'rm -rf "$TMPD"' EXIT HUP INT TERM
fi

# ---- one A lookup, ahead of the fan-out ----

# Twelve of the thirteen checks call q_a "$DOMAIN" for the identical answer, each paying its
# own host+hn-timeout pair for it [docs/PROBES.md, the -C contract: a -C child runs alone, so
# nothing here can assume the fan-out happened -- q_a still works without this]. 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-webserver.sh, "the budget expires"]. CF_A_CACHE
# carries the raw multi-line answer q_a would have left in _Q, exported alongside the rest of the
# configuration below; empty is a real answer, no A record, not a failure to look up.
WD="$TMPD/_a_cache"
_predl=$CF_TIMEOUT
[ "$CF_BUDGET" -lt "$_predl" ] && _predl=$CF_BUDGET
DL=$(( ${SECONDS%%.*} + _predl ))
q_a "$DOMAIN"
CF_A_CACHE=$_Q

# ---- run ----

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

# 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]. timeout(1) 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.
#
# execve per target: 614 with the text handling in printf, grep, sed, tr, head, cat, mkdir and
# awk [mesure:web 2026-09-27, ktrace, hors-norme.fr: printf 263, grep 143, sed 42, tr 32,
# timeout 29, openssl 21, mkdir 19, host 17, head 13, ksh 13, cat 13, awk 7]. With it in the
# shell: hn-timeout, host, nc, openssl, hexdump, one mktemp, one rm and the 13 ksh, about 85
# [infere until a ktrace on web; the suite's clean domain counts 56 through PATH plus the 13
# ksh, test-probe-webserver.sh section 15].
PROBE_WEBSERVER_TMPD=$TMPD
PROBE_WEBSERVER_CR=$CR
export PROBE_WEBSERVER_TMPD PROBE_WEBSERVER_CR
# The children inherit the configuration resolved here instead of re-reading it: parsed and
# validated once. What a check reads is exported, and a key a check reads goes on this line.
export CF_ALIAS CF_PORT CF_REDIRECT CF_HSTS CF_HSTS_MIN_AGE CF_OCSP CF_CERT_DAYS CF_CIPHERS \
    CF_TIMEOUT CF_A_CACHE
# 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.
FIRED=0
print -rn -- "$_list" | hn-timeout "$CF_BUDGET" \
    xargs -n1 -P "$NCHK" /bin/ksh "$SELF" -C "$DOMAIN" 2>>"$TMPD/launch.err"
# 124 is what 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
		# Nothing was measured and nobody chose that: the budget expired or the check died.
		# Calling it a success would hand a green verdict for a test that never happened,
		# which is the one thing a probe must not do.
		_s=silent
		SILENT="$SILENT $_ck"
	fi
	if [ "$_s" = fail ]; then
		NKO=$((NKO + 1))
		KO="$KO $_ck"
	fi
done

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

# _JN: the names of $1, leading blanks off, each run of blanks turned into ", ".
join_names() {
	_jn_s=$1
	_jn_s=${_jn_s#"${_jn_s%%[! ]*}"}
	_JN=
	while :; do
		case $_jn_s in
		*" "*)	_JN=$_JN${_jn_s%%" "*}', '
			_jn_s=${_jn_s#*" "}
			_jn_s=${_jn_s#"${_jn_s%%[! ]*}"} ;;
		*)	_JN=$_JN$_jn_s
			return 0 ;;
		esac
	done
}

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
}

# _DET: the detail lines of a check joined by "; ".
detail_of() {
	_DET=
	[ -f "$TMPD/$1.txt" ] || return 0
	_do_sep=
	while IFS= read -r _do_l || [ -n "$_do_l" ]; do
		_DET=$_DET$_do_sep$_do_l
		_do_sep='; '
	done < "$TMPD/$1.txt"
}

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

# _JE: $1 as the inside of a JSON string, backslash and quote escaped, tab, newline and return
# turned into spaces.
jesc() {
	subst "$1" '\' '\\'
	subst "$_SUB" '"' '\"'
	subst "$_SUB" "$TAB" ' '
	subst "$_SUB" "$NL" ' '
	subst "$_SUB" "$CR" ' '
	_JE=$_SUB
}

# One block per check: the name padded to 7 like %-7s, the state, then the detail lines
# indented. A last line without its newline stays without one, as sed leaves it.
check_block() {
	_cb_n=$1
	while [ ${#_cb_n} -lt 7 ]; do
		_cb_n="$_cb_n "
	done
	status_of "$1"
	_OUT="$_OUT$_cb_n $_STAT$NL"
	[ -f "$TMPD/$1.txt" ] || return 0
	while :; do
		if IFS= read -r _cb_l; then
			_OUT="$_OUT    $_cb_l$NL"
		else
			[ -n "$_cb_l" ] && _OUT="$_OUT    $_cb_l"
			break
		fi
	done < "$TMPD/$1.txt"
}

# The document is built in _OUT and written once.
_OUT=
if [ "$FORMAT" = json ]; then
	_OUT="{\"domain\":\"$DOMAIN\""
	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.
	for _ck in $SEL; do
		check_block "$_ck"
	done
	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
		for _ck in $SEL; do
			check_block "$_ck"
		done
	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"
