#!/bin/sh
# ogmaprotect-setup -- OgmaProtect firstboot + convergence provisioner (Phase 10.E1 S1).
#
# The runtime provisioning that turns installed binaries into a working HTTPS login
# used to live ONLY inside scripts/deploy.py (a dev-host SSH tool the customer never
# runs -- audit finding L6-02). This is the extracted, on-box provisioner. It is one
# script with TWO loops (contract docs/PHASE10-E1-INSTALLABLE-PRODUCT.md sec 2.4/3.2):
#
#   loop 1 -- one-time SECRET-GEN (idempotent by per-resource EXISTENCE, not a flag
#             file): the self-signed TLS cert (CN-only for S1; IP-SAN is S3) and the
#             first admin + RBAC grant (gated on authd's authoritative "zero enabled
#             admins" signal -- never a deletable sentinel).
#   loop 2 -- every-run CONVERGENCE (level-triggered): CGI.pm, the httpd/slowcgi
#             chroot, /etc/httpd.conf, slowcgi -t 180, the newsyslog fragment. Re-run
#             each boot so a sysmerge-reverted httpd.conf / syspatch-updated libc
#             actually reaches the box.
#
# Trigger is per-SKU: the rc.d one-shot etc/rc.d/ogmaprotect_setup runs it at boot
# (Lite: `rcctl enable ogmaprotect_setup`; appliance: seeded rc.firsttime); deploy.py
# invokes it over SSH; a fresh-VM CI leg invokes it directly.
#
# SCOPE (S1+S2): faithful extraction of deploy.py's provisioning + existence/count
# idempotency + the save-list seam (S1), plus httpd-LAST boot ordering (this is the
# SOLE, LAST starter of httpd/slowcgi; httpd is never `rcctl enable`d) and a brownfield
# fail-closed pf ruleset loaded before httpd binds (S2). NOT here: IP-SAN cert,
# umask-077, force-change credential model, the must_change authd op (all S3); the
# per-domain pf adoption matrix (import/gate/mgmt-source) + INSTALL doc (S4).
#
# POSIX sh (dash/pdksh/oksh safe): no bashisms, no brace expansion, no [[ ]], no
# arrays, no process substitution. Runs as ROOT.
#
# Usage: ogmaprotect-setup [bootstrap|all|secrets|converge|webplane]  (default: all)
#   bootstrap  the OPERATOR's first-install command. `rcctl enable`s + starts the
#              daemons in the inventory's boot order (provisioner last), then runs
#              `all`. This is the one command between `pkg_add` and an HTTPS login;
#              pkg_add itself still enables and starts nothing.
#   all      loop 1 then loop 2 (rc.d one-shot/deploy.py/vmactions/firstboot).
#   secrets  loop 1 only.
#   converge loop 2 only ("point deploy.py configure at loop 2" made explicit).
#   webplane internal: only the httpd-LAST web-plane start (fail-closed pf + admin-gated
#            httpd). A valid manual re-run after creating the admin post-provision.
#   relcheck the post-install RELEASE-MATCH self-check (10.E6 VD-E6-22): compares the
#            installed package's `built_for_openbsd` (/etc/ogmaprotect/RELEASE-INFO)
#            with `uname -r` and prints match|mismatch|unknown. Exit 0 match, 1 MISMATCH,
#            2 cannot tell. `bootstrap` runs it FIRST and refuses on anything but a
#            match: a cross-release package installs and verifies cleanly and then every
#            daemon dies with `can't load library` -- enabling it at boot would only
#            record a dead boot order. No daemon can run this check (the daemon is what
#            cannot start), so it lives here and in `ogmaprotectctl status`.
#
# Env knobs (deploy.py/CI inject; firstboot uses the defaults; S3/S4 refine):
#   OGMA_ADMIN_PASS  injected first-admin password. Empty => admin seed DEFERRED
#                    (S3 adds the console-minted random password). The lab/CI inject
#                    a known value (exempt from the sec 5.2 uniqueness rule per 5.4).
#   OGMA_CERT_CN     cert subject CN. Empty => derived from the primary IPv4.
#   OGMA_SRC_DIR     dir holding the config TEMPLATES (needs $OGMA_SRC_DIR/etc/
#                    httpd.conf). deploy.py passes the uploaded tree; CI passes the
#                    checkout. (S2 stages templates under /etc/examples/ogmaprotect
#                    and drops this knob.)
#   OGMA_AUTHD_SOCK  authd control socket (default the canonical path). A TEST SEAM
#                    (overridden by t/ogmaprotect_setup.t to a fake socket) -- it is
#                    NOT a persist-relocation knob (the save-list mechanism is the
#                    image layer, out of product scope; contract sec 3.3b/D4).
#   OGMA_CRT / OGMA_KEY / OGMA_KEYDIR / OGMA_HTTPD_CONF
#                    canonical target paths; overridable TEST SEAMS only (same role as
#                    the Makefile's -DOGMA_STAGING_DIR=... unit-test overrides).
#   OGMA_RELEASE_INFO / OGMA_UNAME_R
#                    TEST SEAMS for relcheck (the same names scripts/check-release-match.sh
#                    honours): the RELEASE-INFO path and the release to compare against.
#                    Never set by a real caller.
#   OGMA_SETUP_PF    1 (default) loads the fail-closed pf live; 0 validates only (no
#                    live pfctl -f/-e). deploy.py sets 0 AND drops a persist sentinel
#                    so a shared-lab REBOOT never severs the router with a pf reload.
#   OGMA_PFCTL / OGMA_BOOT_PF / OGMA_PF_CONF / OGMA_PF_MANAGED
#                    the pfctl(8) binary + the bootstrap ruleset + the pf envelope +
#                    the managed-block paths. HERMETIC-TEST SEAMS ONLY -- OGMA_PFCTL
#                    names a root-run executable and OGMA_BOOT_PF a ruleset fed to it,
#                    so these must NEVER be sourced from ogmaprotect.conf/an untrusted
#                    file (D7/S4 must exclude them), the same rule as OGMA_AUTHD_SOCK.
#
# The save-list (persist vs volatile enumeration; contract sec 3.3a) is a companion
# DATA file scripts/ogma-savelist.conf -- parsed, never sourced.

set -u   # every knob is defaulted below, so -u is safe (and catches typos). NOT -e:
         # loop 2 is best-effort/level-triggered and several steps are legitimately
         # non-zero (perl -MCGI absent, pkg_add offline, rcctl get) -- criticals are
         # checked explicitly with `|| die`.

# --- knob defaults (MUST precede any use under set -u) -----------------------------
: "${OGMA_ADMIN_PASS:=}"
: "${OGMA_CERT_CN:=}"
: "${OGMA_SRC_DIR:=}"
: "${OGMA_AUTHD_SOCK:=/var/www/run/ogmaprotect.sock}"
# OGMA_CRT/KEY/KEYDIR/HTTPD_CONF are the canonical TARGET paths. They are overridable
# ONLY as hermetic-test WRITE seams (t/ogmaprotect_setup.t points them at a tmp dir),
# the same role as the Makefile's -DOGMA_STAGING_DIR unit-test overrides. They are
# NOT a persist-relocation knob and MUST NOT be used for a relocation proof: httpd and
# the daemons read these paths at compile-time constants, so only the image-layer
# directory symlink (the vmactions /cfg proof) actually relocates read+write.
: "${OGMA_CRT:=/etc/ssl/ogmaprotect.crt}"
: "${OGMA_KEY:=/etc/ssl/private/ogmaprotect.key}"
: "${OGMA_KEYDIR:=/etc/ssl/private}"
: "${OGMA_HTTPD_CONF:=/etc/httpd.conf}"

