Files
unpoller_unpoller/pkg/inputunifi/interface.go
T
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

427 lines
12 KiB
Go

package inputunifi
/* This file contains the three poller.Input interface methods. */
import (
"fmt"
"strings"
"github.com/unpoller/unifi/v6"
"github.com/unpoller/unpoller/pkg/poller"
"github.com/unpoller/unpoller/pkg/webserver"
)
var (
ErrDynamicLookupsDisabled = fmt.Errorf("filter path requested but dynamic lookups disabled")
ErrControllerNumNotFound = fmt.Errorf("controller number not found")
ErrNoFilterKindProvided = fmt.Errorf("must provide filter: devices, clients, other")
ErrNetworkDisabled = fmt.Errorf("controller has disable_network set: no Network application to query")
)
// Initialize gets called one time when starting up.
// Satisfies poller.Input interface.
func (u *InputUnifi) Initialize(l poller.Logger) error {
if u.Config == nil {
u.Config = &Config{Disable: true}
}
if u.Logger = l; u.Disable {
u.Logf("UniFi input plugin disabled or missing configuration!")
return nil
}
// Discover remote controllers if remote mode is enabled at config level
if u.Remote && u.RemoteAPIKey != "" {
u.Logf("Remote API mode enabled, discovering controllers...")
discovered, err := u.discoverRemoteControllers(u.RemoteAPIKey)
if err != nil {
u.LogErrorf("Failed to discover remote controllers: %v", err)
} else if len(discovered) > 0 {
// Replace controllers with discovered ones when using config-level remote mode
u.Controllers = discovered
u.Logf("Discovered %d remote controller(s)", len(discovered))
}
} else {
// Only set default controller if not using config-level remote mode
if u.setDefaults(&u.Default); len(u.Controllers) == 0 && !u.Dynamic {
u.Controllers = []*Controller{&u.Default}
}
// Check individual controllers for remote flag (per-controller remote mode)
for _, c := range u.Controllers {
if c.Remote && c.APIKey != "" && c.ConsoleID == "" {
// This controller has remote flag but no console ID, try to discover
discovered, err := u.discoverRemoteControllers(c.APIKey)
if err != nil {
u.LogErrorf("Failed to discover remote controllers for controller: %v", err)
continue
}
if len(discovered) > 0 {
// Replace this controller with discovered ones
// Remove the current one and add discovered
newControllers := []*Controller{}
for _, existing := range u.Controllers {
if existing != c {
newControllers = append(newControllers, existing)
}
}
newControllers = append(newControllers, discovered...)
u.Controllers = newControllers
}
}
}
}
if len(u.Controllers) == 0 {
u.setDefaults(&u.Default)
u.Logf("No controllers configured. Polling dynamic controllers only! Defaults:")
u.logController(&u.Default)
}
for i, c := range u.Controllers {
u.warnProtectOnly(u.setControllerDefaults(c))
if err := u.getUnifi(c); err != nil {
u.LogErrorf("Controller %d of %d Auth or Connection Error, retrying: %v", i+1, len(u.Controllers), err)
continue
}
// A Protect-only console has no sites to check.
if !*c.DisableNetwork {
if err := u.checkSites(c); err != nil {
u.LogErrorf("checking sites on %s: %v", c.URL, err)
}
}
u.Logf("Configured UniFi Controller %d of %d:", i+1, len(u.Controllers))
u.logController(c)
}
webserver.UpdateInput(&webserver.Input{Name: PluginName, Config: formatConfig(u.Config)})
return nil
}
func (u *InputUnifi) DebugInput() (bool, error) {
if u == nil || u.Config == nil {
return true, nil
}
if u.setDefaults(&u.Default); len(u.Controllers) == 0 && !u.Dynamic {
u.Controllers = []*Controller{&u.Default}
}
if len(u.Controllers) == 0 {
u.setDefaults(&u.Default)
u.Logf("No controllers configured. Polling dynamic controllers only! Defaults:")
u.logController(&u.Default)
}
allOK := true
var allErrors error
for i, c := range u.Controllers {
if err := u.getUnifi(u.setControllerDefaults(c)); err != nil {
u.LogErrorf("Controller %d of %d Auth or Connection Error, retrying: %v", i+1, len(u.Controllers), err)
allOK = false
if allErrors != nil {
allErrors = fmt.Errorf("%v: %w", err, allErrors)
} else {
allErrors = err
}
continue
}
// A Protect-only console has no sites to check.
if !*c.DisableNetwork {
if err := u.checkSites(c); err != nil {
u.LogErrorf("checking sites on %s: %v", c.URL, err)
allOK = false
if allErrors != nil {
allErrors = fmt.Errorf("%v: %w", err, allErrors)
} else {
allErrors = err
}
continue
}
}
u.Logf("Valid UniFi Controller %d of %d:", i+1, len(u.Controllers))
u.logController(c)
}
return allOK, allErrors
}
func (u *InputUnifi) logController(c *Controller) {
mode := "Local"
if c.Remote {
mode = "Remote"
if c.ConsoleID != "" {
mode += fmt.Sprintf(" (Console: %s)", c.ConsoleID)
}
}
if *c.DisableNetwork {
mode += " (Protect only: no Network application)"
}
u.Logf(" => Mode: %s", mode)
u.Logf(" => URL: %s (verify SSL: %v, timeout: %v)", c.URL, *c.VerifySSL, c.Timeout.Duration)
if len(c.CertPaths) > 0 {
u.Logf(" => Cert Files: %s", strings.Join(c.CertPaths, ", "))
}
if c.Unifi != nil {
u.Logf(" => Version: %s (%s)", c.Unifi.ServerVersion, c.Unifi.UUID)
}
if c.Remote {
u.Logf(" => API Key: %v", c.APIKey != "")
} else {
u.Logf(" => Username: %s (has password: %v) (has api-key: %v)", c.User, c.Pass != "", c.APIKey != "")
}
u.Logf(" => Hash PII %v / Drop PII %v", *c.HashPII, *c.DropPII)
u.Logf(" => Save Protect Devices: %v (has protect-api-key: %v)", *c.SaveProtectDevices, c.ProtectAPIKey != "")
u.Logf(" => Save Protect Logs %v (thumbnails: %v)", *c.SaveProtectLogs, *c.ProtectThumbnails)
// The rest are all Network-application settings; printing them for a Protect-only
// console would only suggest data that is never collected.
if *c.DisableNetwork {
return
}
u.Logf(" => Poll Sites: %s", strings.Join(c.Sites, ", "))
u.Logf(" => Save Sites %v / Save DPI %v (metrics)", *c.SaveSites, *c.SaveDPI)
u.Logf(" => Save Events %v / Save Syslog %v / Save IDs %v (logs)", *c.SaveEvents, *c.SaveSyslog, *c.SaveIDs)
u.Logf(" => Save Alarms %v / Anomalies %v", *c.SaveAlarms, *c.SaveAnomal)
u.Logf(" => Save Rogue APs: %v", *c.SaveRogue)
u.Logf(" => Save Traffic %v", *c.SaveTraffic)
u.Logf(" => Save Speed Tests: %v", *c.SaveSpeedTest)
}
// warnProtectOnly reports a disable_network controller that can never collect anything.
// Neither case is fatal -- the controller is still polled -- but both are always a mistake,
// and silently collecting nothing is the failure mode hardest to diagnose from a log.
func (u *InputUnifi) warnProtectOnly(c *Controller) {
if !*c.DisableNetwork {
return
}
if !*c.SaveProtectDevices && !*c.SaveProtectLogs {
u.LogErrorf("Controller %s sets disable_network but neither save_protect_devices nor "+
"save_protect_logs: nothing will be collected from it", c.URL)
}
if *c.SaveProtectDevices && c.ProtectAPIKey == "" && c.APIKey == "" {
u.LogErrorf("Controller %s sets disable_network and save_protect_devices but no "+
"protect_api_key: the Protect Integration API cannot be authenticated", c.URL)
}
}
// Events allows you to pull only events (and IDs) from the UniFi Controller.
// This does not fully respect HashPII, but it may in the future!
// Use Filter.Path to pick a specific controller, otherwise poll them all!
func (u *InputUnifi) Events(filter *poller.Filter) (*poller.Events, error) {
if u.Disable {
return nil, nil
}
logs := []any{}
if filter == nil {
filter = &poller.Filter{}
}
var collectionErrors []error
for _, c := range u.Controllers {
if filter.Path != "" && !strings.EqualFold(c.URL, filter.Path) {
continue
}
events, err := u.collectControllerEvents(c)
if err != nil {
// Log error but continue to next controller
u.LogErrorf("Failed to collect events from controller %s: %v", c.URL, err)
collectionErrors = append(collectionErrors, fmt.Errorf("%s: %w", c.URL, err))
continue
}
logs = append(logs, events...)
}
// Return collected events even if some controllers failed
// Only return error if ALL controllers failed and we have no events
if len(logs) == 0 && len(collectionErrors) > 0 {
return nil, collectionErrors[0]
}
return &poller.Events{Logs: logs}, nil
}
// Metrics grabs all the measurements from a UniFi controller and returns them.
// Set Filter.Path to a controller URL for a specific controller (or get them all).
func (u *InputUnifi) Metrics(filter *poller.Filter) (*poller.Metrics, error) {
if u.Disable {
return nil, nil
}
metrics := &poller.Metrics{}
if filter == nil {
filter = &poller.Filter{}
}
var collectionErrors []error
// Check if the request is for an existing, configured controller (or all controllers)
for _, c := range u.Controllers {
if filter.Path != "" && !strings.EqualFold(c.URL, filter.Path) {
// continue only if we have a filter path and it doesn't match.
continue
}
m, err := u.collectController(c)
if err != nil {
// Log error but continue to next controller
u.LogErrorf("Failed to collect metrics from controller %s: %v", c.URL, err)
collectionErrors = append(collectionErrors, fmt.Errorf("%s: %w", c.URL, err))
// Record controller as down so output plugins can expose the status.
source := c.URL
if c.ID != "" {
source = c.ID
}
metrics.ControllerStatuses = append(metrics.ControllerStatuses, poller.ControllerStatus{
Source: source,
Up: false,
})
continue
}
// Record controller as up.
source := c.URL
if c.ID != "" {
source = c.ID
}
if m != nil {
m.ControllerStatuses = append(m.ControllerStatuses, poller.ControllerStatus{
Source: source,
Up: true,
})
}
metrics = poller.AppendMetrics(metrics, m)
}
// If we collected data from at least one controller, return success. ProtectDevices counts:
// a Protect-only console contributes no devices and no clients, and without it a filtered
// scrape of one would fall through to the dynamic-controller path and fail.
if len(metrics.Devices) > 0 || len(metrics.Clients) > 0 || len(metrics.ProtectDevices) > 0 {
return metrics, nil
}
// If all controllers failed and we had errors, return the first error
if len(collectionErrors) > 0 {
return metrics, collectionErrors[0]
}
if filter.Path == "" || len(metrics.Clients) != 0 {
return metrics, nil
}
if !u.Dynamic {
return nil, ErrDynamicLookupsDisabled
}
// Attempt a dynamic metrics fetch from an unconfigured controller.
return u.dynamicController(filter)
}
// RawMetrics returns API output from the first configured UniFi controller.
// Adjust filter.Unit to pull from a controller other than the first.
func (u *InputUnifi) RawMetrics(filter *poller.Filter) ([]byte, error) {
if l := len(u.Controllers); filter.Unit >= l {
return nil, fmt.Errorf("%d controller(s) configured, '%d': %w", l, filter.Unit, ErrControllerNumNotFound)
}
c := u.Controllers[filter.Unit]
if u.isNill(c) {
u.Logf("Re-authenticating to UniFi Controller: %s", c.URL)
if err := u.getUnifi(c); err != nil {
return nil, fmt.Errorf("re-authenticating to %s: %w", c.URL, err)
}
}
// Every site-scoped kind below needs a Network application. Only the raw-path kind can
// say anything about a Protect-only console, so answer that and reject the rest plainly
// rather than returning a confusing empty result.
if *c.DisableNetwork {
if filter.Kind == "other" || filter.Kind == "o" {
return c.Unifi.GetJSON(filter.Path)
}
return nil, fmt.Errorf("%s: %w", c.URL, ErrNetworkDisabled)
}
if err := u.checkSites(c); err != nil {
return nil, err
}
sites, err := u.getFilteredSites(c)
if err != nil {
return nil, err
}
switch filter.Kind {
case "d", "device", "devices":
return u.getSitesJSON(c, unifi.APIDevicePath, sites)
case "client", "clients", "c":
return u.getSitesJSON(c, unifi.APIClientPath, sites)
case "other", "o":
return c.Unifi.GetJSON(filter.Path)
default:
return []byte{}, ErrNoFilterKindProvided
}
}
func (u *InputUnifi) getSitesJSON(c *Controller, path string, sites []*unifi.Site) ([]byte, error) {
allJSON := []byte{}
for _, s := range sites {
apiPath := fmt.Sprintf(path, s.Name)
u.LogDebugf("Returning Path '%s' for site: %s (%s):\n", apiPath, s.Desc, s.Name)
body, err := c.Unifi.GetJSON(apiPath)
if err != nil {
return allJSON, fmt.Errorf("controller: %w", err)
}
allJSON = append(allJSON, body...)
}
return allJSON, nil
}