#!/usr/bin/env bash
# scripts/opnsense-bootstrap-apikey.sh [--dry-run] <edge-host> <output-creds-file>
#
# Bootstraps an OPNsense REST API key on an edge that we can already reach over SSH,
# WITHOUT a GUI click and WITHOUT re-implementing any vendor crypto. This is the
# missing link that makes D-113(a2) edge provisioning fully automatable -- Stage 3's
# DC1/DC2 edges included.
#
# HOW: ships scripts/opnsense-mint-apikey.php to the edge and runs it there. That
# script calls OPNSENSE'S OWN MODEL (Auth\User -> apikeys->add()) -- the exact code
# path the GUI's "Create and download API key for this user" ticket button invokes.
#
# WHY NOT MINT THE KEY OFFLINE: OPNsense stores API secrets as crypt(secret,'$6$')
# (SHA-512, empty salt -- verified on the live 26.1 box). We COULD reproduce that and
# seed a key into a bootstrap config.xml. We deliberately DO NOT: it would couple our
# provisioning to a vendor hash format we would have to keep byte-compatible forever,
# and a mismatch fails SILENTLY -- keys that simply never authenticate. Calling the
# vendor's generator has no format to keep in sync. Same principle as D-113(a2)
# itself: call the interface, do not re-implement the internals.
#
# PREREQUISITE: SSH to the edge as root with a key (i.e. the D-112(c) console
# bootstrap has already run). This script does NOT solve first-contact; it solves
# "we have SSH, now get an API key so everything else can be REST".
#
# STAGING NOTE: the minter (opnsense-mint-apikey.php) must sit BESIDE this script
# in the SAME directory -- when staging to a rack, copy BOTH files (measured on the
# dc0 build; the script fails loud but only at run time).
#
# TIMING: right after the console bootstrap the edge sshd is RESTARTING
# (`configctl openssh restart`) and the first contact can get "Connection closed"
# (measured transient, dc1 2026-07-23). This script therefore WAITS for sshd to
# answer before shipping anything, and retries the first scp once.
#
# THE SECRET IS NEVER PRINTED. OPNsense hashes it on save, so creation is the ONLY
# moment it exists in cleartext. It goes straight to <output-creds-file> (0600) in the
# `key=`/`secret=` form scripts/opnsense-api.sh expects. Stdout gets lengths only.
#
# Exit: 0 minted | 1 usage/precondition error | 2 mint or retrieval failed.
set -euo pipefail

HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
MINTER="$HERE/opnsense-mint-apikey.php"

DRY_RUN=0
if [ "${1:-}" = "--dry-run" ]; then DRY_RUN=1; shift; fi

EDGE="${1:-}"
OUT="${2:-}"
: "${OPNSENSE_API_USER:=root}"

usage() {
  echo "usage: opnsense-bootstrap-apikey.sh [--dry-run] <edge-host> <output-creds-file>" >&2
  echo "  e.g. opnsense-bootstrap-apikey.sh 10.10.0.1 ~/vr1-office1-creds/opnsense-api.txt" >&2
  echo "  env: OPNSENSE_SSH_KEY (required)  OPNSENSE_API_USER (default: root)" >&2
  echo "  NOTE: opnsense-mint-apikey.php must sit BESIDE this script -- copy BOTH when staging to a rack" >&2
  exit 1
}

[ -n "$EDGE" ] && [ -n "$OUT" ] || usage
[ -f "$MINTER" ] || { echo "FAIL: minter not found: $MINTER" >&2; exit 1; }

# Refuse to clobber an existing credential file. Overwriting it would strand a live
# API key on the edge with no local copy -- unrecoverable, since the secret is hashed
# and can never be read back. Delete the old key first, deliberately.
if [ -e "$OUT" ]; then
  echo "FAIL: $OUT already exists -- refusing to overwrite." >&2
  echo "      An existing creds file means a live key. Overwriting would STRAND it on the" >&2
  echo "      edge with no local copy (the secret is hashed there and can never be read" >&2
  echo "      back). Delete the old key via the API first, then re-run." >&2
  exit 1
fi