# S2 fail-closed-pf seams. OGMA_SETUP_PF gates the LIVE load (validate-only when 0 or
# when the sentinel exists) so a routine lab configure/reboot never severs the shared
# router; the rest are hermetic-test EXEC/WRITE seams (see the header -- never source
# from an untrusted file: OGMA_PFCTL is a root-run executable).
: "${OGMA_SETUP_PF:=1}"
: "${OGMA_SETUP_PF_SENTINEL:=/var/db/ogmaprotect/.setup-no-live-pf}"
: "${OGMA_PFCTL:=pfctl}"
: "${OGMA_BOOT_PF:=/etc/examples/ogmaprotect/pf.boot.conf}"
: "${OGMA_PF_CONF:=/etc/pf.conf}"
: "${OGMA_PF_MANAGED:=/etc/pf.conf.ogmaprotect}"
# S3 console-mint WRITE seam. The firstboot-minted one-time admin password is written
# HERE and NOWHERE else (never stdout/syslog/a file/argv). /dev/console on a real box;
# t/ogmaprotect_setup.t points it at a temp file to assert console-only delivery.
: "${OGMA_CONSOLE:=/dev/console}"

# S4 D7 knob file: the OPERATOR-facing firstboot config, parsed as DATA (never
# sourced). Installed at /etc/ogmaprotect/ogmaprotect.conf (ETCDIR, Makefile:485).
# Overridable as a hermetic-test READ seam only. See read_conf_knobs below for the
# strict whitelist (S4 ships exactly one knob: pf_adopt); the exec/write seams
# (OGMA_PFCTL/OGMA_BOOT_PF/OGMA_AUTHD_SOCK/OGMA_CRT/...) are NEVER read from it.
: "${OGMA_CONF:=/etc/ogmaprotect/ogmaprotect.conf}"
# The installed projection of the daemon inventory (daemon/common/daemon_inventory.c
# -> scripts/daemon_inventory.gen -> /etc/examples/ogmaprotect/daemon_inventory). The
# `bootstrap` mode reads the boot ORDER from it rather than carrying a copy: the order
# is a correctness property (netd exposes the control socket authd hard-requires, so a
# wrong order leaves the management plane dead after every reboot), and a hand-kept
# 16th copy of it here is exactly the drift L1-08/L1-22 exist to prevent. Overridable
# as a hermetic-test READ seam only.
: "${OGMA_INVENTORY:=/etc/examples/ogmaprotect/daemon_inventory}"
# 10.E9 S1 (VD-E9-2) fsck-rc-patch re-apply seams: the applier binary and the
# operator-intent marker ogma-fsck-rc-apply writes at `enable` and clears at
# `disable` (under /etc/ogmaprotect, which no etcXX.tgz set touches, so it
# SURVIVES a sysupgrade -- that survival is the whole point). HERMETIC-TEST
# SEAMS ONLY: OGMA_FSCK_APPLY names a root-run executable, so the OGMA_PFCTL
# rule applies -- never sourced from ogmaprotect.conf/an untrusted file.
: "${OGMA_FSCK_APPLY:=/usr/local/sbin/ogma-fsck-rc-apply}"
: "${OGMA_FSCK_INTENT:=/etc/ogmaprotect/fsck-autorepair.enabled}"
# pf_adopt policy (none|import); the ENV knob OGMA_PF_ADOPT wins over the file
# (harness/CI injection precedence). _ENV_PF_ADOPT records whether the env set it,
# so read_conf_knobs knows not to override. CONF_PF_ADOPT is the resolved value,
# defaulted here so a read is safe under set -u even when the file is absent.
: "${OGMA_PF_ADOPT:=}"
_ENV_PF_ADOPT="$OGMA_PF_ADOPT"
CONF_PF_ADOPT="${OGMA_PF_ADOPT:-none}"

# The injected admin password arrives in the ENVIRONMENT (deploy.py `VAR=val cmd`) and
# would otherwise be inherited by every child of this run -- openssl, pkg_add's
# third-party +INSTALL scripts, cpan, and the rcctl-restarted long-lived httpd/slowcgi
# (whose env is readable by root via `ps -Aww e`). Capture it once, then scrub it from
# the environment before any child runs; the seed path uses the shell-local copy and
# pipes it to ogmaprotectctl's stdin (never argv).
_ADMIN_PASS="$OGMA_ADMIN_PASS"
unset OGMA_ADMIN_PASS

note() { echo "ogmaprotect-setup: $*"; }
warn() { echo "ogmaprotect-setup: WARN: $*" >&2; }
die()  { echo "ogmaprotect-setup: ERROR: $*" >&2; exit 1; }

# ===================================================================================
# S4 D7 -- the operator config-knob reader (whitelist, DATA-parsed, never sourced)
# ===================================================================================
#
# Read /etc/ogmaprotect/ogmaprotect.conf as key=value DATA. This file steers ONLY
# this provisioner (no daemon reads it). SECURITY (review S4-SEC-11):
#   - Ignored unless a REGULAR file (a symlink/FIFO/dir is refused + warned) owned
#     by root (uid 0) and NOT group/world-writable -- else a perm-drifted /etc lets
#     a non-root user steer root's firstboot behaviour.
#   - Parsed with awk, NEVER sourced -- a `pf_adopt=import; rm -rf /` line yields the
#     literal value "import; rm -rf /", which fails the strict enum below and is
#     never executed. No shell metacharacter is ever evaluated.
#   - WHITELIST: exactly one key, `pf_adopt` (none|import). Unknown keys warn once.
#   - The exec/write seams (OGMA_PFCTL, OGMA_BOOT_PF, OGMA_AUTHD_SOCK, OGMA_CRT,
#     OGMA_KEY, OGMA_SETUP_PF, ...) are DELIBERATELY NOT sourced from here -- they
#     name root-run executables / target paths, so a file-driven override would be
#     arbitrary-root. They stay environment-only (the header's own rule).
#   - Env wins over file: a value already set in the environment is NOT overridden.
read_conf_knobs() {
	[ -f "$OGMA_CONF" ] || return 0
	# Reject a non-regular file or unsafe ownership/permissions.
	if [ -h "$OGMA_CONF" ]; then
		warn "$OGMA_CONF is a symlink; ignoring it (config knobs must be a regular root-owned file)"
		return 0
	fi
	# stat(1): BSD (OpenBSD, the product target) then a GNU fallback so the
	# hermetic t/ test also runs on a Linux CI leg. "<owner> <octal-perm>".
	_st=$(stat -f '%Su %Lp' "$OGMA_CONF" 2>/dev/null) ||
		_st=$(stat -c '%U %a' "$OGMA_CONF" 2>/dev/null)
	_owner=${_st%% *}
	_perm=${_st##* }
	# The file must be owned by the user RUNNING the provisioner (root in
	# production; the unprivileged test user under t/). A file owned by anyone
	# else is a lower-trust write surface -> ignore it. `id -un` is root on a
	# real box, so this is the strict "root-owned /etc file" check in the field.
	_self=$(id -un 2>/dev/null)
	if [ -z "$_owner" ] || [ "$_owner" != "${_self:-root}" ]; then
		warn "$OGMA_CONF is not owned by the provisioner user ($_owner != ${_self:-root}); ignoring it"
		return 0
	fi
	# The last two octal digits are GROUP and OTHER perms; the write bit (2) is set
	# in {2,3,6,7}. Match a writable GROUP digit (2nd-from-last) OR OTHER digit (last)
	# so 0664 (group-writable) is caught, not just 0646/0666 (world-writable).
	case "$_perm" in
	*[2367][0-7] | *[2367])
		warn "$OGMA_CONF is group/world-writable ($_perm); ignoring it"; return 0 ;;
	esac
	# Parse whitelisted keys. awk prints `key<TAB>value` for recognised keys only;
	# a value with any character outside the strict enum charset is dropped by the
	# case guards below (defence in depth on top of awk's field split).
	_kv=$(awk -F= '
		/^[ \t]*#/ { next }
		/^[ \t]*$/ { next }
		{
			key=$1; sub(/^[ \t]+/, "", key); sub(/[ \t]+$/, "", key)
			val=$2; sub(/^[ \t]+/, "", val); sub(/[ \t]+$/, "", val)
			if (key == "pf_adopt")      print "pf_adopt\t" val
			else                        print "?\t" key
		}
	' "$OGMA_CONF" 2>/dev/null)
	# Iterate the recognised lines. IFS newline so a value never word-splits.
	_oldifs="$IFS"; IFS='
'
	for _line in $_kv; do
		_k=${_line%%	*}
		_v=${_line#*	}
		case "$_k" in
		pf_adopt)
			case "$_v" in
			none|import) [ -n "${_ENV_PF_ADOPT:-}" ] || CONF_PF_ADOPT="$_v" ;;
			*) warn "ignoring invalid pf_adopt='$_v' in $OGMA_CONF (want none|import)" ;;
			esac ;;
		'?')
			warn "ignoring unknown key '$_v' in $OGMA_CONF" ;;
		esac
	done
	IFS="$_oldifs"
}

