Files
unpoller_unpoller/pkg/inputunifi/interface.go
T
Cooper Ry LeesandClaude Opus 5 a73a2e0ab2 fix: don't send unused credentials to a Protect-only console
Found by running this against a real UNVR4 rather than only the fake one.

setDefaults fills an unset user with the placeholder "unifipoller". For a
Protect-only console that placeholder was reaching Login(), the console answered
403, and the controller died during initialisation -- the same symptom #1066 set
out to fix, one layer further in. A Protect API key and no local account is the
config an operator actually writes for a UNVR, so this was the common case, not
an edge one.

Nothing on such a console uses a session unless Protect logs are wanted: the
Integration API authenticates with the key alone. So withhold the credentials
entirely in that case, and say so in the startup summary rather than naming a
username that is never sent.

Also pins the go.mod bump to unifi v6.0.1, which is the release that carries
NewProtectClient (unpoller/unifi#240).

Verified end to end against a UNVR4 (UniFi OS 5.1.31, Protect 7.2.105): the
controller comes up, logs "Auth: Protect API key only (no session needed)",
makes no login request at all, and exports 37 unpoller_protect_* series across
9 cameras and 2 bridges with unpoller_controller_up = 1.

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

434 lines
13 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)
}
switch {
case c.Remote:
u.Logf(" => API Key: %v", c.APIKey != "")
case *c.DisableNetwork && !*c.SaveProtectLogs:
// getUnifi withholds the credentials entirely in this case, so naming a user here
// would describe an authentication that never happens.
u.Logf(" => Auth: Protect API key only (no session needed)")
case *c.DisableNetwork:
u.Logf(" => Username: %s (has password: %v) — used only for Protect logs", c.User, c.Pass != "")
default:
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
}