#!/usr/bin/env bash
# scripts/opnsense-plugins.sh [--dry-run] <list|apply> [profile]
#
# Install a NAMED OPNsense plugin PROFILE on an edge via the D-113 firmware API,
# the reusable tool D-129 pins for DC-edge bring-up (runbooks/dc-dc-phase2-tofu-
# dc-substrate.md, "Edge plugin profile"). It is a thin ORCHESTRATOR over
# scripts/opnsense-api.sh -- that script owns auth + secret handling (creds never
# in argv/context); this one owns the profile table + the install DISCIPLINE.
#
# THE DISCIPLINE (both learned the hard way 2026-07-18, D-129 Verification):
# 1. ONE PLUGIN AT A TIME + POLL. Two back-to-back core/firmware/install POSTs
# collide on the OPNsense firmware LOCK -- the second fails SILENTLY. So we
# install one, poll core/firmware/upgradestatus to completion, THEN the next.
# 2. os-qemu-guest-agent PACKAGE != a working guest agent. It also needs the
# libvirt domain to expose the org.qemu.guest_agent.0 virtio channel (an IaC
# job in modules/opnsense-edge) AND the service enabled AND an edge restart.
# This script installs the package and LOUDLY says so; it cannot do the rest.
#
# Live use needs OPNSENSE_API_HOST (e.g. 10.10.0.1) + the API creds file that
# opnsense-api.sh reads. --dry-run prints the intended API calls and touches no
# network and reads no creds (so the harness can prove the shape offline).
#
# Exit: 0 ok | 1 usage/arg error | 2 an install did not converge.
set -euo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
API="$HERE/opnsense-api.sh"
# Poll bounds for the firmware action (live only). Generous: a plugin pull over a
# slow lab uplink can take a while; we would rather wait than declare a false fail.
: "${PLUGIN_POLL_MAX:=90}" # iterations
: "${PLUGIN_POLL_SLEEP:=2}" # seconds between polls
DRY_RUN=0
if [ "${1:-}" = "--dry-run" ]; then DRY_RUN=1; shift; fi
CMD="${1:-}"
PROFILE="${2:-}"
usage() {
cat >&2 <<EOF
usage: opnsense-plugins.sh [--dry-run] <list|apply> [profile]
list print the known profiles and their plugins
apply <profile> install the profile's plugins, ONE AT A TIME + poll
profiles:
vr1-edge the VR1 (virtual) edge base profile -- D-129
metal-edge Roosevelt baremetal edge profile (site-specific, see notes)
e.g. OPNSENSE_API_HOST=10.10.0.1 opnsense-plugins.sh apply vr1-edge
opnsense-plugins.sh --dry-run apply vr1-edge
EOF
exit 1
}
# --- Profile table (the single source of truth for "what is on an edge"). -------
# vr1-edge: adopted by D-129 2026-07-18. os-qemu-guest-agent = clean libvirt-guest
# coordination (needs the channel, see warning below); os-iperf = transit
# throughput validation.
profile_plugins() {
case "$1" in
vr1-edge) echo "os-qemu-guest-agent os-iperf" ;;
# metal-edge is DELIBERATELY not a flat list: os-nut vs os-apcupsd and the
# intel-vs-amd microcode package are per-host choices (D-129). Emit the
# unambiguous members; the operator adds the site-specific ones by hand.
metal-edge) echo "os-smart os-lldpd" ;;
*) return 1 ;;
esac
}
list_profiles() {
local p
for p in vr1-edge metal-edge; do
printf '%-11s %s\n' "$p" "$(profile_plugins "$p")"
done
echo "metal-edge NOTE: also add os-nut|os-apcupsd + os-cpu-microcode-intel|-amd per host (D-129)."
}
# --- qemu-guest-agent honesty: the package is necessary but NOT sufficient. -----
qga_warning() {
cat >&2 <<'EOF'
NOTE: os-qemu-guest-agent installs the PACKAGE only. For a WORKING guest agent the
libvirt domain must expose the org.qemu.guest_agent.0 virtio channel (IaC:
modules/opnsense-edge), the service must be enabled, and the edge restarted.
New DC edges should be BORN with the channel; office1-opnsense retrofits at
its next scheduled restart. (D-129 Verification, 2026-07-18.)
EOF
}
api() { bash "$API" ${DRY_RUN:+--dry-run} "$@"; }
# Poll core/firmware/upgradestatus until the running action finishes. Live only;
# dry-run returns immediately (nothing was actually kicked off).
poll_until_done() {
[ "$DRY_RUN" = "1" ] && { echo "DRY-RUN would poll core/firmware/upgradestatus"; return 0; }
local i resp
for i in $(seq 1 "$PLUGIN_POLL_MAX"); do
resp="$(bash "$API" GET core/firmware/upgradestatus 2>/dev/null || true)"
# status is "running" while the action is live; anything else = finished.
printf '%s' "$resp" | grep -qE '"status"[[:space:]]*:[[:space:]]*"running"' || return 0
sleep "$PLUGIN_POLL_SLEEP"
done
echo "FAIL: firmware action did not converge in $((PLUGIN_POLL_MAX*PLUGIN_POLL_SLEEP))s" >&2
return 2
}
apply_profile() {
local prof="$1" plugins pkg
plugins="$(profile_plugins "$prof")" || { echo "FAIL: unknown profile '$prof'" >&2; usage; }
case " $plugins " in *" os-qemu-guest-agent "*) qga_warning ;; esac
for pkg in $plugins; do
echo "== install $pkg =="
api POST "core/firmware/install/$pkg" # kick off (async on the edge)
poll_until_done # ... and WAIT before the next (the lock)
done
echo "OK: profile '$prof' applied ($plugins)"
}
case "$CMD" in
list) [ -n "$PROFILE" ] && usage; list_profiles ;;
apply) [ -n "$PROFILE" ] || usage; apply_profile "$PROFILE" ;;
*) usage ;;
esac