# ===================================================================================
# authd helpers
# ===================================================================================

# Send a JSON request to the authd control socket; print authd's one-line COMPACT
# wire response (empty on any failure). Mirrors deploy.py's _OP_CALL. Only NON-secret
# ops (ping/db_status) go through here -- the admin password never touches argv (it is
# piped to ogmaprotectctl's stdin, below). perl(1) is OpenBSD base.
#
# The socket path is passed to perl as ARGV, NOT via the environment: a
# `: "${OGMA_AUTHD_SOCK:=...}"` default is a shell var that is NOT exported, so a perl
# child reading $ENV{OGMA_AUTHD_SOCK} would see undef on every real (non-test) call.
# argv also keeps the path out of every other child's environment.
ogma_authd_op() {
	perl -MIO::Socket::UNIX -e '
		my ($sock, $j) = @ARGV;
		my $s = IO::Socket::UNIX->new(Type => SOCK_STREAM, Peer => $sock) or exit 3;
		print $s $j, "\n"; my $l = <$s>; chomp $l if defined $l;
		print $l if defined $l;
	' "$OGMA_AUTHD_SOCK" "$1" 2>/dev/null
}

# ===================================================================================
# loop 1 -- one-time secret generation (existence/count gated)
# ===================================================================================

# Derive a default cert CN = the box's primary IPv4 (default-route iface, else first
# non-loopback inet, else the hostname).
default_cn() {
	_if=$(route -n get default 2>/dev/null | awk '/interface:/{print $2; exit}')
	if [ -n "${_if:-}" ]; then
		_ip=$(ifconfig "$_if" 2>/dev/null | awk '/[ \t]inet /{print $2; exit}')
		[ -n "${_ip:-}" ] && { echo "$_ip"; return 0; }
	fi
	_ip=$(ifconfig 2>/dev/null | awk '/[ \t]inet /&&$2!="127.0.0.1"{print $2; exit}')
	[ -n "${_ip:-}" ] && { echo "$_ip"; return 0; }
	hostname 2>/dev/null || echo localhost
}

# Strict dotted-quad IPv4 validator (subshell-isolated IFS). Every SAN candidate is
# validated through this BEFORE it enters the openssl cnf, mirroring sysd/cert.c's
# check_cn_san so a malformed/hostile `ifconfig` line can never inject a second cnf
# directive (no newline/'='/'#' survives — the charset here is [0-9.] only).
is_ipv4() (
	case "$1" in
	''|.*|*.|*..*|*[!0-9.]*) exit 1 ;;
	esac
	_n=0; IFS=.
	for _o in $1; do
		_n=$((_n + 1))
		[ "$_o" -le 255 ] 2>/dev/null || exit 1
	done
	[ "$_n" -eq 4 ]
)

# Enumerate the box's primary IPv4 address(es) as strictly-validated openssl IP SAN
# entries ("IP:a.b.c.d,IP:..."). Default-route interface first (its inet primary + any
# aliases), else every non-loopback inet. Each candidate is validated (is_ipv4) and
# deduped; capped at 16 (parity with OGMA_MAX_CERT_SAN / sysd/cert.c). Falls back to
# IP:127.0.0.1 so the cert ALWAYS carries >=1 SAN and is browser-loadable even before
# DHCP / on CI. IPv4-ONLY: an ifconfig inet6 link-local carries a "%zone" suffix that
# openssl rejects as an IP SAN, which would DoS firstboot cert-gen; a name SAN is added
# later by the C13 identity domain when the box is named.
default_sans() {
	_cands=""
	_if=$(route -n get default 2>/dev/null | awk '/interface:/{print $2; exit}')
	[ -n "${_if:-}" ] && _cands=$(ifconfig "$_if" 2>/dev/null | awk '/[ \t]inet /{print $2}')
	[ -n "$_cands" ] || _cands=$(ifconfig 2>/dev/null | awk '/[ \t]inet /&&$2!="127.0.0.1"{print $2}')
	_sans=""; _n=0
	for _a in $_cands; do
		is_ipv4 "$_a" || continue
		case ",$_sans," in *",IP:$_a,"*) continue ;; esac
		_sans="${_sans:+$_sans,}IP:$_a"
		_n=$((_n + 1))
		[ "$_n" -ge 16 ] && break
	done
	[ -n "$_sans" ] || _sans="IP:127.0.0.1"
	echo "$_sans"
}

# Self-signed TLS cert + key with a REAL IP subjectAltName (so https://<ip> loads --
# CN-only certs are browser-rejected). Existence-gated on BOTH files (a partial write --
# crt present, key missing -- must regenerate, or httpd -n later fails). Written to temp
# files + atomic mv so an interrupted openssl never leaves a half-written pair that reads
# as "exists". The cnf + openssl run under `umask 077` so the -keyout temp is born 0600
# (closes the M13 born-0644 window that the keydir-0700 assertion only masked). The cnf
# (config-file SAN, mirroring daemon/sysd/cert.c's leaf server profile -- NOT -addext,
# which needs a LibreSSL version gate) is removed on every path; the public cert is
# chmod 644 (umask 077 would otherwise leave it 0600, EPERM'ing a non-root reader).
gen_cert() {
	if [ -f "$OGMA_CRT" ] && [ -f "$OGMA_KEY" ]; then
		note "TLS cert+key already present ($OGMA_CRT); skip"
		return 0
	fi
	_cn="$OGMA_CERT_CN"
	[ -n "$_cn" ] || _cn=$(default_cn)
	_sans=$(default_sans)
	# The CN enters a cnf directive line; strip anything outside the hostname/IP
	# charset so it cannot inject a second directive (defense in depth -- deploy sets
	# it to an IP). If it strips empty, fall back to the first SAN IP.
	_cn=$(printf '%s' "$_cn" | tr -cd 'A-Za-z0-9.-')
	[ -n "$_cn" ] || _cn=$(printf '%s' "$_sans" | sed 's/^IP://; s/,.*//')
	[ -n "$_cn" ] || die "cannot determine a cert CN (set OGMA_CERT_CN)"
	[ -d "$OGMA_KEYDIR" ] || mkdir -p "$OGMA_KEYDIR" || die "mkdir $OGMA_KEYDIR"
	chmod 700 "$OGMA_KEYDIR" || warn "could not chmod 700 $OGMA_KEYDIR"
	note "generating self-signed TLS cert (CN=$_cn, SAN=$_sans)"
	_tcrt="$OGMA_CRT.tmp.$$"
	_tkey="$OGMA_KEY.tmp.$$"
	_tcnf="$OGMA_KEYDIR/.ogma-cert.$$.cnf"
	# umask 077 wraps BOTH the cnf write and openssl so every temp is born 0600.
	( umask 077
	  cat > "$_tcnf" <<EOF
[req]
distinguished_name = dn
req_extensions = v3
x509_extensions = v3
prompt = no
[dn]
CN = $_cn
[v3]
basicConstraints = CA:FALSE
keyUsage = digitalSignature, keyEncipherment
extendedKeyUsage = serverAuth
subjectAltName = $_sans
EOF
	  openssl req -new -newkey rsa:2048 -days 3650 -nodes -x509 \
		-config "$_tcnf" \
		-keyout "$_tkey" -out "$_tcrt" >/dev/null 2>&1 )
	_rc=$?
	rm -f "$_tcnf"
	if [ "$_rc" -ne 0 ]; then
		rm -f "$_tcrt" "$_tkey"
		die "openssl cert generation failed"
	fi
	chmod 600 "$_tkey" || { rm -f "$_tcrt" "$_tkey"; die "chmod key"; }
	chmod 644 "$_tcrt" || warn "could not chmod 644 $OGMA_CRT"
	# key first: a crash between the two mv's then leaves key-present/crt-absent,
	# which the both-file existence gate (above) regenerates on the next run.
	mv "$_tkey" "$OGMA_KEY" && mv "$_tcrt" "$OGMA_CRT" || {
		rm -f "$_tcrt" "$_tkey"; die "installing cert/key"; }
	note "TLS cert generated (IP-SAN)"
}

