Files
unpoller_unpoller/pkg/poller/inputs.go
T
Cody LeeandClaude Sonnet 5 cc60dc9239 Recover input plugin panics in poller goroutines (fixes #1030)
Metrics/Events/Initialize each fan out to input plugins in their own
goroutines with no recover(), so a panic there (e.g. a UniFi
controller returning an unexpected Site Speed Test aggregated-dashboard
payload) crashes the whole process with exit code 2. Because the
panic occurs in a child goroutine, promunifi's existing safeRefresh
recover() in the caller's goroutine never sees it, which is why the
crash survived the earlier robustness work. This converts a panicking
input into a logged/returned error so polling continues instead of
crashing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 15:36:37 -05:00

379 lines
10 KiB
Go

package poller
import (
"fmt"
"strings"
"sync"
"time"
"github.com/pkg/errors"
)
var (
// These are used ot keep track of loaded input plugins.
inputs []*InputPlugin // nolint: gochecknoglobals
inputSync sync.RWMutex // nolint: gochecknoglobals
)
// Input plugins must implement this interface.
type Input interface {
Initialize(Logger) error // Called once on startup to initialize the plugin.
Metrics(*Filter) (*Metrics, error) // Called every time new metrics are requested.
Events(*Filter) (*Events, error) // This is new.
RawMetrics(*Filter) ([]byte, error)
DebugInput() (bool, error)
}
// Discoverer is an optional interface for inputs that can discover API endpoints.
type Discoverer interface {
Discover(outputPath string) error
}
// InputPlugin describes an input plugin's consumable interface.
type InputPlugin struct {
Name string
Config any // Each config is passed into an unmarshaller later.
Input
}
// Filter is used for metrics filters. Many fields for lots of expansion.
type Filter struct {
Type string
Term string
Name string
Role string
Kind string
Path string
Text string
Unit int
Pass bool
Skip bool
Time time.Time
Dur time.Duration
}
// NewInput creates a metric input. This should be called by input plugins
// init() functions.
func NewInput(i *InputPlugin) {
inputSync.Lock()
defer inputSync.Unlock()
if i == nil || i.Input == nil {
panic("nil output or method passed to poller.NewOutput")
}
inputs = append(inputs, i)
}
// InitializeInputs runs the passed-in initializer method for each input plugin.
func (u *UnifiPoller) InitializeInputs() error {
inputSync.RLock()
defer inputSync.RUnlock()
errChan := make(chan error, len(inputs))
wg := &sync.WaitGroup{}
// parallelize startup
u.LogDebugf("initializing %d inputs", len(inputs))
for _, input := range inputs {
wg.Add(1)
go func(input *InputPlugin) {
defer wg.Done()
sent := false
// A panicking input plugin runs in its own goroutine, so it cannot be
// caught by a recover() in the caller. Without this, a panic here
// crashes the whole process before the app even starts.
// See https://github.com/unpoller/unpoller/issues/1030
defer func() {
if r := recover(); r != nil && !sent {
u.LogErrorf("input plugin %s panicked initializing (see issue #1030): %v", input.Name, r)
errChan <- fmt.Errorf("input plugin %s panicked initializing: %v", input.Name, r) //nolint:err113
}
}()
// This must return, or the app locks up here.
u.LogDebugf("inititalizing input... %s", input.Name)
if err := input.Initialize(u); err != nil {
u.LogDebugf("error initializing input ... %s", input.Name)
sent = true
errChan <- err
return
}
u.LogDebugf("input successfully initialized ... %s", input.Name)
sent = true
errChan <- nil
}(input)
}
wg.Wait()
close(errChan)
u.LogDebugf("collecting input errors...")
// collect errors if any.
errs := make([]error, 0)
for err := range errChan {
if err != nil {
errs = append(errs, err)
}
}
var err error
if len(errs) > 0 {
err = fmt.Errorf("error initializing inputs")
for _, e := range errs {
err = errors.Wrap(err, e.Error())
}
}
u.LogDebugf("returning error: %w", err)
return err
}
type eventInputResult struct {
logs []any
err error
}
func collectEvents(filter *Filter, inputs []*InputPlugin) (*Events, error) {
resultChan := make(chan eventInputResult, len(inputs))
wg := &sync.WaitGroup{}
for _, input := range inputs {
wg.Add(1)
go func(input *InputPlugin) {
defer wg.Done()
sent := false
// A panicking input plugin runs in its own goroutine, so it cannot be
// caught by a recover() in the caller. Without this, a panic here
// (e.g. from a malformed controller response) crashes the whole
// process. See https://github.com/unpoller/unpoller/issues/1030
defer func() {
if r := recover(); r != nil && !sent {
resultChan <- eventInputResult{
err: fmt.Errorf("input plugin %s panicked collecting events (see issue #1030): %v", input.Name, r),
}
}
}()
if filter != nil &&
filter.Name != "" &&
!strings.EqualFold(input.Name, filter.Name) {
sent = true
resultChan <- eventInputResult{}
return
}
e, err := input.Events(filter)
if err != nil {
sent = true
resultChan <- eventInputResult{err: err}
return
}
sent = true
resultChan <- eventInputResult{logs: e.Logs}
}(input)
}
wg.Wait()
close(resultChan)
events := Events{}
errs := make([]error, 0)
for result := range resultChan {
if result.err != nil {
errs = append(errs, result.err)
} else if result.logs != nil {
// Logs is the only member to extend at this time.
events.Logs = append(events.Logs, result.logs...)
}
}
var err error
if len(errs) > 0 {
err = fmt.Errorf("error initializing inputs")
for _, e := range errs {
err = errors.Wrap(err, e.Error())
}
}
return &events, err
}
// Events aggregates log messages (events) from one or more sources.
func (u *UnifiPoller) Events(filter *Filter) (*Events, error) {
inputSync.RLock()
defer inputSync.RUnlock()
return collectEvents(filter, inputs)
}
type metricInputResult struct {
metric *Metrics
err error
}
func collectMetrics(filter *Filter, inputs []*InputPlugin) (*Metrics, error) {
resultChan := make(chan metricInputResult, len(inputs))
wg := &sync.WaitGroup{}
for _, input := range inputs {
wg.Add(1)
go func(input *InputPlugin) {
defer wg.Done()
sent := false
// A panicking input plugin runs in its own goroutine, so it cannot be
// caught by a recover() in the caller. Without this, a panic here
// (e.g. from a malformed controller response, such as an unexpected
// Site Speed Test aggregated-dashboard payload) crashes the whole
// process. See https://github.com/unpoller/unpoller/issues/1030
defer func() {
if r := recover(); r != nil && !sent {
resultChan <- metricInputResult{
err: fmt.Errorf("input plugin %s panicked collecting metrics (see issue #1030): %v", input.Name, r),
}
}
}()
if filter != nil &&
filter.Name != "" &&
!strings.EqualFold(input.Name, filter.Name) {
sent = true
resultChan <- metricInputResult{}
return
}
m, err := input.Metrics(filter)
sent = true
resultChan <- metricInputResult{metric: m, err: err}
}(input)
}
wg.Wait()
close(resultChan)
errs := make([]error, 0)
metrics := &Metrics{}
for result := range resultChan {
if result.err != nil {
errs = append(errs, result.err)
} else if result.metric != nil {
metrics = AppendMetrics(metrics, result.metric)
}
}
var err error
if len(errs) > 0 {
err = fmt.Errorf("error initializing inputs")
for _, e := range errs {
err = errors.Wrap(err, e.Error())
}
}
return metrics, err
}
// Metrics aggregates all the measurements from filtered inputs and returns them.
// Passing a null filter returns everything!
func (u *UnifiPoller) Metrics(filter *Filter) (*Metrics, error) {
inputSync.RLock()
defer inputSync.RUnlock()
return collectMetrics(filter, inputs)
}
// AppendMetrics combines the metrics from two sources.
func AppendMetrics(existing *Metrics, m *Metrics) *Metrics {
if existing == nil {
return m
}
if m == nil {
return existing
}
existing.SitesDPI = append(existing.SitesDPI, m.SitesDPI...)
existing.Sites = append(existing.Sites, m.Sites...)
existing.ClientsDPI = append(existing.ClientsDPI, m.ClientsDPI...)
existing.RogueAPs = append(existing.RogueAPs, m.RogueAPs...)
existing.Clients = append(existing.Clients, m.Clients...)
existing.Devices = append(existing.Devices, m.Devices...)
existing.CountryTraffic = append(existing.CountryTraffic, m.CountryTraffic...)
existing.DHCPLeases = append(existing.DHCPLeases, m.DHCPLeases...)
existing.WANConfigs = append(existing.WANConfigs, m.WANConfigs...)
existing.Sysinfos = append(existing.Sysinfos, m.Sysinfos...)
existing.FirewallPolicies = append(existing.FirewallPolicies, m.FirewallPolicies...)
existing.Topologies = append(existing.Topologies, m.Topologies...)
existing.PortAnomalies = append(existing.PortAnomalies, m.PortAnomalies...)
existing.VPNMeshes = append(existing.VPNMeshes, m.VPNMeshes...)
existing.ControllerStatuses = append(existing.ControllerStatuses, m.ControllerStatuses...)
existing.WANStatuses = append(existing.WANStatuses, m.WANStatuses...)
existing.PortForwards = append(existing.PortForwards, m.PortForwards...)
existing.SSLCertificates = append(existing.SSLCertificates, m.SSLCertificates...)
existing.UPSDevices = append(existing.UPSDevices, m.UPSDevices...)
existing.IntegrationDevStats = append(existing.IntegrationDevStats, m.IntegrationDevStats...)
existing.WifiBroadcasts = append(existing.WifiBroadcasts, m.WifiBroadcasts...)
existing.FirewallZones = append(existing.FirewallZones, m.FirewallZones...)
existing.ACLRules = append(existing.ACLRules, m.ACLRules...)
existing.VPNServers = append(existing.VPNServers, m.VPNServers...)
existing.SiteToSiteTunnels = append(existing.SiteToSiteTunnels, m.SiteToSiteTunnels...)
existing.LAGs = append(existing.LAGs, m.LAGs...)
existing.MCLAGDomains = append(existing.MCLAGDomains, m.MCLAGDomains...)
existing.SwitchStacks = append(existing.SwitchStacks, m.SwitchStacks...)
existing.DNSPolicies = append(existing.DNSPolicies, m.DNSPolicies...)
existing.RADIUSProfiles = append(existing.RADIUSProfiles, m.RADIUSProfiles...)
existing.TrafficMatchingLists = append(existing.TrafficMatchingLists, m.TrafficMatchingLists...)
existing.HotspotVouchers = append(existing.HotspotVouchers, m.HotspotVouchers...)
existing.DPIApplications = append(existing.DPIApplications, m.DPIApplications...)
existing.DPICategories = append(existing.DPICategories, m.DPICategories...)
existing.PendingDevices = append(existing.PendingDevices, m.PendingDevices...)
existing.Countries = append(existing.Countries, m.Countries...)
return existing
}
// Inputs allows output plugins to see the list of loaded input plugins.
func (u *UnifiPoller) Inputs() (names []string) {
inputSync.RLock()
defer inputSync.RUnlock()
for i := range inputs {
names = append(names, inputs[i].Name)
}
return names
}