mirror of
https://github.com/unpoller/unpoller.git
synced 2026-07-28 16:52:40 -04:00
Fixes #425 When polling multiple controllers, if one controller was down or unreachable, unpoller would stop collecting data from ALL controllers. This caused complete data loss across all sites when just one was down. Root Cause: Both Metrics() and Events() methods would immediately return an error when any controller failed, skipping all remaining controllers in the loop. Changes: - Log errors from failed controllers but continue to next controller - Track collection errors separately from successful data collection - Only return error if ALL controllers failed and no data was collected - Return success if at least one controller provided data This allows unpoller to continue monitoring healthy controllers even when some are temporarily unreachable due to network issues, timeouts, or maintenance. Example behavior: - Controller 1: Down (timeout) - logs error, continues - Controller 2: Up - collects data successfully - Controller 3: Up - collects data successfully - Result: Returns data from controllers 2 and 3 Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
339 lines
9.5 KiB
Go
339 lines
9.5 KiB
Go
package inputunifi
|
|
|
|
/* This file contains the three poller.Input interface methods. */
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/unpoller/unifi/v5"
|
|
"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")
|
|
)
|
|
|
|
// 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.Logf("No controllers configured. Polling dynamic controllers only! Defaults:")
|
|
u.logController(&u.Default)
|
|
}
|
|
|
|
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)
|
|
|
|
continue
|
|
}
|
|
|
|
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.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
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
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 / Poll Sites: %s", *c.HashPII, *c.DropPII, 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 / Protect Logs %v (thumbnails: %v)", *c.SaveAlarms, *c.SaveAnomal, *c.SaveProtectLogs, *c.ProtectThumbnails)
|
|
u.Logf(" => Save Rogue APs: %v", *c.SaveRogue)
|
|
u.Logf(" => Save Traffic %v", *c.SaveTraffic)
|
|
}
|
|
|
|
// 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))
|
|
continue
|
|
}
|
|
|
|
metrics = poller.AppendMetrics(metrics, m)
|
|
}
|
|
|
|
// If we collected data from at least one controller, return success
|
|
if len(metrics.Devices) > 0 || len(metrics.Clients) > 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)
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|