if [ "$DRY_RUN" = "1" ]; then
  echo "DRY-RUN would ship $MINTER -> ${OPNSENSE_API_USER}@${EDGE}:/tmp/"
  echo "DRY-RUN would mint for user '${OPNSENSE_API_USER}' and write ${OUT}"
  exit 0
fi

[ -n "${OPNSENSE_SSH_KEY:-}" ] || { echo "FAIL: \$OPNSENSE_SSH_KEY not set" >&2; exit 1; }

# -n on every ssh: none of these calls needs stdin, and an ssh that reads stdin EATS the
# rest of a piped `bash -s` driver script (measured on the dc1 mint, 2026-07-23 -- the
# retrieve/cleanup lines were swallowed). Same class as the lxc/tcsh stdin traps.
SSH=(ssh -n -i "$OPNSENSE_SSH_KEY" -o BatchMode=yes -o ConnectTimeout=15)
SCP=(scp -i "$OPNSENSE_SSH_KEY" -o BatchMode=yes -o ConnectTimeout=15 -q)
# Retry pacing: overridable ONLY so the offline harness need not sleep for real.
RETRY_DELAY="${OPNSENSE_BOOTSTRAP_RETRY_DELAY:-5}"

REMOTE_PHP="/tmp/opnsense-mint-apikey.$$.php"
REMOTE_OUT="/tmp/opnsense-apikey.$$.txt"
# Always wipe the remote copies, even on failure -- the remote file holds the
# cleartext secret until we have it.
cleanup() { "${SSH[@]}" "root@${EDGE}" "rm -f '$REMOTE_PHP' '$REMOTE_OUT'" >/dev/null 2>&1 || true; }
trap cleanup EXIT

# Post-bootstrap sshd readiness wait (see TIMING in the header): probe until the edge
# answers before shipping anything. Bounded -- never spins forever on a dead edge.
ready=0
for attempt in 1 2 3 4 5 6; do
  if "${SSH[@]}" "root@${EDGE}" true >/dev/null 2>&1; then ready=1; break; fi
  echo "  ... edge sshd not answering (attempt $attempt/6); retrying in ${RETRY_DELAY}s"
  sleep "$RETRY_DELAY"
done
[ "$ready" = "1" ] || { echo "FAIL: edge sshd at $EDGE never became ready (still restarting after the console bootstrap? wrong host/key?)" >&2; exit 2; }

# First contact after the readiness probe can still hit the restart transient -- one retry.
if ! "${SCP[@]}" "$MINTER" "root@${EDGE}:${REMOTE_PHP}"; then
  echo "  ... first scp failed (post-restart transient?); retrying once in ${RETRY_DELAY}s"
  sleep "$RETRY_DELAY"
  "${SCP[@]}" "$MINTER" "root@${EDGE}:${REMOTE_PHP}" || { echo "FAIL: could not ship the minter to $EDGE" >&2; exit 2; }
fi

# load_phalcon.php is resolved RELATIVE to /usr/local/opnsense/mvc -- must cd there.
"${SSH[@]}" "root@${EDGE}" \
  "cd /usr/local/opnsense/mvc && php '$REMOTE_PHP' '$OPNSENSE_API_USER' '$REMOTE_OUT'" \
  || { echo "FAIL: minting failed on $EDGE" >&2; exit 2; }

umask 077
"${SCP[@]}" "root@${EDGE}:${REMOTE_OUT}" "$OUT" || { echo "FAIL: could not retrieve the key" >&2; exit 2; }
chmod 600 "$OUT"

# Verify shape without printing anything sensitive.
k=$(grep -c '^key=' "$OUT" || true)
s=$(grep -c '^secret=' "$OUT" || true)
if [ "$k" != "1" ] || [ "$s" != "1" ]; then
  echo "FAIL: $OUT does not contain exactly one key= and one secret= line" >&2
  exit 2
fi

echo "OK: API key minted on ${EDGE} for '${OPNSENSE_API_USER}' -> ${OUT} (0600, secret not printed)"
echo "    verify with: OPNSENSE_API_HOST=${EDGE} OPNSENSE_API_CREDS=${OUT} bash scripts/opnsense-api.sh GET core/firmware/status"