# True (0) iff authd reports zero enabled admins. Reads the db_status op's auth_empty
# field, emitted ONLY to a uid-0 socket peer as authd's COMPACT wire ("auth_empty":
# true, no spaces) and fail-closed: an unreadable users.db yields count -1 => auth_
# empty:false => we never seed/clobber. $1 = the db_status response (so the caller can
# reuse one round-trip for the reachability check).
auth_is_empty() { echo "$1" | grep -q '"auth_empty":true'; }

# Mint a strong random one-time password. FAIL-CLOSED: a pipeline's exit status is the
# LAST command's, so `openssl rand ... | tr` would mask a failed/killed openssl (tr
# exits 0 on empty input) and hand back an empty password. Capture openssl's status
# directly and only then strip the trailing newline. 18 bytes base64 = 24 chars (144
# bits); the caller asserts a minimum length as a second belt.
gen_random_pw() {
	_p=$(openssl rand -base64 18) || return 1
	printf '%s' "$_p" | tr -d '\n'
}

# Seed the first admin + RBAC grant, once, when the box has zero admins.
#
# 10.E1 S3 credential model: with NO injected password (a real firstboot), MINT a
# random one-time password, force-change-flag it, and print it to /dev/console ONLY
# (never stdout/syslog/a file/argv). With an injected OGMA_ADMIN_PASS (deploy.py/lab/
# CI), use it verbatim and DO NOT set must_change -- that path is exempt (D3).
#
# REACHABILITY is checked first: a fresh box whose authd never came up must HARD-FAIL,
# not silently log "already provisioned" (auth_empty is false both when an admin exists
# AND when authd/DB is unreachable). The password is piped to ogmaprotectctl's stdin
# (prompt_pass) so it never appears in argv/ps. ogmaprotectctl prints PRETTY JSON
# ("ok" : true) and exits 0 regardless of ok, so we parse the body whitespace-
# tolerantly, not $?. role_grant is idempotent (INSERT OR IGNORE) and returns ok even
# on a no-op, so a positive re-confirm (auth_empty must flip to false) is the only
# proof the seed took.
#
# DELIVERY-FIRST ordering (crux, adversarial-review-hardened): for a minted password we
# print it to the console BEFORE any DB commit. Once the seed commits, auth_empty flips
# false and the count-gate makes every re-run (the rc.d one-shot fires each boot) SKIP
# seeding -- so a kill/failure AFTER the commit but before the print would lose the only
# copy of the minted password forever (unrecoverable brick). Printing first means a
# failure before the commit simply re-mints+reprints on the next run. The operator
# cannot log in until user_add commits (microseconds after the print), by which point
# set_must_change has run -- so there is no practical force-change-bypass window. If
# set_must_change fails after the commit the admin is usable but not force-changed (a
# degraded, non-brick residual; same socket/DB as the just-succeeded seed).
seed_admin() {
	_minted=0
	if [ -z "$_ADMIN_PASS" ]; then
		_ADMIN_PASS=$(gen_random_pw) || die "could not mint a random admin password (openssl rand failed)"
		[ ${#_ADMIN_PASS} -ge 12 ] || die "minted admin password too short (openssl rand malfunction)"
		_minted=1
	fi
	[ -S "$OGMA_AUTHD_SOCK" ] || die "authd socket $OGMA_AUTHD_SOCK absent; cannot seed admin (authd not up?)"
	_st=$(ogma_authd_op '{"op":"db_status"}')
	[ -n "$_st" ] || die "authd db_status returned nothing; cannot seed admin (authd not responding?)"
	if ! auth_is_empty "$_st"; then
		note "admin already provisioned (auth_empty!=true); skip seed"
		return 0
	fi
	# DELIVERY-FIRST: a minted password reaches the console BEFORE any DB commit.
	if [ "$_minted" -eq 1 ]; then
		printf 'OgmaProtect firstboot: admin "admin" one-time password (you MUST change it at first login): %s\n' \
			"$_ADMIN_PASS" > "$OGMA_CONSOLE" || die "could not write the one-time admin password to $OGMA_CONSOLE"
	fi
	note "seeding first admin (auth_empty=true)"
	_out=$(printf '%s\n' "$_ADMIN_PASS" | ogmaprotectctl user add admin 2>&1)
	if ! echo "$_out" | grep -Eq '"ok"[[:space:]]*:[[:space:]]*true'; then
		die "ogmaprotectctl user add admin failed: $_out"
	fi
	ogmaprotectctl role grant admin net-admin >/dev/null 2>&1 || warn "role grant admin net-admin returned non-zero"
	# Positive re-confirm: role_grant is INSERT-OR-IGNORE and returns ok even on a
	# no-op, so the count flip (auth_empty true->false, driven by the user:*:write
	# grant) is the ONLY proof the seed took. An EMPTY re-probe (authd dropped right
	# after user_add) must FAIL, not read as "not empty" == "seeded".
	_st2=$(ogma_authd_op '{"op":"db_status"}')
	[ -n "$_st2" ] || die "cannot confirm admin seed (authd stopped responding after user add)"
	if auth_is_empty "$_st2"; then
		die "admin seed did not take (auth_empty still true after user add + role grant)"
	fi
	note "first admin seeded and granted net-admin"
	# Force-change ONLY the minted credential (the injected deploy/lab/CI password is
	# exempt, D3). The seed is committed, so set_must_change reaches the just-created
	# admin over the same uid-0 socket. Its COMPACT wire reply is "ok":true (no spaces)
	# -- distinct from ogmaprotectctl's whitespace-tolerant pretty JSON above.
	if [ "$_minted" -eq 1 ]; then
		_mc=$(ogma_authd_op '{"op":"set_must_change","username":"admin","must_change":true}')
		echo "$_mc" | grep -q '"ok":true' || die "could not set force-change flag on the seeded admin: $_mc"
		note "seeded admin flagged for force-change at first login"
	fi
}

loop1_secrets() {
	note "loop 1: secret-gen"
	gen_cert
	seed_admin
}

# ===================================================================================
# loop 2 -- every-run convergence (level-triggered, best-effort)
# ===================================================================================

# Ensure CGI.pm is available on the HOST (site_perl), where setup_chroot copies it in.
# p5-CGI is the port RUN_DEPENDS; fall back to CPAN only offline. Verbatim from
# deploy.py install_cgi_module (retiring the CPAN path is D6/S4).
ensure_cgi() {
	perl -MCGI -e 1 >/dev/null 2>&1 && return 0
	note "CGI.pm absent; pkg_add p5-CGI"
	pkg_add -I p5-CGI >/dev/null 2>&1
	perl -MCGI -e 1 >/dev/null 2>&1 && return 0
	warn "p5-CGI unavailable (offline?); trying CPAN"
	PERL_MM_USE_DEFAULT=1 cpan -i CGI >/dev/null 2>&1 || warn "CGI.pm not installable; web UI will 500 until CGI.pm is present"
}

# Populate the httpd/slowcgi chroot (/var/www): /dev nodes, perl + shared libs +
# site_perl, staging dirs, the www rundir. Faithful port of deploy.py
# setup_httpd_chroot (brace-expansions written out for dash safety).
setup_chroot() {
	mkdir -p /var/www/dev \
		/var/www/usr/bin /var/www/usr/lib /var/www/usr/libexec \
		/var/www/usr/libdata/perl5 /var/www/usr/local/libdata/perl5/site_perl 2>/dev/null
	cp -p /dev/MAKEDEV /var/www/dev/MAKEDEV 2>/dev/null && \
		( cd /var/www/dev && sh MAKEDEV std >/dev/null 2>&1 )
	# The node MAKEDEV just made here cannot work: /var is mounted nodev by
	# default on OpenBSD, so opening it returns ENODEV (measured). Replace it
	# with a plain empty file, so anything in the chroot that opens /dev/null
	# gets a usable fd rather than an error.
	#
	# NOT for CGI.pm -- which is what this comment claimed until 2026-08-18,
	# and it is wrong. CGI.pm never opens /dev/null: zero occurrences across
	# the whole distribution (CGI.pm, CGI/File/Temp.pm, Util, Carp, Push,
	# Cookie), checked on 4.09, 4.21, 4.38 and master. It drains a discarded
	# upload in memory (`while (defined($data = $buffer->read)) { }`) and puts
	# a kept one in CGI::File::Temp under TMPDIR -- which is what /var/www/tmp
	# below is actually for. slowcgi's own /dev/null open is pre-chroot
	# (slowcgi.c, fds 0-2), so that is the host's, not this one.
	#
	# So nothing writes here today, and the file cannot grow: the web tier
	# never shells out, so there is no `2>/dev/null` path into it either. It
	# stays as a cheap placeholder against code that expects /dev/null to
	# exist -- keep that in mind before treating it as load-bearing.
	rm -f /var/www/dev/null && : > /var/www/dev/null && chmod 666 /var/www/dev/null
	# Chroot runtime (perl + shared libs): re-sync when the host copy is NEWER than the
	# chroot copy, not only when absent -- so a syspatch-updated libc/libperl actually
	# reaches the chroot (contract 5.6 OS-upgrade survival; the S1 copy-once guard left
	# this stale). Per-file mtime gate keeps the common no-op boot cheap. `-nt` is true
	# when the destination is missing, so first-populate and upgrade share one path.
	_perl_resynced=0
	if [ ! -x /var/www/usr/bin/perl ] || [ /usr/bin/perl -nt /var/www/usr/bin/perl ]; then
		cp -p /usr/bin/perl /var/www/usr/bin/ 2>/dev/null && _perl_resynced=1
	fi
	for _lib in /usr/lib/libperl.so.* /usr/lib/libm.so.* /usr/lib/libc.so.*; do
		[ -f "$_lib" ] || continue
		_ldst="/var/www/usr/lib/$(basename "$_lib")"
		{ [ ! -f "$_ldst" ] || [ "$_lib" -nt "$_ldst" ]; } && cp -p "$_lib" /var/www/usr/lib/ 2>/dev/null
	done
	if [ ! -f /var/www/usr/libexec/ld.so ] || [ /usr/libexec/ld.so -nt /var/www/usr/libexec/ld.so ]; then
		cp -p /usr/libexec/ld.so /var/www/usr/libexec/ 2>/dev/null
	fi
	# perl modules: re-copy on first populate or after a perl re-sync (version bump).
	[ "$_perl_resynced" = 1 ] && cp -Rp /usr/libdata/perl5/* /var/www/usr/libdata/perl5/ 2>/dev/null
	# site_perl (CGI.pm) must stay in sync after ensure_cgi().
	if [ -f /usr/local/libdata/perl5/site_perl/CGI.pm ]; then
		cp -Rp /usr/local/libdata/perl5/site_perl/* /var/www/usr/local/libdata/perl5/site_perl/ 2>/dev/null
	fi
	chmod 755 /var/www/htdocs/ogmaprotect/cgi/ogmaprotect.pl 2>/dev/null
	mkdir -p /var/db/ogmaprotect/staging/uploads /var/db/ogmaprotect/staging/exports \
		/var/www/staging/uploads /var/www/staging/exports 2>/dev/null
	chown root:www /var/db/ogmaprotect/staging/uploads 2>/dev/null
	chmod 770 /var/db/ogmaprotect/staging/uploads 2>/dev/null
	chown root:www /var/www/staging/uploads 2>/dev/null
	chmod 770 /var/www/staging/uploads 2>/dev/null
	# exports: group-writable by www (CGI unlinks the mirror bundle after streaming;
	# 0770 also stops local users reading transient export bundles).
	chown root:www /var/www/staging/exports 2>/dev/null
	chmod 770 /var/www/staging/exports 2>/dev/null
	mkdir -p /var/www/tmp && chmod 1777 /var/www/tmp
	ensure_www_rundir
}

# /var/www/run group affects the default socket gid at bind(2); keep root:www.
ensure_www_rundir() {
	mkdir -p /var/www/run 2>/dev/null
	chown root:www /var/www/run 2>/dev/null
	chmod 770 /var/www/run 2>/dev/null
}

# Write /etc/httpd.conf from the canonical template + set the slowcgi -t 180 backstop.
# Does NOT start or enable httpd/slowcgi -- that is start_web_plane's job (httpd-LAST,
# after the fail-closed pf). The template is read from $OGMA_SRC_DIR/etc/httpd.conf
# (deploy.py/CI provide it) else the S2-staged /etc/examples/ogmaprotect/httpd.conf.
# Read as DATA (cp), never sourced. Validated in a TEMP path BEFORE it overwrites the
# live file, so a broken shipped template can never corrupt /etc/httpd.conf (which would
# re-break every boot).
write_httpd_conf() {
	_tmpl=""
	if [ -n "$OGMA_SRC_DIR" ] && [ -f "$OGMA_SRC_DIR/etc/httpd.conf" ]; then
		_tmpl="$OGMA_SRC_DIR/etc/httpd.conf"
	elif [ -f /etc/examples/ogmaprotect/httpd.conf ]; then
		_tmpl=/etc/examples/ogmaprotect/httpd.conf   # staged by `make install` (S2)
	fi
	[ -n "$_tmpl" ] || die "httpd.conf template not found (set OGMA_SRC_DIR to the source tree)"

	# ACME (Phase 9.O3): the http-01 webroot + acme-client key dir. Idempotent; inert
	# until ACME is enabled.
	[ -d /var/www/acme ] || mkdir -p /var/www/acme; chmod 755 /var/www/acme 2>/dev/null
	[ -d /etc/acme ] || mkdir -p /etc/acme; chmod 700 /etc/acme 2>/dev/null

	# Validate the TEMPLATE (temp copy) before touching the live file. httpd -nf reads
	# the config at the given path; the box-absolute paths inside it (chroot, cert) are
	# independent of where the file lives, so validating the temp is equivalent.
	_thttpd="$OGMA_HTTPD_CONF.ogma-tmp.$$"
	cp "$_tmpl" "$_thttpd" || { rm -f "$_thttpd"; die "cp httpd.conf template -> $_thttpd"; }
	if ! httpd -nf "$_thttpd" >/dev/null 2>&1; then
		httpd -nf "$_thttpd" >&2 2>&1 || true
		rm -f "$_thttpd"
		die "httpd -n rejected the shipped httpd.conf template ($_tmpl); refusing to install it"
	fi
	# Preserve the operator's pre-OgmaProtect httpd.conf once, then swap in atomically.
	[ -f /etc/httpd.conf.pre-ogma ] || [ ! -f "$OGMA_HTTPD_CONF" ] || \
		cp -p "$OGMA_HTTPD_CONF" /etc/httpd.conf.pre-ogma 2>/dev/null
	mv "$_thttpd" "$OGMA_HTTPD_CONF" || { rm -f "$_thttpd"; die "installing $OGMA_HTTPD_CONF"; }

	# slowcgi -t 180: the deadline backstop below httpd and above the web budget. NO
	# leading `--` (rcctl stores the flags verbatim; a literal `--` usage()-exits slowcgi
	# at the next boot). Apply only when different; start_web_plane restarts if we changed it.
	if [ "$(rcctl get slowcgi flags 2>/dev/null)" != "-t 180" ]; then
		rcctl set slowcgi flags -t 180 2>/dev/null
		_slowcgi_restart=1
	else
		_slowcgi_restart=0
	fi
}

# ===================================================================================
# S2 -- fail-closed pf gate (loaded before httpd binds)
# ===================================================================================

# True (0) iff pf is administratively disabled -- so its ruleset is not enforced and
# traffic falls through to OpenBSD's implicit default-pass. `pfctl -si` prints a
# "Status: Enabled"/"Disabled" line.
pf_is_disabled() {
	! "$OGMA_PFCTL" -si 2>/dev/null | grep -q 'Status: Enabled'
}

# True (0) iff /etc/pf.conf carries the OgmaProtect managed-block include (matched on the
# managed path, exactly as ensure_include_stanza dedups it in pf_canonical.c).
pf_conf_includes_managed() {
	[ -f "$OGMA_PF_CONF" ] && grep -qF "$OGMA_PF_MANAGED" "$OGMA_PF_CONF" 2>/dev/null
}

# True (0) iff the managed block /etc/pf.conf.ogmaprotect has a non-comment, non-blank
# line between its begin/end markers (i.e. the operator has actually configured pf, vs
# an empty managed block that renders to pass-all through the envelope).
pf_managed_body_nonempty() {
	[ -f "$OGMA_PF_MANAGED" ] || return 1
	awk '
		/ogmaprotect:pf begin/ { f=1; next }
		/ogmaprotect:pf end/   { f=0 }
		f && /^[ \t]*#/        { next }
		f && /^[ \t]*$/        { next }
		f                      { n++ }
		END { exit (n>0)?0:1 }
	' "$OGMA_PF_MANAGED" 2>/dev/null
}

# True (0) iff /etc/pf.conf is effectively the stock/empty OpenBSD default (no operator
# firewall): absent, or every non-comment/blank line matches an EXACT stock-default form.
# Deliberately NOT a byte-compare to /etc/examples/pf.conf (which carries an RCS
# $OpenBSD$ header and drifts across releases). STRICT (10.E1 S4 review S4-SEC-2/F5):
# only `set skip on lo|lo0`, a bare `pass`, the bare block-family forms, the stock X11
# block and the stock _pbuild block qualify. Any other set/match/antispoof/qualified
# rule is OPERATOR POLICY -> brownfield (do-not-clobber / the S4 adoption gate). The
# earlier wholesale `set `/`match `/`antispoof ` skips misread real NAT/skip policy as
# "no operator firewall" (and would have let the bootstrap load over it). The C twin is
# ogma_pf_body_is_effectively_default (daemon/common/pf_canonical.c); both are pinned
# to the shared corpus daemon/test/fixtures/pf_default/ -- change BOTH together. An
# over-long (>=512-char comment-stripped) line is never stock (parity with the C bound).
pf_is_effectively_default() {
	[ -f "$OGMA_PF_CONF" ] || return 0
	awk '
		{
			line=$0
			sub(/#.*/, "", line)         # strip inline comment
			sub(/[ \t]+$/, "", line)     # trim trailing whitespace
			if (line ~ /^[ \t]*$/) next  # blank / comment-only
			if (length(line) >= 512) { found=1; next }
			if (line ~ /^[ \t]*set[ \t]+skip[ \t]+on[ \t]+lo0?[ \t]*$/) next
			if (line ~ /^[ \t]*pass[ \t]*$/) next
			if (line ~ /^[ \t]*block([ \t]+(return|drop))?([ \t]+all)?[ \t]*$/) next
			if (line ~ /^[ \t]*block[ \t]+return[ \t]+in[ \t]+on[ \t]+![ \t]*lo0?[ \t]+proto[ \t]+tcp[ \t]+to[ \t]+port[ \t]+6000:6010[ \t]*$/) next
			if (line ~ /^[ \t]*block[ \t]+return[ \t]+out[ \t]+log[ \t]+proto[ \t].*[ \t]user[ \t]+_pbuild[ \t]*$/) next
			found=1
		}
		END { exit found ? 1 : 0 }
	' "$OGMA_PF_CONF" 2>/dev/null
}

