ADR-030: Dual pfSense API backend architecture (Nexus Phase A)¶
Status: research/architecture only. Nothing in this ADR is implemented, wired, or reachable. No production behavior changed.
Update (Phase B, 2026-08-17): this document is preserved unmodified as
the historical Phase A record. Two of its findings were superseded, not
retracted here — see docs/NEXUS_COMPATIBILITY_MATRIX.md's "Phase B
update" section for the full detail: (1) the device-routing question this
ADR left UNKNOWN is now CONFIRMED, directly from Netgate's own example
source code; (2) pfsense_get_gateway_status, listed below as one of the
three ports this ADR's skeleton targeted, was field-by-field diffed in
Phase B and downgraded from ADAPTABLE to PARTIAL — a faithful
implementation was found not achievable, and no concrete adapter was
built. docs/adr/ADR-031-backend-target-identity-boundary.md was added in
Phase B, independent of that outcome.
Motivation¶
The existing backend talks exclusively to the community pfSense-pkg-RESTAPI
package running on a single appliance. Netgate maintains its own, separate
official API — publicly described as "Nexus," backed by the
Netgate/pfsense-api repository and OpenAPI schema
(pfapi_openapi.yml, Apache-2.0) — aimed at pfSense Plus multi-instance
management. This ADR records what Phase A ("Nexus Phase A") established
about whether that official API can become a second, optional backend for
this project's 42 default-profile READ tools, and separately, what it would
take (not what is done) for the existing WRITE architecture to eventually
support it too.
Official Netgate API discovery (FACT / INFERENCE / UNKNOWN)¶
- FACT. Nexus is "a GUI configuration interface and API for pfSense Plus software with multi-instance management (MIM) capabilities," available in "pfSense Plus software versions 25.07 and later" (docs.netgate.com/pfsense/en/latest/nexus/index.html). No Community Edition (CE) support is documented.
- FACT. The
Netgate/pfsense-apirepository's README describes itself as providing "a RESTful interface provided by the pfSense Multi-instance Management Controller." The OpenAPI schema's owninfo.descriptionfield reads "# Nexus Multi-instance Management APIs" — directly confirming this schema is the Nexus API, not a separate or older thing. - FACT. The schema is OpenAPI 3.0.3,
info.version: "1.0", 486 paths / 755 operations, Apache-2.0 licensed, noservers:entry (base URL is deployment-specific, not documented in the schema). - FACT. Authentication is JWT-based:
POST /loginwith base64-encodedusername/password(+ optionalsecondfactor) returns an access token plus a refresh-token cookie, refreshed viaPOST /login/refresh. This is a Controller-account credential, not a per-pfSense-user API key. - FACT. The Controller maintains a device registry (
/mim/devices*) where each managed device has its ownaddress/address6, adevice_cert/device_key(public-key-based device identity), and astatefield explicitly enumerated asactive, error, offline, rebooting, pending (pending auth). - INFERENCE. The Controller is architecturally a centralized service
distinct from any individual pfSense appliance, mediating requests to
registered devices — supported by the device-registry model above, but
the exact request-routing mechanism for ordinary (non-
/mim/*) data-plane paths (how a call to, say,/aliasesis scoped to one specific managed device) is not established from the schema alone. - UNKNOWN. Exactly how a single data-plane operation (e.g.
GET /aliases) is routed to one specific managed device when multiple devices are registered. No device-selector header, path segment, or query parameter was found on the endpoints inspected. This is the single most important open question before any live integration could be attempted. - UNKNOWN. Whether GET endpoints are guaranteed side-effect-free by
Netgate. Several GET-named diagnostic endpoints (
/diag/ping,/diag/traceroute,/diag/smart/test) appear from their names to trigger live network probes or hardware tests even though modeled asGET— consistent with this project's own standing rule that "GET does not automatically mean safe." None of these were called, and none are candidates for any future READ adapter. - FACT. Error handling uses exactly two HTTP status codes across all
755 operations:
200and400. Error detail (errcode/errlevel/errmsg) lives in the JSON body'sErrorschema, not in HTTP status — a genuinely different error model than the currentRestApiClient's 401/403-by-status-code mapping. - FACT. No pagination parameters were found on any of three
representative large-collection endpoints checked (
/services/dhcp/leases,/system/users,/firewall/rules/interface/{interface}). - FACT. A system-wide "dirty subsystems" concept exists
(
GET /system/config/dirty,DirtySubsystemsschema) — broader in scope than the currentFIREWALL_APPLY_STATUSendpoint, which is firewall-subsystem-specific. - UNKNOWN. HA/CARP behavior beyond
GET /services/carp/statusexisting — its response schema and any HA-sync-configuration equivalent to the currentSYSTEM_HASYNCendpoint were not found (positive search; see the compatibility matrix).
Why the community backend remains¶
It is the only backend with any live evidence at all. The scoped
least-privilege identity, the two independently-verified live WRITE
executions, and the entire ADR-026 evidence chain are all evaluated against
pfSense-pkg-RESTAPI specifically. None of that evidence transfers to
Nexus by resemblance, similarity of endpoint names, or shared underlying
pfSense codebase — see "WRITE evidence isolation" below.
Compatibility results¶
See docs/NEXUS_COMPATIBILITY_MATRIX.md for the full 42-row table.
Summary: 0 DIRECT, 32 ADAPTABLE, 3 PARTIAL, 5 UNSUPPORTED, 1 UNKNOWN, 1
LOCAL. No tool was classified DIRECT — every plausible candidate endpoint
inspected deeply enough had at least one confirmed semantic gap (a required
current-side model field with no confirmed Nexus source field, an
identifier-shape mismatch, or a genuine structural difference such as
per-interface aggregation).
Five tools have no Nexus equivalent found by positive search:
pfsense_get_auth_keys (fundamentally different auth model — JWT sessions,
not per-user API keys), pfsense_get_cron_jobs, pfsense_get_firewall_states_size,
pfsense_get_system_hasync, and pfsense_get_system_restapi_settings
(this one is inherently community-package-specific and can never have a
Nexus equivalent by definition).
Backend abstraction decision¶
Decision: capability-specific ports/adapters (option C in the target
architecture), not a domain-service-level facade and never a
transport-level generic request() surface.
Rejected alternatives:
- (A) Transport-level abstraction (swap the Transport implementation
under the existing RestApiClient) — rejected because Nexus's path
shapes, auth model (JWT + refresh, not a static bearer/API key), and
error model (body-encoded, not status-code-encoded) are different enough
that RestApiClient's GET-only path-construction logic
(/api/{version}{path_suffix}) does not generalize; forcing it to would
either weaken its contract or require backend-specific branches inside a
module whose entire value is being the single reviewed chokepoint.
- (B) Domain-service-level facade (one PfSenseClient-shaped interface
both backends implement) — rejected for the same reason ADAPTABLE
dominates the matrix: the domain models' required fields (e.g.
GatewayStatus.id: int, SystemStatus.disk_usage: int) cannot always be
populated from Nexus data without inventing values. A facade that must
either fabricate fields or make them silently optional would weaken the
existing fail-closed, no-guessing posture this project has held
throughout every prior ADR.
- (D) Hybrid — considered, but a hybrid that keeps (A) for the parts
that overlap cleanly still inherits (A)'s and (B)'s problems for the
parts that don't, without a clean seam. Not adopted.
(C) means: small, typed Protocol interfaces, one per capability
(mirroring this project's own existing per-tool-per-model structure), each
returning the existing domain model unchanged. A capability whose fields
cannot be honestly populated from Nexus data (e.g. SystemStatusReader, per
the matrix's PARTIAL finding) simply does not get a Nexus implementation
yet — the Protocol can exist without every backend implementing every
member being wired anywhere. This keeps the abstraction genuinely optional
and additive: zero existing files change, capability adapters can be added
one at a time only when a real, non-fabricated mapping exists, and nothing
about tool registration, capability policy, or the Tier1 boundary needs to
know a second backend exists until one is actually wired in — which this
Phase does not do.
Security architecture review (Phase 4)¶
Challenging the target diagram from the task brief rather than assuming it:
- Backend substitution attack. If a future backend-selection mechanism
lived below the capability/policy/authorization layers (as the target
diagram shows), an attacker who could influence backend selection after
authorization was granted could potentially redirect a signed mutation
intended for the community backend's specific appliance toward a
different target reachable only through Nexus. This is why backend
identity must become part of signed/canonical state before any Nexus
WRITE work is ever authorized — stating this requirement explicitly, not
changing any code to satisfy it. Today, backend identity is implicit
(there is only one backend), so no code change is needed yet, but any
future ADR introducing a second live backend for WRITE must bind backend
identity into the plan/execution-intent digest, analogous to how
tier1/alias_description.py'starget_precondition/rollback_snapshotalready bind live pfSense state. - Differing target identity. Confirmed by the matrix: Nexus identifiers
are strings (device IDs, gateway names, certificate
refids), not the integersResolvedTransportTarget.numeric_locatorand several domain models (GatewayConfig.id,GatewayStatus.id) assume. A Nexus adapter cannot honestly satisfy those fields without inventing a mapping. This is a real blocker for WRITE, not just a READ inconvenience. - Differing apply/dirty semantics. Nexus's dirty-state check is system-wide; the community backend's is firewall-subsystem-scoped. A reconciliation check written against one would not mean the same thing against the other.
- Differing failure ambiguity / error model. The community backend
distinguishes auth failure from other errors by HTTP status
(401/403 →
PfSenseAuthError). Nexus encodes everything as200/400with an in-body error code. A reconciliation or read-back check that currently branches on HTTP status would silently misclassify Nexus failures if backends were ever unified without addressing this. - Controller compromise implications. Because Nexus is Controller-
mediated (device credentials/certs held centrally per the
/mim/devices*registry), a compromised Controller is a categorically different threat than a compromised single-appliance API key — it is a blast-radius multiplier across every device it manages. This alone is a strong reason the existing scoped, single-appliance Tier1 identity should never be treated as equivalent evidence for a Controller-mediated backend. - Multi-node confusion. The device
statefield (active/error/offline/rebooting/pending) confirms devices are not always reachable/consistent from the Controller's point of view — a reconciliation design assuming one directly-reachable appliance does not automatically generalize. - Normalization hiding security-relevant information. Any future normalization layer that silently drops or defaults a field the current domain model requires (rather than failing closed) would be exactly the kind of behavior this project's fail-closed posture forbids. This is why Phase 6 of this run implemented no concrete Nexus reader for any capability — every one inspected deeply enough had at least one required field with no honest source, and fabricating a default would violate that posture.
- Loss of deterministic read-back. Not evaluated this pass beyond the general dirty/apply-scope finding above; requires its own future investigation once (if) a specific WRITE-candidate endpoint on Nexus is chosen.
No authorization semantics were changed to accommodate any of the above. This section documents requirements for a later ADR, per the task's own explicit instruction to prefer that over premature security-critical changes.
WRITE evidence isolation¶
The community backend's FIREWALL_ALIAS_DESCRIPTION endpoint remains
verified=True, unchanged by this ADR. Any future Nexus equivalent must
be treated as UNVERIFIED until it independently earns its own acceptance
process, mirroring exactly the evidentiary bar ADR-026 required for the
community backend (live execution, independent re-verification,
least-privilege identity, TPM witness advancement, authoritative read-back)
— not inherited, not assumed, not transferred by code or endpoint
similarity.
Nexus WRITE readiness¶
NOT DESIGNED. Phase 3's tracing (MCP exposure → capability/profile gate → preparation → plan digest → authorization → RecoveryContract → confirmation → executor → REST mutation → read-back → apply/dirty → witness) was not attempted for a specific Nexus endpoint this pass, because Phase 2/4 already surfaced blocking prerequisites (identifier-shape mismatch, backend-identity-not-yet-bound-to-signed-state) that would make any such design premature and likely to be discarded once resolved.
Migration strategy (if a future ADR proceeds)¶
- Resolve the device-routing UNKNOWN (Phase 1) — likely requires either a live Nexus/Controller instance to observe request routing directly, or direct outreach to Netgate.
- Pick the single highest-confidence ADAPTABLE tool (e.g.
pfsense_get_gateway_status, Medium-High confidence) as the first real NexusCapabilityAdapterimplementation, including a full field-by-field schema diff (not yet done for any tool in this pass) before writing any normalization code. - Add a
PFSENSE_BACKEND(or similar) config value gating backend selection, defaulting to the existing community backend unconditionally. - Only after multiple READ capabilities are proven should a Nexus WRITE
design even begin, and only as its own ADR with its own acceptance
process — never by extending
verified=Trueacross backends.
Rejected alternatives¶
Covered inline above (transport-level and domain-service-level abstractions, and the hybrid). No alternative to "capability-specific ports" was found that avoided either fabricating field values or re-introducing backend-specific branching into a currently-clean chokepoint.
Future acceptance requirements¶
Unchanged from ADR-026's own bar, applied fresh to Nexus: independent live execution against a disposable target, least-privilege credential proof, authoritative read-back, and (if hardware-witness parity is desired) its own anti-rollback evidence — none of which this ADR performed or claims.