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

7.3 KiB

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) 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.

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.