# Ensure a fail-closed pf ruleset is enforced BEFORE httpd binds *:443. DIES (exit 1)
# on any failure -- the script is `set -u`, NOT `-e`, so a bare `return` would fall
# through to the httpd start in start_web_plane; only a `die` guarantees httpd does not
# bind behind an unvalidated/empty ruleset. Returns 0 only on success or the
# validate-only (lab/hermetic) skip. Never writes /etc/pf.conf (do-not-clobber).
ensure_failclosed_pf() {
	_pf_skip_live=0
	{ [ "${OGMA_SETUP_PF:-1}" = 0 ] || [ -f "$OGMA_SETUP_PF_SENTINEL" ]; } && _pf_skip_live=1

	_pf_target=""; _pf_case=""
	if pf_conf_includes_managed; then
		if pf_managed_body_nonempty; then
			_pf_target="$OGMA_PF_CONF"; _pf_case="managed"         # Case A: OgmaProtect-managed
		else
			# Half-provisioned: the include is present but the managed block is EMPTY, so the
			# envelope renders to pass-all. Load the fail-closed bootstrap, not that pass-all.
			_pf_target="$OGMA_BOOT_PF"; _pf_case="fresh(empty-managed)"
		fi
	elif [ ! -f "$OGMA_PF_CONF" ] || pf_is_effectively_default; then
		_pf_target="$OGMA_BOOT_PF";  _pf_case="fresh"                   # Case FRESH: no operator firewall
	elif pf_is_disabled; then
		# A non-OgmaProtect /etc/pf.conf exists but pf is OFF -> NOTHING is enforced (implicit
		# default-pass). There is no LIVE ruleset to clobber, and we must not expose :443 behind
		# no firewall, so load the fail-closed bootstrap (the operator's pf.conf FILE is left
		# untouched; S4 adoption imports it). This enables pf and blocks transit until adoption.
		_pf_target="$OGMA_BOOT_PF"; _pf_case="fresh(pf-off)"
	else
		# pf is ENABLED with a non-OgmaProtect ruleset: a LIVE operator firewall. Do NOT clobber
		# it (that would brick their forwarding); the mgmt plane (:443) is governed by it. S4's
		# pf import-or-gate adoption brings it under fail-closed management.
		warn "pf is enabled with a non-OgmaProtect ruleset -- NOT clobbering it; the mgmt plane (:443) is governed by the existing firewall (configure it via OgmaProtect / S4 adoption for fail-closed management)"
		note "pf: brownfield operator ruleset left intact (do-not-clobber)"
		# S4 D7: if the operator asked to adopt (pf_adopt=import), point them at the
		# web-UI import. Automatic headless import is NOT performed here: adoption is a
		# capability-gated pfd op that drives a commit-confirm window the operator must
		# review, and on a shared lab (OGMA_SETUP_PF=0/sentinel) a live pf mutation must
		# never fire unattended -- so this stays advisory (review L4-2). The web UI
		# (Firewall -> "Import existing ruleset") performs the reviewed, revertible import.
		if [ "$CONF_PF_ADOPT" = import ]; then
			note "pf_adopt=import: an existing operator firewall is present -- import it under management from the web UI (Firewall -> Import existing ruleset); headless auto-import is not performed"
		fi
		return 0                                                        # Case BROWNFIELD (pf enabled)
	fi

	# Validate; die rather than start httpd behind an invalid/empty ruleset. A managed
	# /etc/pf.conf that fails -nf (e.g. a broken include of a since-removed managed block)
	# falls back to the bootstrap rather than dying into pass-all.
	if ! "$OGMA_PFCTL" -nf "$_pf_target" >/dev/null 2>&1; then
		if [ "$_pf_case" = managed ]; then
			warn "managed $OGMA_PF_CONF failed 'pfctl -nf' (broken include?); falling back to the fail-closed bootstrap"
			_pf_target="$OGMA_BOOT_PF"; _pf_case="fresh(fallback)"
			"$OGMA_PFCTL" -nf "$_pf_target" >/dev/null 2>&1 || die "fail-closed bootstrap ruleset invalid ($_pf_target); refusing to start httpd"
		else
			die "fail-closed pf ruleset invalid ($_pf_target); refusing to start httpd"
		fi
	fi

	if [ "$_pf_skip_live" = 1 ]; then
		note "pf: live load skipped (OGMA_SETUP_PF=0/sentinel); validated $_pf_case ruleset ($_pf_target)"
		return 0
	fi
	"$OGMA_PFCTL" -f "$_pf_target" || die "pfctl -f $_pf_target failed; refusing to start httpd"
	"$OGMA_PFCTL" -e >/dev/null 2>&1 || true   # -e fails only with "already enabled" (pf is always in-kernel)
	# ...but don't trust that: confirm pf is actually ENFORCING, or a rules-loaded-but-disabled
	# state would leave httpd behind implicit-pass.
	if pf_is_disabled; then
		die "pf did not enable after loading $_pf_target; refusing to start httpd"
	fi
	note "fail-closed pf loaded ($_pf_case: $_pf_target)"
}

