#!/usr/bin/env bash
# scripts/opnsense-api.sh [--dry-run] <METHOD> <api-path> [json-body]
#
# Thin REST client for the OPNsense API -- the D-113 (a2) config path.
#
# D-113 ADOPTED (a2): configuration of the edge moves OFF hand-authored
# config.xml and ONTO the documented REST API. Hand-editing the appliance's
# GUI-owned config.xml was the single root cause of DOCFIX-191 (no sshd/key),
# DOCFIX-192 (no console) and DOCFIX-193 (no DHCP), plus the 667-element
# migration self-heal we ended up depending on. None of those is expressible
# through the API, where sshd/DHCP/rules are typed resources with defaults.
#
# Credentials: read from a file (NEVER from argv, NEVER echoed). Mint the key
# in the GUI -- System > Access > Users > root > API keys > "+" -- which
# downloads a file containing:
# key=...
# secret=...
# Save it to $OPNSENSE_API_CREDS (default ~/vr1-office1-creds/opnsense-api.txt).
# The secret is passed to curl via --config on STDIN, so it never appears in
# argv (i.e. never in `ps`). Do not "improve" this into `curl -u`.
#
# TLS: the edge presents a SELF-SIGNED cert that OPNsense REGENERATES ON EVERY
# BOOT (measured 2026-07-13 -- "Created web GUI TLS certificate" appears in the
# config revision trail after each boot). Pinning it is therefore pointless, so
# we use --insecure. This is acceptable ONLY because the transport is a private
# lab LAN leg (virbr2). Do NOT copy this flag to anything tenant-facing.
#
# Exit: 0 ok | 1 usage/credential/config error | 2 HTTP error from the API.
set -euo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
: "${OPNSENSE_API_CREDS:=$HOME/vr1-office1-creds/opnsense-api.txt}"
DRY_RUN=0
if [ "${1:-}" = "--dry-run" ]; then DRY_RUN=1; shift; fi
METHOD="${1:-}"
APIPATH="${2:-}"
BODY="${3:-}"
usage() {
echo "usage: opnsense-api.sh [--dry-run] <GET|POST> <api-path> [json-body]" >&2
echo " e.g. opnsense-api.sh GET core/firmware/status" >&2
echo " opnsense-api.sh POST kea/dhcpv4/set '{\"dhcpv4\":{...}}'" >&2
exit 1
}
[ -n "$METHOD" ] && [ -n "$APIPATH" ] || usage
case "$METHOD" in
GET|POST) ;;
*) echo "FAIL: method must be GET or POST (got '$METHOD')" >&2; usage ;;
esac
# The API host is NEVER inferred (hard rule 2): it is measured and passed in.
if [ -z "${OPNSENSE_API_HOST:-}" ]; then
echo "FAIL: \$OPNSENSE_API_HOST not set (e.g. 10.10.0.1) -- refusing to guess the edge address" >&2
exit 1
fi
# Leading slashes on the path are a common typo; normalize rather than 404.
APIPATH="${APIPATH#/}"
APIPATH="${APIPATH#api/}"
URL="https://${OPNSENSE_API_HOST}/api/${APIPATH}"
if [ "$DRY_RUN" = "1" ]; then
# Dry-run exists so the harness can prove URL construction WITHOUT credentials
# and without touching the network. It must never read the creds file.
echo "DRY-RUN ${METHOD} ${URL}"
[ -n "$BODY" ] && echo "DRY-RUN body: ${BODY}"
exit 0
fi
[ -f "$OPNSENSE_API_CREDS" ] || {
echo "FAIL: credentials file not found: $OPNSENSE_API_CREDS" >&2
echo " Mint one in the GUI: System > Access > Users > root > API keys > '+'" >&2
exit 1
}
# Parse key/secret WITHOUT echoing them. Tolerate OPNsense's downloaded format
# (key=...\nsecret=...), CRLF, and surrounding quotes.
API_KEY="$(sed -n 's/\r$//; s/^[[:space:]]*key[[:space:]]*=[[:space:]]*"\{0,1\}\([^"]*\)"\{0,1\}[[:space:]]*$/\1/p' "$OPNSENSE_API_CREDS" | head -1)"
API_SECRET="$(sed -n 's/\r$//; s/^[[:space:]]*secret[[:space:]]*=[[:space:]]*"\{0,1\}\([^"]*\)"\{0,1\}[[:space:]]*$/\1/p' "$OPNSENSE_API_CREDS" | head -1)"
[ -n "$API_KEY" ] || { echo "FAIL: no 'key=' line in $OPNSENSE_API_CREDS" >&2; exit 1; }
[ -n "$API_SECRET" ] || { echo "FAIL: no 'secret=' line in $OPNSENSE_API_CREDS" >&2; exit 1; }
# Secret goes to curl over STDIN via --config, so it is absent from argv/ps.
CURL_ARGS=(--silent --show-error --insecure --config - --request "$METHOD" --url "$URL"
--write-out '\n__HTTP_STATUS__:%{http_code}\n')
if [ -n "$BODY" ]; then
CURL_ARGS+=(--header 'Content-Type: application/json' --data "$BODY")
fi
RESP="$(printf 'user = "%s:%s"\n' "$API_KEY" "$API_SECRET" | curl "${CURL_ARGS[@]}")" || {
echo "FAIL: curl could not reach $URL" >&2
exit 2
}
STATUS="$(printf '%s' "$RESP" | sed -n 's/^__HTTP_STATUS__:\([0-9]*\)$/\1/p' | tail -1)"
printf '%s\n' "$RESP" | grep -v '^__HTTP_STATUS__:'
case "$STATUS" in
2*) exit 0 ;;
401|403)
echo "FAIL: HTTP $STATUS -- the API rejected the credential." >&2
echo " Note OPNsense API keys authenticate as their OWNING USER; a GUI password will NOT work here." >&2
exit 2 ;;
"")
echo "FAIL: no HTTP status returned from $URL" >&2
exit 2 ;;
*)
echo "FAIL: HTTP $STATUS from $URL" >&2
exit 2 ;;
esac