#!/bin/ksh
#
# probe-openbsd [-q]
#
# One verdict for how far THIS host has drifted from OpenBSD: pending security patches, packages
# a mirror update would move, a release no longer served (pkg_add and syspatch about to break),
# a newer release available (upgrade to plan). Nothing is applied -- neither syspatch nor
# sysupgrade -n, which creates /bsd.upgrade and fires the upgrade at the NEXT reboot, whatever its
# motive. The decision to act stays human.
#
# Probe contract: silent and rc 0 when nothing drifted, one summary line on stderr and rc 1
# otherwise, detail on stdout. Needs root: syspatch -c and pkg_add -un both require it. No
# argument but -q, which drops the report of a sleeping in-house repository (PKG_PATH's first
# entry unreachable) without changing what runs -- the checks below still fall back to the public
# mirror.
#
# Verdict cache, three hours: a caller polling every few minutes should not pay three mirror
# round trips each time information that moves weekly is asked for. Absent = never checked, empty
# = nothing to report. Written only on a run where every check concluded -- one that could not
# measure says nothing about the host, and caching it would replay a transient mirror hiccup for
# the whole TTL.
#
# Tools: ksh, syspatch, pkg_add, ftp (OpenBSD base). Read-only, applies nothing.
#
# 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
# ksh imports the caller PATH over its own default and never puts it back [verifie:openbsd/src
# bin/ksh/main.c:220-226 for the default, :272-274 for the import]. doas builds the environment
# from the target's login.conf, so this is belt and braces.
# /usr/local/bin last, for hn-timeout alone (sysutils/hn-timeout): timeout(1) of base with its
# signals blocked before fork(), where base blocks them after and a command that exits in between
# leaves timeout asleep until its deadline. Last, so that no package shadows a base tool.
PATH=/bin:/sbin:/usr/bin:/usr/sbin:/usr/local/bin; export PATH

# Leading colon: getopts leaves the message to us. A doas rule naming this command names the
# option too [man.openbsd.org/doas.conf.5], so an option added here without the rule following is
# a probe the caller can no longer run.
QUIET_REPO=false
while getopts :qh _o; do
	case $_o in
	q)	QUIET_REPO=true ;;
	h)	echo "usage: ${0##*/} [-q]"
		echo "  -q  a sleeping in-house repo is not reported"
		exit 0 ;;
	*)	echo "${0##*/}: unknown option -$OPTARG" >&2; exit 2 ;;
	esac
done
shift $((OPTIND - 1))
[ $# -eq 0 ] || { echo "${0##*/}: no argument expected" >&2; exit 2; }

# Cache: three network round trips per pass outside it. Empty file = nothing to report. Absent =
# never checked.
STATE=/var/cache/probe-openbsd/verdict
TTL_H=3

# pkg_add -un under hn-timeout, seconds: 81 s on app (352 packages) against 18 s on web, so a 90 s
# cap left app a 9 s margin [mesure:2026-10-05, pkg_add -un, PKG_PATH of the fleet]. reed2 gives
# this probe more, mon_reed2_probe_timeout in infra.git roles/mon_reed2/defaults/main.yml.
PKG_ADD_S=210

# Full report on stdout, ONE summary line on stderr, the split every probe of this family holds:
# a caller reading only the first stderr line must never lose a finding because a later one
# carried a newline of its own. Shared by the fresh run and the cache replay below so neither
# path can drift onto that.
# $2, when given, replaces the summary line: a check that could not run is named there, since
# the stdout detail is not what the caller reads.
report() {
	# Normalized regardless of the caller's shape: the fresh-scan path passes $OUT with its
	# trailing newline (add() appends one per line), the cache-hit path passes "$(< "$STATE")",
	# whose command substitution strips ALL trailing newlines first -- printf '%s' would
	# otherwise reproduce whichever shape it got, so the two paths would emit different byte
	# streams for the same content. A command substitution strips trailing newlines the same
	# way regardless of how many the input carried, so routing $1 through one here (rather than
	# `${1%$'\n'}`, a ksh93/bash ANSI-C quoting form absent from OpenBSD's ksh(1)) normalizes
	# both paths in one POSIX-portable step.
	_body=$(printf '%s' "$1")
	printf '%s\n' "$_body"
	n=$(printf '%s\n' "$_body" | grep -c '')
	if [ -n "${2:-}" ]; then
		echo "$2" >&2
	else
		echo "$n OpenBSD drift line(s) found (detail on stdout)" >&2
	fi
}

now=$(date +%s)
# The read is held before the arithmetic: an empty operand is a FATAL error that kills this shell,
# and the probe then reads as silent rather than as failing.
_mt=$(stat -f %m "$STATE" 2>/dev/null)
case ${now}-${_mt} in
*[!0-9-]*|-*|*-)	_age_h=$TTL_H ;;
*)			_age_h=$(( (now - _mt) / 3600 )) ;;
esac
if [ -f "$STATE" ] && [ "$_age_h" -lt "$TTL_H" ]; then
	[ -s "$STATE" ] || exit 0
	report "$(< "$STATE")"
	exit 1