# httpd-LAST web-plane start: the SOLE, LAST starter of httpd/slowcgi. httpd is never
# `rcctl enable`d, and any enable a prior (S1) provision persisted is cleared here every
# run, so base /etc/rc can never early-bind *:443 before the fail-closed pf loads. httpd
# is gated on an admin existing -- never expose a TLS login on *:443 with no account to
# log into.
start_web_plane() {
	# Disable ONLY httpd, never slowcgi. `rcctl disable slowcgi` writes
	# slowcgi_flags=NO, which both clobbers the -t 180 deadline write_httpd_conf set
	# AND makes `rcctl get slowcgi flags` report NO (a disabled service hides its
	# flags) -- so the deadline backstop is lost across every `deploy configure`
	# (10.D5: the disable-clobbers-flags trap). Only httpd must stay unenabled, so
	# base /etc/rc can never early-bind *:443 before the fail-closed pf loads; slowcgi
	# is a local UNIX socket with no network exposure, so leaving it enabled with
	# -t 180 is safe and keeps the deadline durable.
	rcctl disable httpd 2>/dev/null

	# slowcgi first (write_httpd_conf enabled it with flags -t 180). A `-f` start runs
	# it regardless of enable state (matches the repo's CI idiom); restart picks up any
	# flag change from this run.
	rcctl -f start slowcgi 2>/dev/null
	rcctl -f restart slowcgi 2>/dev/null

	# Belt-and-braces: ensure_failclosed_pf dies internally on every failure, but the script
	# is `set -u` (NOT -e), so guard the call site too -- a future `return 1` must never fall
	# through to the httpd start below.
	ensure_failclosed_pf || die "fail-closed pf gate failed; refusing to start httpd"

	# Gate httpd on an admin existing (authd's db_status, uid-0 signal; we run as root). No
	# admin, or authd not answering (the UI needs it) => defer httpd; the box stays booted +
	# SSH-reachable. deploy.py/CI inject a password so the seed runs and httpd comes up; a
	# bare firstboot defers until the admin is created (console; S3).
	_wp_st=$(ogma_authd_op '{"op":"db_status"}')
	if [ -z "$_wp_st" ]; then
		warn "authd not answering db_status; web plane (httpd) deferred (the UI needs authd)"
		return 0
	fi
	# Fail-closed on a broken DB: auth_empty is `count==0`, but count is -1 on an UNREADABLE
	# users.db, which renders auth_empty:false (== "not empty") -- NOT "an admin exists". So a
	# corrupt/unreadable DB would otherwise expose :443 with a dead login. Require db_healthy.
	if ! echo "$_wp_st" | grep -q '"db_healthy":true'; then
		warn "authd reports an unhealthy users.db; web plane (httpd) deferred (fail-closed -- auth_empty:false can also mean an unreadable DB)"
		return 0
	fi
	if auth_is_empty "$_wp_st"; then
		note "no admin yet; web plane (httpd) deferred until an admin exists (see the package MESSAGE / 'ogmaprotectctl user add admin')"
		return 0
	fi

	rcctl -f start httpd || die "httpd failed to start"
	note "web plane up: httpd started behind the fail-closed pf ruleset"
}

