mirror of
https://github.com/unpoller/unpoller.git
synced 2026-09-30 03:21:28 +02:00
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
125 lines
7.3 KiB
Markdown
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.
|