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