# Merge the audit-log rotation fragment into /etc/newsyslog.conf (single conf, no
# conf.d) and tighten existing audit logs (L4-07). Strip-then-append inside a
# delimited block keeps it idempotent. The chmod only ever TIGHTENS. Faithful port of
# deploy.py setup_newsyslog. (NOTE: today deploy.py runs this BEFORE the daemon
# restart to pull a legacy 0644 log to 0640 before writers reopen it; here it is
# post-restart -- level-triggered convergence closes that window within one pass, an
# accepted delta.)
merge_newsyslog() {
	_frag=/etc/examples/ogmaprotect/newsyslog.conf.ogmaprotect
	if [ ! -f "$_frag" ]; then
		[ -n "$OGMA_SRC_DIR" ] && [ -f "$OGMA_SRC_DIR/etc/newsyslog.conf.ogmaprotect" ] && \
			_frag="$OGMA_SRC_DIR/etc/newsyslog.conf.ogmaprotect"
	fi
	[ -f "$_frag" ] || { warn "newsyslog fragment not found; skip rotation merge"; return 0; }
	awk '/^# --- OgmaProtect ---$/{skip=1;next} /^# --- end OgmaProtect ---$/{skip=0;next} !skip' \
		/etc/newsyslog.conf > /etc/newsyslog.conf.ogma-tmp 2>/dev/null && \
		mv /etc/newsyslog.conf.ogma-tmp /etc/newsyslog.conf
	{ echo '# --- OgmaProtect ---'; cat "$_frag"; echo '# --- end OgmaProtect ---'; } \
		>> /etc/newsyslog.conf
	find /var/log -maxdepth 1 -type f -name 'ogmaprotect-*' -exec chmod o-rwx,g-w {} + 2>/dev/null
	newsyslog -n >/dev/null 2>&1 || warn "newsyslog -n rejected /etc/newsyslog.conf"
}

# 10.E9 S1 (VD-E9-2): put the fsck auto-repair patch back after a sysupgrade
# replaced /etc/rc with the stock etcXX.tgz copy. Evidence-gated: only when the
# operator's `enable` intent stands recorded (the marker the applier writes and
# its `disable` clears -- a deliberate disable is honoured forever), and only
# THROUGH the applier, whose own gates refuse a moved-hunk release loudly with
# /etc/rc untouched (zero-fuzz dry-run + per-release selection, VD-E9-5, plus
# the ksh -n restore). On a refusal the box keeps the fsck_rc_patch drift
# WARNING -- the contract path, §2.1(b) -- and this step SAYS so on every boot
# rather than papering over it. This is an every-boot ROOT edit of /etc/rc,
# reviewed as such: the edit is exactly `ogma-fsck-rc-apply enable`, which an
# operator could type, idempotent, refusal-first, and stock-restoring on any
# failed syntax check. Never a die: a refused re-apply must not stop the rest
# of loop 2 (the web plane still has to come up).
fsck_patch_converge() {
	[ -f "$OGMA_FSCK_INTENT" ] || return 0
	if [ ! -x "$OGMA_FSCK_APPLY" ]; then
		warn "fsck re-apply intent is recorded at $OGMA_FSCK_INTENT but $OGMA_FSCK_APPLY is missing"
		return 0
	fi
	if "$OGMA_FSCK_APPLY" status >/dev/null 2>&1; then
		return 0	# applied: the level-triggered no-op
	fi
	note "fsck auto-repair is enabled by the operator but /etc/rc is stock (post-sysupgrade?); re-applying"
	if "$OGMA_FSCK_APPLY" enable; then
		note "fsck rc patch re-applied"
	else
		warn "fsck rc patch re-apply REFUSED or failed; /etc/rc left stock -- a preen failure at boot will halt on the console until this is acted on (run: ogma-fsck-rc-apply status; the fsck_rc_patch drift WARNING stands)"
	fi
	return 0
}

loop2_converge() {
	note "loop 2: convergence"
	fsck_patch_converge  # 10.E9 S1 (VD-E9-2): first, so a later step's failure
	                     # cannot skip it -- it steers the NEXT boot's fsck
	ensure_cgi
	setup_chroot
	write_httpd_conf
	merge_newsyslog
	start_web_plane   # httpd-LAST: fail-closed pf, then (admin-gated) httpd
}

# ===================================================================================
# main
# ===================================================================================
# ===================================================================================
# bootstrap -- the operator-run first-install one-shot
# ===================================================================================
#
# Everything below this point already existed; what did NOT was a single command an
# operator could run after `pkg_add` to reach a working HTTPS login. The daemons were
# theirs to `rcctl enable` by hand from a 16-name ordered list reproduced in pkg
# MESSAGE, docs/INSTALL.md and the website -- three hand-kept copies of an ordering
# whose only failure mode is silent (a wrong order boots a box whose management plane
# never comes back). This mode does that enable/start from the inventory, then runs
# the two loops that were already here.
#
# NOT a boot path: the rc.d one-shot still calls `all`, and `pkg_add` still enables
# and starts nothing (OpenBSD packages must not, and this one does not -- PLIST
# carries no @exec that touches rcctl). An operator typing `ogmaprotect-setup
# bootstrap` IS the consent that convention withholds from the installer.

