Files
unpoller_unpoller/docs/plan-protect-only-support.md
Cooper Ry LeesandClaude Opus 5 31cc5a0f31 feat: poll Protect-only consoles via disable_network (closes #1066)
A UNVR or UNVR Pro runs UniFi Protect with no Network application installed.
UnPoller could not poll one at all: NewUnifi ends with GetServerData(), a GET of
/proxy/network/status, which such a console answers with its UniFi OS SPA HTML.
The controller entry died during initialisation and re-failed every interval,
never even printing a config summary -- while the Protect Integration API on the
same host answered every endpoint with the same key.

Set disable_network = true on that controller. It defaults to false, so nothing
about an existing config changes.

The Protect collectors were already complete and already not site-scoped; three
things stood between them and a Protect-only console:

  - getUnifi now calls unifi.NewProtectClient, which skips the Network probe and
    validates the Protect Integration API instead (unpoller/unifi#240).

  - pollController aborted on getFilteredSites long before reaching
    collectProtect, and collectControllerEvents did the same before
    collectProtectLogs. The Network pass is extracted into pollNetwork and
    skipped wholesale; the event collector list reduces to collectProtectLogs,
    the only site-independent one.

  - Metrics counted a poll successful only if it produced devices or clients. A
    Protect-only console produces neither, so a filtered scrape of one -- the
    Prometheus per-target path -- fell through to the dynamic-controller branch
    and reported ErrDynamicLookupsDisabled despite a successful collection.
    ProtectDevices now counts too.

Two smaller things worth calling out for reviewers:

  - extractDevices dereferenced metrics.Devices unguarded. That was already a
    latent panic; skipping the Network pass makes it reachable, so it is fixed
    here rather than left for the first person to hit it.

  - RawMetrics answers the raw-path kind for these consoles and rejects the
    site-scoped kinds with ErrNetworkDisabled. Returning an empty result would
    read as "this console has no devices" rather than "wrong question".

warnProtectOnly logs an error, without failing the controller, for the two
configurations that can never collect anything: disable_network with neither
Protect save flag, and save_protect_devices with no key to authenticate with.
Silently collecting nothing is the failure mode hardest to spot in a log.

pkg/inputunifi had no tests before this. input_test.go follows inputunas'
input_test.go: an httptest fake UNVR serving the console's SPA HTML for
everything but the Protect paths and the login, covering initialisation,
metrics, events, the filtered scrape, RawMetrics, both warnings, config binding
across toml/json/yaml/env, and that the shipped examples leave Network enabled.
TestProtectOnlyControllerFailsWithoutFlag pins the original bug against that
same console, so the flag is demonstrably what makes the difference.

Requires github.com/unpoller/unifi/v6 with NewProtectClient (unpoller/unifi#240).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GVreutpEATmBjm6PBw9RjQ
2026-08-31 13:08:27 +00:00

125 lines
7.3 KiB
Markdown

# Plan: Protect-only console support (issue #1066)
v5 added UniFi Protect device metrics, but they cannot be collected from a **Protect-only
console** — a UNVR or UNVR Pro, which runs UniFi Protect with no Network application
installed. The Protect collectors are complete; only controller initialisation blocks them.
## Design decision: a `disable_network` flag, not a separate input plugin
UNAS Pro got its own plugin (`pkg/inputunas`, see [plan-unas-support.md](plan-unas-support.md))
because it shares nothing with a UniFi controller: its own credentials, its own host, its own
JSON API. A Protect-only console is the opposite case. It is reached at the same URL, with the
same `Controller` config, by the same `*unifi.Unifi` client, and `collectProtect` /
`collectProtectLogs` already live in `inputunifi` and are already not site-scoped. A second
plugin would duplicate the controller config block to gate two calls it already makes.
So: a per-controller `disable_network` flag, defaulting to `false`, as
[@platinummonkey asked for on the issue](https://github.com/unpoller/unpoller/issues/1066).
Detection is by explicit flag only. The issue documents two unauthenticated probes that
identify a UNVR (`/api/system` reporting `hardware.shortname`, and `/proxy/network/status`
answering HTML rather than a 401). Auto-detection was cut: it adds network calls and cached
state to every startup, and the flag has to exist as an override regardless.
### The blockers
1. **`unifi.NewUnifi()` cannot construct a client for the console at all.** It ends in
`GetServerData()` → GET `APIStatusPath` (`/status`), which `path()` rewrites to
`/proxy/network/status`. With no Network application to route to, UniFi OS serves its own
SPA HTML and the call fails on its first byte:
`invalid character '<' looking for beginning of value`. `getUnifi()` treats any non-429
error as fatal, so the entry never even prints a config summary.
2. **`pollController` aborts on `getFilteredSites`** long before it reaches `collectProtect`.
`collectControllerEvents` aborts in the same place, before `collectProtectLogs`.
3. **`Metrics()` counts a poll successful only if it produced devices or clients.** A
Protect-only console produces neither, so a filtered scrape of one — the Prometheus
per-target path — falls through to the dynamic-controller branch and reports
`ErrDynamicLookupsDisabled` despite a successful collection.
What already works in our favour: `path()` passes anything starting with `/proxy/` through
untouched, so the Protect Integration paths need no changes; `collectProtect` and
`collectProtectLogs` take no `sites` argument; and every output plugin already handles
`ProtectDevices`.
## Part 1 — `../unifi` (github.com/unpoller/unifi/v6)
`NewProtectClient(config *Config) (*Unifi, error)` in `protect.go`, modelled on
`NewUNASClient` in `unas.go`, which exists for the same underlying reason.
- Rejects a nil config, and a config with neither `ProtectAPIKey` nor `APIKey`:
Integration/v1 is `X-API-Key` only and has no cookie fallback.
- Sets `u.new = true` directly rather than probing with `checkNewStyleAPI()`. A Protect
console is always a UniFi OS console, so `Login()` resolves to `/api/auth/login` without
depending on how the console answers a GET of `/`.
- Logs in **only** when `APIKey == "" && User != ""`. Integration/v1 needs no session, but the
legacy Protect endpoints (`GetProtectLogs`, `GetProtectEventThumbnail`) authenticate with a
session cookie. Skipping login when `APIKey` is set is required for correctness: `Login()`
routes through `/status` in that case, the one endpoint this console cannot serve.
- Ends by probing `/v1/meta/info`, the way `NewUnifi` ends by probing Network, so a caller
that gets no error has a console it can really poll.
- Returns a plain `*Unifi` rather than a wrapper type, unlike `NewUNASClient`: the Protect
getters are already `*Unifi` methods.
**`ServerStatus` must be populated, and that is not cosmetic.** `Unifi` embeds
`*ServerStatus`, so leaving it nil turns any caller's `u.ServerVersion` into a nil
dereference — including `inputunifi`'s config summary. `ServerVersion` holds the *Protect*
application version, since a Protect-only console has no Network version to report.
## Part 2 — `unpoller`
### 2a. Config
`Controller.DisableNetwork *bool`, tagged for json/toml/xml/yaml, defaulting to `false` in
`setDefaults` and inheritable from `[unifi.defaults]` via `setControllerDefaults`, matching
every neighbouring flag. Added to `formatControllers` so the web UI reflects it.
### 2b. Skipping the Network application
| Site | Change |
|---|---|
| `getUnifi` | Calls `unifi.NewProtectClient` instead of `unifi.NewUnifi`, inside the unchanged 429-retry loop. |
| `Initialize`, `DebugInput` | Skip `checkSites`. |
| `pollController` | The Network pass is extracted into a new `pollNetwork(c, sites, m) error` and skipped wholesale, leaving site discovery and `collectProtect` behind. |
| `collectControllerEvents` | Skips site discovery and reduces the collector list to `collectProtectLogs`, the only site-independent one. |
| `Metrics` | Counts `ProtectDevices` toward a successful poll. |
| `RawMetrics` | Answers the raw-path kind and rejects the site-scoped kinds with `ErrNetworkDisabled`, rather than returning a confusing empty result. |
| `logController` | Marks the mode and omits the Network-only lines. |
`extractDevices` also gains a nil guard on `metrics.Devices`, which it dereferenced
unconditionally — a latent panic in its own right, now reachable whenever the Network pass
does not run.
### 2c. Warnings, not failures
`warnProtectOnly` logs an error at startup for the two configurations that can never collect
anything: `disable_network` with neither Protect save flag, and `save_protect_devices` with no
`protect_api_key` or `api_key`. Neither is fatal — silently collecting nothing is the failure
mode hardest to diagnose from a log, so it is called out rather than acted on.
### 2d. Docs and tests
`pkg/inputunifi/README.md` gains a "Protect-only consoles (UNVR)" section; the three
`examples/up.*.example` files gain `disable_network`, defaulting off.
`pkg/inputunifi` had no tests before this change. The new `input_test.go` follows
`pkg/inputunas/input_test.go`: external test package, an `httptest` fake UNVR that serves the
console's SPA HTML for everything but the Protect paths and the login, and a prose comment
above each test. It covers initialisation, metrics, events, the filtered scrape, `RawMetrics`,
both warnings, config binding across toml/json/yaml/env, and that the shipped examples do not
disable the Network application. `TestProtectOnlyControllerFailsWithoutFlag` pins the original
bug against the same fake console.
## Sequencing across the two repos
`unpoller` consumes `unpoller/unifi` as a tagged module, so `NewProtectClient` has to exist
and be released before anything can call it. Part 1 lands and is tagged first; Part 2 stays a
draft until then, developed against a local `go.work` workspace so `go.mod` is never
temporarily rewritten.
## Open items
- Auto-detection of Protect-only consoles, if operators find the flag a papercut.
- The Protect bootstrap API (`/proxy/protect/api/bootstrap`) carries richer per-camera data —
`isRecording`, `isConnected`, NVR `version` — but it is private and undocumented, so it is
out of scope here as it was in #1015.