fi

OUT=""
add() { OUT="$OUT$1
"; }
# A check that could NOT run, as opposed to one that ran and found something. Reported the same
# way, cached differently -- see the write at the bottom.
FAILED=false
FIRST_FAIL=""
fail() {
	FAILED=true
	[ -n "$FIRST_FAIL" ] || FIRST_FAIL=$1
	add "$1"
}

# --- 1. security patches --------------------------------------------------------------------
# rc 0 whatever the patch count: the OUTPUT carries the info, one line per pending patch.
# Failure alerts, never stays silent: -c needs root, and a silent probe on a security check is
# worse than a false alert. stderr folded into $p = message.
if p=$(syspatch -c 2>&1); then
	if [ -n "$p" ]; then
		add "Security patches pending: $(echo $p)"
		add "  apply with:  syspatch"
	fi
else
	fail "syspatch -c failed, patches NOT checked: $(echo $p)"
fi

# --- 2. packages ------------------------------------------------------------------------------
# -n reports the steps, installs nothing [verifie:man.openbsd.org/pkg_add.1]. Needs root, like
# syspatch -c. hn-timeout: dependency resolution over every installed package has no bound of
# its own -- PKG_ADD_S, a caller polling this probe should size its own budget above that.
#
# A repository FIRST in PKG_PATH that has gone to sleep costs five retries -- 5, 10, 20, 40, 80
# then 160 s -- before pkg_add gives up on it and walks to the next entry
# [verifie:openbsd/src usr.bin/pkg_add/OpenBSD/PackageRepository.pm, try_until_success]; it only
# gives up early on a 550 or a 404, and a host that is off answers neither. That alone blows the
# PKG_ADD_S cap below, so the first entry is reached once, 2 s, and dropped from PKG_PATH when it does
# not answer -- the packages of the fleet still come from the rest of the list, and what this
# check measures is the drift from OpenBSD, not the health of one repository.
# `%:*`, the SHORTEST match from the end, not `%%:*`: the separator of the list is a colon and so
# is the one of `https://`, so the longest match would cut the url down to `https`.
# PKG_PATH absent is no error under set -u: the doas rule of the fleet sets it only when an
# in-house repository is configured, and a hand run through a relative `doas probe-openbsd`
# matches no setenv rule at all, doas comparing a rule's cmd to argv[0] as typed
# [verifie:openbsd/src usr.bin/doas/doas.c, permit(), strcmp(r->cmd, cmd)]. Nothing to reach
# then, nothing dropped: pkg_add reads the mirror alone.
repo=${PKG_PATH:-}
repo=${repo%:*}
case $repo in
https://*|http://*)
	# -w 2: "wait for seconds for the remote server to connect before giving up"
	# [verifie:man.openbsd.org/ftp.1]. A host that is off must not cost more than that.
	if ! ftp -w 2 -o /dev/null "$repo" >/dev/null 2>&1; then
		PKG_PATH=${PKG_PATH##*:}
		export PKG_PATH
		# The drop happens whatever -q says: what the option silences is the report, never
		# the fallback, and the checks below still run against the rest of PKG_PATH.
		$QUIET_REPO || add "In-house repo $repo is not answering -- checked against the rest of PKG_PATH only."
	fi
	;;
esac

pk=$(hn-timeout "$PKG_ADD_S" pkg_add -un 2>&1); _rc=$?
if [ "$_rc" -eq 0 ]; then
	# quirks is fetched first on EVERY run [verifie:man.openbsd.org/pkg_add.1]: its signature
	# line is noise, not a pending update.
	#
	# So is the report of a dependency loop. pkg_add says "Detected loop, merging sets" and then
	# lists the members of the merged set, one per line, prefixed by a bar -- the only place
	# that prefix is written [verifie:openbsd/src usr.sbin/pkg_add/OpenBSD/Dependencies.pm:
	# 155-162]. Each line reads name-version->version, and the two versions are the SAME:
	# `print` of an update set joins the old full name to what is left of the new one when both
	# share a stem [same tree, OpenBSD/UpdateSet.pm:370-392]. Nothing there is an update.
	pk=$(printf '%s\n' "$pk" | grep -vE 'quirks|signed on' |
	    grep -v '^Detected loop, merging sets' | grep -v '^| ')
	[ -n "$pk" ] && add "Packages to update: $(echo $pk)"
elif [ "$_rc" -eq 124 ]; then
	# hn-timeout exits 124 when its first signal ended the command [verifie:man.openbsd.org/timeout.1].
	fail "pkg_add -un timed out after $PKG_ADD_S s, packages NOT checked"
else
	# pkg_add exits 1 when an installed package has no candidate in PKG_PATH, after saying
	# "Couldn't find updates for <names>", or "Couldn't find any update" when it did nothing else
	# [verifie:openbsd/src usr.sbin/pkg_add/OpenBSD/PkgAdd.pm, inform_user_of_problems and
	# report_cantupdate; OpenBSD/AddDelete.pm, exit_code]. Without PKG_PATH it searches the
	# mirror alone, where no package of the in-house repository is: that complaint and nothing
	# else is a probe called outside the doas rule that sets PKG_PATH -- a relative `doas
	# probe-openbsd`, which matches no rule naming the absolute path (above). Said as such, with
	# the call that gets PKG_PATH, instead of the hundred names pkg_add lists. Anything else in
	# the output -- an error of its own, PKG_PATH set -- keeps the message as pkg_add wrote it.
	_names=$(printf '%s\n' "$pk" | sed -n "s/^Couldn't find updates for //p")
	_any=$(printf '%s\n' "$pk" | grep -c "^Couldn't find any update")
	_other=$(printf '%s\n' "$pk" | grep -vE 'quirks|signed on' |
	    grep -v '^Detected loop, merging sets' | grep -v '^| ' | grep -v -- '->' |
	    grep -v "^Couldn't find updates for " | grep -v "^Couldn't find any update" | grep .)
	if [ -z "${PKG_PATH:-}" ] && [ "$_rc" -eq 1 ] && [ -z "$_other" ] &&
	    { [ -n "$_names" ] || [ "$_any" -gt 0 ]; }; then
		_n=$(printf '%s\n' $_names | grep -c .)
		_what="installed packages"
		[ "$_n" -eq 0 ] || _what="$_n installed package(s)"
		fail "pkg_add -un failed (rc 1), packages NOT checked: PKG_PATH not set, $_what looked for on the OpenBSD mirror alone and not found -- run doas /usr/local/sbin/probe-openbsd, whose doas rule sets PKG_PATH"
	else
		fail "pkg_add -un failed (rc $_rc), packages NOT checked: $(echo $pk)"
	fi
fi

# --- 3 and 4. versions -----------------------------------------------------------------------
# Mirror of /etc/installurl. `ftp -o /dev/null` fails on a missing path. Two witnesses, one per
# question. Installed release still served: its package index, $v/packages/$a/SHA256, the path
# pkg_add walks -- gone, pkg_add and syspatch break. Next release out: the signify signature of
# its SETS, $n/$a/SHA256.sig, the file sysupgrade itself fetches to decide that a release exists
# [verifie:openbsd/src usr.sbin/sysupgrade/sysupgrade.sh:132,146]. NOT its package tree: the
# mirror serves $n/packages/$a/, SHA256 and SHA256.sig included, before the release is cut --
# 8.0/packages/amd64/ signed while 8.0/amd64/SHA256.sig answered 404 [constate:2026-10-11,
# cdn.openbsd.org]. A probe reading the packages would announce the release before OpenBSD does.
# BOTH fetches before concluding: one failure alone cannot tell a withdrawn path from a dead
# network. Next version answering proves the network. Both mute -> say so, claim nothing.
# Readability tested by an ACTUAL open rather than `test -r`, which calls access(2), where "the
# real user ID is used in place of the effective user ID" [verifie:man.openbsd.org/access.2] --
# under doas the real uid is the caller's, not root's. The subshell keeps a redirection error
# local: `:` is a POSIX special built-in, and a redirection error on one of those ends the shell.
if ( : < /etc/installurl ) 2>/dev/null; then
	u=$(cat /etc/installurl); v=$(uname -r); a=$(machine)
	# OpenBSD goes from x.9 to (x+1).0 (6.9 -> 7.0).
	M=${v%%.*}; m=${v##*.}
	[ "$m" -ge 9 ] && n="$((M + 1)).0" || n="$M.$((m + 1))"

	cur=false; next=false
	ftp -o /dev/null "$u/$v/packages/$a/SHA256" >/dev/null 2>&1 && cur=true
	ftp -o /dev/null "$u/$n/$a/SHA256.sig" >/dev/null 2>&1 && next=true

	if $next; then
		add "OpenBSD $n is available (installed: $v) -- plan a sysupgrade."
		$cur || add "OpenBSD $v is NO LONGER served by $u -- pkg_add and syspatch will break."
	elif ! $cur; then
		fail "mirror $u unreachable, version NOT checked: neither $v nor $n answers"
	fi
else
	fail "/etc/installurl unreadable, version NOT checked"
fi

# Empty verdict cached too. Write needs root; failure = no cache, not an error.
# A measurement that could not run is NOT cached: it says nothing about the host, and holding it
# for TTL_H would replay one transient failure for hours. A caller expecting two consecutive
# failures before alerting reads a cached one as both, one mirror hiccup then reading as a
# confirmed fault. Left uncached, the next pass measures again and gets its own answer.
if ! $FAILED; then
	(umask 022; mkdir -p "${STATE%/*}"; printf '%s' "$OUT" > "$STATE") 2>/dev/null || true
fi

[ -n "$OUT" ] || exit 0
if $FAILED; then
	report "$OUT" "check could not run: $FIRST_FAIL"
else
	report "$OUT"
fi
exit 1