# The rc.d script names in boot-rank order, read from the installed inventory
# projection. Column 3 is rc_script, column 14 is boot_rank (0 = first).
inventory_rc_order() {
	awk -F'|' '
		/^#/ { next }
		NF >= 14 && $3 != "" && $14 ~ /^[0-9]+$/ { printf "%03d %s\n", $14, $3 }
	' "$OGMA_INVENTORY" | sort | cut -d' ' -f2 | tr '\n' ' '
}

# 10.E6 VD-E6-22: the post-install release-match self-check. Asserts INSTALLED STATE
# (the in-package RELEASE-INFO vs the running kernel), never a pkg_add exit code --
# VD-E6-15 measured pkg_add failing silently at rc 0 on this very surface. Parsing is
# check-release-match.sh's rule (`key: value`, first line wins, a trailing `#` is part
# of the value). Prints one word on stdout; the explanation goes to stderr.
#   0  match      1  MISMATCH (do not enable; nothing will start)      2  cannot tell
release_match_check() {
	_info="${OGMA_RELEASE_INFO:-/etc/ogmaprotect/RELEASE-INFO}"
	_run="${OGMA_UNAME_R:-$(uname -r)}"
	if [ ! -r "$_info" ]; then
		echo unknown
		echo "ogmaprotect-setup: $_info not readable -- cannot tell which OpenBSD release \
this package was built for (every package from 0.5.3 installs one; a missing file means an \
incomplete install or a pre-0.5.3 build)" >&2
		return 2
	fi
	_built=$(sed -n 's/^built_for_openbsd: //p' "$_info" | head -1)
	_ver=$(sed -n 's/^version: //p' "$_info" | head -1)
	if [ -z "$_built" ] || [ "$_built" = unknown ]; then
		echo unknown
		echo "ogmaprotect-setup: $_info does not state an OpenBSD release \
(built_for_openbsd: ${_built:-<absent>}); an artifact that cannot say what it was built for \
did not come from a release ceremony" >&2
		return 2
	fi
	if [ "$_built" = "$_run" ]; then
		echo match
		return 0
	fi
	echo mismatch
	cat >&2 <<EOM
ogmaprotect-setup: !! RELEASE MISMATCH -- ogmaprotect ${_ver:-(unstated)} was built for OpenBSD $_built
   and this box is running OpenBSD $_run.
   The package installed and its signature verified, but the daemons cannot load their
   shared libraries (OpenBSD bumps library MAJORs between releases; ld.so needs an exact
   match). Nothing will start. Install the package built for OpenBSD $_run, or move this
   box to OpenBSD $_built first (INSTALL.md 3a; RELEASE.md, "One package, one OpenBSD release").
EOM
	return 1
}

bootstrap() {
	# FIRST, before anything is enabled or started: refuse to record a boot order for a
	# product that cannot start (10.E6 VD-E6-22). `die` on a mismatch; a file that
	# cannot be read or compared is refused too -- bootstrap is the one command between
	# pkg_add and a login, and "unknown" is not a green light there.
	_rm=$(release_match_check) || {
		_rc=$?
		[ "$_rc" -eq 1 ] && die "release mismatch (see above); refusing to enable or start anything"
		die "cannot confirm this package was built for the running OpenBSD release \
(relcheck: ${_rm:-unknown}); refusing to enable or start anything"
	}
	note "release check: package built for the running OpenBSD release ($_rm)"
	[ -r "$OGMA_INVENTORY" ] || die "daemon inventory $OGMA_INVENTORY not readable; \
is the package fully installed? (bootstrap reads the boot order from it)"
	_order=$(inventory_rc_order)
	[ -n "$_order" ] || die "daemon inventory $OGMA_INVENTORY yielded no rc.d scripts \
(truncated or wrong format); refusing to guess a boot order"

	# Every named script must exist before we enable ANY of them: a partial enable
	# writes a pkg_scripts line the operator then has to unpick by hand.
	for _s in $_order; do
		[ -x "/etc/rc.d/$_s" ] || die "/etc/rc.d/$_s missing or not executable; \
refusing to record a boot order the box cannot honour"
	done

	# One `rcctl enable` call, in order: rcctl APPENDS to pkg_scripts in argument
	# order, and that recorded order is what every subsequent boot replays. The
	# provisioner itself goes last -- after authd -- so its admin-seed reaches a live
	# authd and it stays the sole, last starter of httpd (see etc/rc.d/ogmaprotect_setup).
	_n=$(printf '%s\n' $_order | wc -l | tr -d ' ')
	note "enabling $_n daemons in boot order, provisioner last"
	# shellcheck disable=SC2086  # deliberate word-split: $_order is a name list
	rcctl enable $_order ogmaprotect_setup || die "rcctl enable failed; boot order not recorded"

	# Start in the same order, and STOP on the first failure. Carrying on would run
	# the provisioner against a half-up control plane, whose most likely outcome is a
	# confusing seed/converge error several steps away from the daemon that actually
	# failed.
	for _s in $_order; do
		note "starting $_s"
		rcctl start "$_s" || die "$_s failed to start; \
run 'rcctl -d start $_s' for its output, then re-run: ogmaprotect-setup bootstrap"
	done

	# The two loops, unchanged. loop 1 mints and console-delivers the one-time admin
	# password (authd is up by now, which is the whole reason this mode exists); loop 2
	# converges the chroot, /etc/httpd.conf, newsyslog, the fail-closed pf, and starts
	# the web plane last.
	loop1_secrets
	loop2_converge

	note "bootstrap complete -- sign in at https://$(default_cn)/ogmaprotect/"
	note "the one-time admin password was written to the console ($OGMA_CONSOLE); \
you must change it at first login. Installed over SSH and cannot read the console? \
set one as root instead: ogmaprotectctl user set-password admin"
}

main() {
	_mode="${1:-all}"
	# S4 D7: resolve the operator config knobs (pf_adopt) BEFORE any mode runs so
	# the fail-closed pf gate's brownfield path can honour pf_adopt. Env wins; the
	# file is strictly whitelisted and DATA-parsed (never sourced).
	read_conf_knobs
	case "$_mode" in
		bootstrap) bootstrap ;;         # operator-run first install: enable+start the
		                                # daemons in inventory boot order, then all.
		all)      loop1_secrets; loop2_converge ;;
		secrets)  loop1_secrets ;;
		converge) loop2_converge ;;
		webplane) start_web_plane ;;   # internal/test seam: only the httpd-LAST web-plane
		                               # start (fail-closed pf + admin-gated httpd). Driven
		                               # by t/ogmaprotect_setup.t; also a valid manual re-run
		                               # after creating the admin post-provision.
		relcheck)                      # 10.E6 VD-E6-22: the post-install release-match self-check
		                               # (also bootstrap's first step). Prints match|mismatch|
		                               # unknown; exit 0 / 1 / 2. Driven by t/ogmaprotect_setup.t.
			release_match_check; exit $? ;;
		knobs)    echo "pf_adopt=$CONF_PF_ADOPT" ;;  # internal/test seam (t/ogmaprotect_
		                               # setup.t): print the RESOLVED D7 config knob after
		                               # read_conf_knobs (whitelist/ownership/enum), so the
		                               # parser's safety is observable without side effects.
		fsckpatch)                     # internal/test seam (t/ogmaprotect_setup.t): run ONLY
		                               # the 10.E9 VD-E9-2 fsck-rc-patch convergence step, so
		                               # its gates (intent marker, applier refusal, level
		                               # trigger) are walkable hermetically without the rest
		                               # of loop 2.
			fsck_patch_converge ;;
		pfdefault)                     # internal/test seam (t/ogmaprotect_setup.t): classify
		                               # $OGMA_PF_CONF with the stock-default heuristic and print
		                               # default|operator, so the sh twin can be walked over the
		                               # SHARED corpus daemon/test/fixtures/pf_default/ (the C twin
		                               # ogma_pf_body_is_effectively_default is walked by test_pf.c).
			if pf_is_effectively_default; then echo default; else echo operator; fi ;;
		-h|--help|help)
			sed -n '2,50p' "$0"; exit 0 ;;
		*)
			die "unknown mode '$_mode' (use: bootstrap | all | secrets | converge | webplane | relcheck)" ;;
	esac
	note "done ($_mode)"
}

main "$@"
