#!/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".
#
# 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
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; }
SSH=(ssh -i "$OPNSENSE_SSH_KEY" -o BatchMode=yes -o ConnectTimeout=15)
SCP=(scp -i "$OPNSENSE_SSH_KEY" -o BatchMode=yes -o ConnectTimeout=15 -q)
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
"${SCP[@]}" "$MINTER" "root@${EDGE}:${REMOTE_PHP}" || { echo "FAIL: could not ship the minter to $EDGE" >&2; exit 2; }
# 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"