Newer
Older
openstack-caracal-dc-dc / scripts / opnsense-api.sh
#!/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