diff --git a/docs/changelog-20260713-opnsense-apikey-bootstrap.md b/docs/changelog-20260713-opnsense-apikey-bootstrap.md new file mode 100644 index 0000000..02932ef --- /dev/null +++ b/docs/changelog-20260713-opnsense-apikey-bootstrap.md @@ -0,0 +1,83 @@ +# 2026-07-13 -- D-113(a2): API-key bootstrap, with no GUI click and no re-implemented crypto + +The last unknown in D-113(a2) edge provisioning. The API path was proven (read AND write), but +every new edge still needed a **manual GUI click** to mint its API key -- which would have put a +human in the middle of building DC1's and DC2's edges in Stage 3. Closed. + +## What shipped + +- `scripts/opnsense-mint-apikey.php` -- runs ON the edge; calls **OPNsense's own model** + (`Auth\User` -> `apikeys->add()`), the exact code path the GUI's ticket button invokes. +- `scripts/opnsense-bootstrap-apikey.sh` -- ships the minter over SSH, runs it, retrieves the + key, wipes the remote copies. +- `tests/opnsense-bootstrap-apikey/run-tests.sh` -- offline harness, **8 PASS** (ssh/scp stubbed; + no edge contacted, no real key minted). + +## The design decision, and why the alternative was rejected + +OPNsense stores API secrets as `crypt(secret, '$6$')` -- SHA-512 with an EMPTY salt (verified on +the live 26.1 box: the stored hash is `$6$$` + 86 chars). So we *could* mint keys offline and +seed them into a bootstrap `config.xml`. + +**We deliberately do not.** That 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, with no error at generation time. Calling the vendor's own generator has no format +to keep in sync. + +This is the same principle as D-113(a2) itself: **call the interface, do not re-implement the +internals.** Hand-reimplementing OPNsense's internals is exactly what cost us DOCFIX-191/192/193. + +(An offline-hash attempt was actually blocked by the permission classifier mid-session. The block +was correct, and it pushed us to the better design.) + +## VERIFIED END TO END on the live edge (2026-07-12/13) + +Minted a SECOND key on Office1 via the vendor model, proved it works, then deleted it: + + mint via OPNsense's own model -> key=80 chars, secret=80 chars (base64 of 60 random bytes) + GET core/firmware/status -> HTTP 200 [the vendor-minted key AUTHENTICATES] + POST auth/user/del_api_key/ -> {"result":"deleted"} + search_api_key -> 1 row remaining (the operator's original key) + retired key re-tested -> REJECTED [negative test: deletion really took] + +The operator's existing key was untouched throughout, and the temporary creds file was shredded +from the jumphost. The edge is back to its prior state. + +## Guard clause worth keeping (harness T5) + +The script **refuses to overwrite an existing creds file.** An existing file means a LIVE key on +the edge; overwriting the local copy would **strand it there permanently**, because the secret is +hashed on save and can never be read back. That is unrecoverable credential loss, and a guard +clause is the only thing standing in front of it. T5 asserts both the refusal AND that the +existing file is left byte-intact. + +The secret is never printed: creation is the only moment it exists in cleartext. It goes straight +to a 0600 file in the `key=`/`secret=` form `scripts/opnsense-api.sh` parses. + +## What this unblocks + +Edge provisioning is now automatable end to end, with no human in the loop: + + boot factory nano + -> D-112(c) console bootstrap (enable SSH + install key) + -> scripts/opnsense-bootstrap-apikey.sh <-- THIS (no GUI click) + -> scripts/opnsense-api.sh <-- everything else: DHCP, firewall, ifaces + +No `config.xml` anywhere in that chain. + +## Still OPEN + +- **The template reduction itself is NOT done.** `opentofu/templates/opnsense-config.xml.tmpl` + still renders a FULL config. Under D-113(a2) it should shrink to a minimal bootstrap or vanish + entirely -- the chain above suggests it may not be needed at all, since the console bootstrap + plus this script covers first contact. That is the next call. +- The `opnsense-edge` module still wires `config_seed` + a cdrom disk for the INERT config-ISO + path (D-112). Removing them touches an INSTANTIATED resource (`libvirt_volume.config_seed` is + in state), so it is a live change, not a docs change -- deliberately not done here. + +## Revert + + git rm scripts/opnsense-bootstrap-apikey.sh scripts/opnsense-mint-apikey.php + git rm -r tests/opnsense-bootstrap-apikey/ + +Nothing live depends on these: the live edge's key was minted via the GUI and is unaffected. diff --git a/docs/session-ledger.md b/docs/session-ledger.md index a168b5f..0cf6004 100644 --- a/docs/session-ledger.md +++ b/docs/session-ledger.md @@ -2011,6 +2011,34 @@ scp/install/reboot path against this edge. Template reduction to a minimal bootstrap (sshd + key + console) is the next step and is NOT yet done. +## D-113(a2): API-KEY BOOTSTRAP SOLVED 2026-07-13 (no GUI click, no re-implemented crypto) + +- **The last unknown in edge provisioning is closed.** `scripts/opnsense-bootstrap-apikey.sh` + + `scripts/opnsense-mint-apikey.php` (harness 8 PASS; gauntlet 54 ALL GREEN). Ships the minter to + the edge over SSH and calls **OPNsense's OWN model** (`Auth\User -> apikeys->add()`) -- the exact + code path the GUI ticket button invokes. +- **VERIFIED END TO END on the live edge:** minted a 2nd key -> `GET core/firmware/status` HTTP 200 + (it AUTHENTICATES) -> `del_api_key` -> 1 key remaining (operator's original, untouched) -> the + retired key is REJECTED (negative test). Temp creds shredded. Edge back to prior state. +- **DESIGN NOTE (do not "optimize" this away):** OPNsense stores API secrets as + `crypt(secret,'$6$')` (SHA-512, EMPTY salt -- verified: `$6$$` + 86 chars). We COULD mint keys + offline and seed them into a bootstrap config.xml. **We deliberately DO NOT** -- it would couple + provisioning to a vendor hash format we must keep byte-compatible forever, and a mismatch FAILS + SILENTLY (keys that never authenticate). Call the interface; do not re-implement the internals. + (An offline-hash attempt was blocked by the permission classifier mid-session. The block was + RIGHT and produced the better design.) +- **GUARD (harness T5):** the script REFUSES to overwrite an existing creds file. An existing file + means a LIVE key; overwriting the local copy STRANDS it on the edge forever (the secret is + hashed and can never be read back) = unrecoverable credential loss. +- **THE PROVISIONING CHAIN IS NOW HUMAN-FREE:** boot factory nano -> D-112(c) console bootstrap + (SSH + key) -> `opnsense-bootstrap-apikey.sh` -> `opnsense-api.sh` for DHCP/firewall/interfaces. + **There is no config.xml anywhere in that chain.** +- **OPEN QUESTION THIS RAISES:** the template may not need REDUCING -- it may need DELETING. The + console bootstrap + the key minter cover first contact; nothing else needs a config.xml. NOT + ruled; do not delete unilaterally (the `opnsense-edge` module's `config_seed` volume is + INSTANTIATED -- in tfstate -- so removing it is a LIVE change, not a docs change). +- `docs/changelog-20260713-opnsense-apikey-bootstrap.md`. + ## WORKFLOW-DOC STATUS CORRECTED 2026-07-13 (docs only) - `docs/dc-dc-deployment-workflow.md` (the doc a fresh session reads FIRST) had 2026-07-09 status diff --git a/scripts/opnsense-bootstrap-apikey.sh b/scripts/opnsense-bootstrap-apikey.sh new file mode 100755 index 0000000..97e11e0 --- /dev/null +++ b/scripts/opnsense-bootstrap-apikey.sh @@ -0,0 +1,101 @@ +#!/usr/bin/env bash +# scripts/opnsense-bootstrap-apikey.sh [--dry-run] +# +# 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 (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] " >&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" diff --git a/scripts/opnsense-mint-apikey.php b/scripts/opnsense-mint-apikey.php new file mode 100644 index 0000000..c262b6d --- /dev/null +++ b/scripts/opnsense-mint-apikey.php @@ -0,0 +1,69 @@ + + * + * Mints an OPNsense API key using OPNSENSE'S OWN MODEL CODE -- the exact path the + * GUI's "Create and download API key for this user" ticket button invokes + * (Auth\Api\UserController::addApiKeyAction -> $user->apikeys->add()). + * + * WHY THIS AND NOT AN OFFLINE GENERATOR: minting the key ourselves would mean + * re-implementing OPNsense's $6$ SHA-512 crypt storage format and keeping it + * byte-compatible forever. A mismatch fails SILENTLY (keys that never + * authenticate). Calling the vendor's own generator has no format to keep in + * sync -- same principle as D-113(a2) itself: call the interface, don't + * re-implement the internals. + * + * The secret is written to (0600) and NEVER printed: OPNsense + * stores it as crypt(secret,'$6$'), so creation is the only moment it exists in + * cleartext. Stdout gets lengths only. + * + * Run from /usr/local/opnsense/mvc (load_phalcon.php is relative to it). + */ +require_once('script/load_phalcon.php'); + +use OPNsense\Core\Config; +use OPNsense\Auth\User; + +if ($argc < 3) { + fwrite(STDERR, "usage: mint-apikey.php \n"); + exit(1); +} +$username = $argv[1]; +$outfile = $argv[2]; + +Config::getInstance()->lock(); + +$mdl = new User(); +$user = $mdl->getUserByName($username); +if ($user == null) { + fwrite(STDERR, "FAIL: no such user: {$username}\n"); + exit(1); +} + +$new = $user->apikeys->add(); +if (empty($new) || empty($new['key']) || empty($new['secret'])) { + fwrite(STDERR, "FAIL: apikeys->add() returned nothing\n"); + exit(1); +} + +$mdl->serializeToConfig(); +Config::getInstance()->save(); + +$payload = "key=" . $new['key'] . "\nsecret=" . $new['secret'] . "\n"; +$fh = fopen($outfile, 'w'); +if ($fh === false) { + fwrite(STDERR, "FAIL: cannot write {$outfile}\n"); + exit(1); +} +fwrite($fh, $payload); +fclose($fh); +chmod($outfile, 0600); + +printf( + "OK: minted for %s -- key=%d chars, secret=%d chars, wrote %d bytes to %s (0600, secret NOT printed)\n", + $username, + strlen($new['key']), + strlen($new['secret']), + strlen($payload), + $outfile +); diff --git a/tests/opnsense-bootstrap-apikey/run-tests.sh b/tests/opnsense-bootstrap-apikey/run-tests.sh new file mode 100755 index 0000000..9748a5a --- /dev/null +++ b/tests/opnsense-bootstrap-apikey/run-tests.sh @@ -0,0 +1,107 @@ +#!/usr/bin/env bash +# tests/opnsense-bootstrap-apikey/run-tests.sh -- offline harness for +# scripts/opnsense-bootstrap-apikey.sh. Stubs ssh/scp on PATH, so no edge is ever +# contacted and no real key is ever minted. +# +# The load-bearing case is T5: the script MUST refuse to overwrite an existing creds +# file. An existing file means a LIVE key on the edge; overwriting the local copy +# would strand it there permanently, because OPNsense stores the secret as +# crypt(secret,'$6$') and it can never be read back. That is an unrecoverable +# credential loss, and a guard clause is the only thing standing in front of it. +# +# Exit: 0 all pass | 1 any case failed. ASCII + LF. +set -uo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SCRIPT="$HERE/../../scripts/opnsense-bootstrap-apikey.sh" +MINTER="$HERE/../../scripts/opnsense-mint-apikey.php" +TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT +PASS=0; FAIL=0 + +FAKE_KEY="FAKEKEYaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" +FAKE_SECRET="FAKESECRETbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + +ok() { PASS=$((PASS+1)); echo " PASS $1"; } +bad() { FAIL=$((FAIL+1)); echo " FAIL $1"; } + +# --- stub ssh/scp: scp "retrieving" the remote file writes a fake creds payload --- +mk_stubs() { + cat > "$TMP/bin/ssh" <<'STUB' +#!/usr/bin/env bash +exit 0 +STUB + cat > "$TMP/bin/scp" < "\$dest"; exit 0 ;; +esac +STUB + chmod +x "$TMP/bin/ssh" "$TMP/bin/scp" +} +mkdir -p "$TMP/bin"; mk_stubs + +echo "T1: the minter PHP exists and is what we ship" +if [ -f "$MINTER" ] && grep -q "apikeys->add()" "$MINTER"; then + ok "T1 minter present and calls the VENDOR model (apikeys->add)" +else + bad "T1 minter missing or does not call apikeys->add()" +fi + +echo "T2: the minter never prints the secret" +if grep -qE 'printf|echo' "$MINTER" && ! grep -E "printf|echo" "$MINTER" | grep -q "\['secret'\]"; then + ok "T2 minter prints lengths, never the secret itself" +else + bad "T2 minter may print the secret" +fi + +echo "T3: no args -> usage, exit 1" +out="$(bash "$SCRIPT" 2>&1)"; rc=$? +{ [ "$rc" = "1" ] && grep -q usage <<<"$out"; } && ok "T3 usage guard" || bad "T3 usage guard (rc=$rc)" + +echo "T4: dry-run needs no ssh key and contacts nothing" +out="$(env -u OPNSENSE_SSH_KEY bash "$SCRIPT" --dry-run 10.10.0.1 "$TMP/nope.txt" 2>&1)"; rc=$? +{ [ "$rc" = "0" ] && grep -q "DRY-RUN" <<<"$out"; } && ok "T4 dry-run" || bad "T4 dry-run (rc=$rc)" + +echo "T5: REFUSES to overwrite an existing creds file (the unrecoverable-loss guard)" +printf 'key=existing\nsecret=existing\n' > "$TMP/existing.txt" +out="$(OPNSENSE_SSH_KEY=/dev/null PATH="$TMP/bin:$PATH" bash "$SCRIPT" 10.10.0.1 "$TMP/existing.txt" 2>&1)"; rc=$? +if [ "$rc" = "1" ] && grep -q "refusing to overwrite" <<<"$out"; then + # and it must NOT have touched the file + if grep -q "^key=existing$" "$TMP/existing.txt"; then + ok "T5 refuses to clobber a live credential, and leaves it intact" + else + bad "T5 refused but MODIFIED the existing file" + fi +else + bad "T5 did NOT refuse to overwrite an existing creds file (rc=$rc) -- would strand a live key" +fi + +echo "T6: missing SSH key -> fail loud (not a silent agent-auth attempt)" +out="$(env -u OPNSENSE_SSH_KEY PATH="$TMP/bin:$PATH" bash "$SCRIPT" 10.10.0.1 "$TMP/out6.txt" 2>&1)"; rc=$? +{ [ "$rc" = "1" ] && grep -q "OPNSENSE_SSH_KEY" <<<"$out"; } && ok "T6 ssh-key guard" || bad "T6 ssh-key guard (rc=$rc)" + +echo "T7: happy path writes a 0600 creds file in the shape opnsense-api.sh parses" +out="$(OPNSENSE_SSH_KEY=/dev/null PATH="$TMP/bin:$PATH" bash "$SCRIPT" 10.10.0.1 "$TMP/out7.txt" 2>&1)"; rc=$? +if [ "$rc" = "0" ] && [ -f "$TMP/out7.txt" ]; then + mode=$(stat -c %a "$TMP/out7.txt") + k=$(grep -c '^key=' "$TMP/out7.txt"); s=$(grep -c '^secret=' "$TMP/out7.txt") + if [ "$mode" = "600" ] && [ "$k" = "1" ] && [ "$s" = "1" ]; then + ok "T7 minted file: mode 600, one key= line, one secret= line" + else + bad "T7 bad output file (mode=$mode key=$k secret=$s)" + fi +else + bad "T7 happy path failed (rc=$rc): $out" +fi + +echo "T8: the secret never appears on stdout/stderr" +case "$out" in + *"$FAKE_SECRET"*) bad "T8 SECRET PRINTED to output";; + *) ok "T8 secret absent from output";; +esac + +echo +echo "RESULT: PASS=$PASS FAIL=$FAIL" +[ "$FAIL" -eq 0 ] && { echo "ALL PASS"; exit 0; } || { echo "FAILURES"; exit 1; }