Files
unpoller_unpoller/pkg/inputunifi/collectevents.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

528 lines
16 KiB
Go

package inputunifi
import (
"encoding/base64"
"errors"
"fmt"
"strings"
"time"
"github.com/unpoller/unifi/v6"
"github.com/unpoller/unpoller/pkg/webserver"
)
/* Event collection. Events are also sent to the webserver for display. */
func (u *InputUnifi) collectControllerEvents(c *Controller) ([]any, error) {
u.LogDebugf("Collecting controller events: %s (%s)", c.URL, c.ID)
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)
}
}
type caller func([]any, []*unifi.Site, *Controller) ([]any, error)
var (
logs = []any{}
newLogs []any
sites []*unifi.Site
err error
calls = []caller{u.collectIDs, u.collectAnomalies, u.collectAlarms, u.collectEvents, u.collectSyslog, u.collectProtectLogs}
)
// A Protect-only console (UNVR) has no sites and no Network application, so every
// site-scoped collector below would fail. collectProtectLogs is the only one that is not
// site-scoped -- it already ignores the argument. See unpoller/unpoller#1066.
if *c.DisableNetwork {
calls = []caller{u.collectProtectLogs}
} else {
// Get the sites we care about.
if sites, err = u.getFilteredSites(c); err != nil {
return nil, fmt.Errorf("unifi.GetSites(): %w", err)
}
}
for _, call := range calls {
if newLogs, err = call(logs, sites, c); err != nil {
if c.Remote && (errors.Is(err, unifi.ErrInvalidStatusCode) || errors.Is(err, unifi.ErrEndpointNotFound)) {
// The remote API (api.ui.com) does not support all event endpoints.
// ErrInvalidStatusCode is retained for backward compatibility: before
// ErrEndpointNotFound was split out, all non-200 remote responses were
// soft-skipped via ErrInvalidStatusCode.
// Note: most inner callers (collectAlarms, collectAnomalies, collectEvents,
// collectIDs, collectProtectLogs) handle ErrEndpointNotFound internally and
// return nil, so this branch fires primarily for collectSyslog and for
// ErrInvalidStatusCode cases on remote controllers.
u.Logf("Failed to collect events from controller %s: %v (endpoint may not be supported by the remote API)", c.URL, err)
continue
}
return logs, err
}
logs = append(logs, newLogs...)
}
return logs, nil
}
func (u *InputUnifi) collectAlarms(logs []any, sites []*unifi.Site, c *Controller) ([]any, error) {
if *c.SaveAlarms {
u.LogDebugf("Collecting controller alarms: %s (%s)", c.URL, c.ID)
// Get devices for all sites to build MAC-to-name lookup
devices, err := c.Unifi.GetDevices(sites)
if err != nil {
u.LogDebugf("Failed to get devices for alarm enrichment: %v (continuing without device names)", err)
devices = &unifi.Devices{} // Empty devices struct, alarms will not have device names
}
// Build MAC address to device name lookup map
macToName := make(map[string]string)
for _, d := range devices.UAPs {
if d.Mac != "" && d.Name != "" {
macToName[strings.ToLower(d.Mac)] = d.Name
}
}
for _, d := range devices.USGs {
if d.Mac != "" && d.Name != "" {
macToName[strings.ToLower(d.Mac)] = d.Name
}
}
for _, d := range devices.USWs {
if d.Mac != "" && d.Name != "" {
macToName[strings.ToLower(d.Mac)] = d.Name
}
}
for _, d := range devices.UDMs {
if d.Mac != "" && d.Name != "" {
macToName[strings.ToLower(d.Mac)] = d.Name
}
}
for _, d := range devices.UXGs {
if d.Mac != "" && d.Name != "" {
macToName[strings.ToLower(d.Mac)] = d.Name
}
}
for _, d := range devices.PDUs {
if d.Mac != "" && d.Name != "" {
macToName[strings.ToLower(d.Mac)] = d.Name
}
}
for _, d := range devices.UBBs {
if d.Mac != "" && d.Name != "" {
macToName[strings.ToLower(d.Mac)] = d.Name
}
}
for _, d := range devices.UCIs {
if d.Mac != "" && d.Name != "" {
macToName[strings.ToLower(d.Mac)] = d.Name
}
}
for _, s := range sites {
events, err := c.Unifi.GetAlarmsSite(s)
if errors.Is(err, unifi.ErrEndpointNotFound) {
// /stat/alarm is removed controller-wide in Network 10.x+; once the first
// site returns 404 all remaining sites on the same controller will too.
u.LogDebugf("[%s] Alarms endpoint not available (Network 10.x+): %v", c.URL, err)
return logs, nil
}
if isAlarmsInvalidObject(err) {
// Some Network 10.x+ controllers (see unpoller/unpoller#1050) return
// HTTP 400 api.err.InvalidObject for list/alarm instead of a 404 when
// the endpoint is gone. unifi.ErrInvalidStatusCode doesn't carry a
// parsed status code, so we match on the formatted resp.Status text.
u.Logf("[%s] Alarms endpoint returned 400 (likely removed on this controller version): %v", c.URL, err)
return logs, nil
}
if err != nil {
return logs, fmt.Errorf("unifi.GetAlarms(): %w", err)
}
for _, e := range events {
// Try to extract MAC address from alarm message and enrich with device name
e.DeviceName = u.extractDeviceNameFromAlarm(e, macToName)
logs = append(logs, e)
webserver.NewInputEvent(PluginName, s.ID+"_alarms", &webserver.Event{
Ts: e.Datetime, Msg: e.Msg, Tags: map[string]string{
"type": "alarm", "key": e.Key, "site_id": e.SiteID,
"site_name": e.SiteName, "source": e.SourceName,
},
})
}
}
}
return logs, nil
}
// isAlarmsInvalidObject reports whether err is a 400 api.err.InvalidObject response,
// which some Network 10.x+ controllers return from list/alarm in place of a 404.
func isAlarmsInvalidObject(err error) bool {
return errors.Is(err, unifi.ErrInvalidStatusCode) && strings.Contains(err.Error(), ": 400 ")
}
func (u *InputUnifi) collectAnomalies(logs []any, sites []*unifi.Site, c *Controller) ([]any, error) {
if *c.SaveAnomal {
u.LogDebugf("Collecting controller anomalies: %s (%s)", c.URL, c.ID)
for _, s := range sites {
events, err := c.Unifi.GetAnomaliesSite(s)
if errors.Is(err, unifi.ErrEndpointNotFound) {
// /stat/anomaly is removed controller-wide in Network 10.x+; once the first
// site returns 404 all remaining sites on the same controller will too.
u.LogDebugf("[%s] Anomalies endpoint not available (Network 10.x+): %v", c.URL, err)
return logs, nil
}
if err != nil {
return logs, fmt.Errorf("unifi.GetAnomalies(): %w", err)
}
for _, e := range events {
if c.DefaultSiteNameOverride != "" && isDefaultSiteName(e.SiteName) {
e.SiteName = c.DefaultSiteNameOverride
}
logs = append(logs, e)
webserver.NewInputEvent(PluginName, s.ID+"_anomalies", &webserver.Event{
Ts: e.Datetime, Msg: e.Anomaly, Tags: map[string]string{
"type": "anomaly", "site_name": e.SiteName, "source": e.SourceName,
},
})
}
}
}
return logs, nil
}
func (u *InputUnifi) collectEvents(logs []any, sites []*unifi.Site, c *Controller) ([]any, error) {
if *c.SaveEvents {
u.LogDebugf("Collecting controller site events (v1): %s (%s)", c.URL, c.ID)
for _, s := range sites {
events, err := c.Unifi.GetSiteEvents(s, time.Hour)
if errors.Is(err, unifi.ErrEndpointNotFound) {
// stat/event was removed in Network 10.x. The path is per-site but the
// removal is controller-wide: if the first site returns 404, every site
// on the same controller will too. No replacement exists in integration/v1.
u.Logf("[%s] Events endpoint removed (Network 10.x+): use save_syslog instead", c.URL)
return logs, nil
}
if err != nil {
return logs, fmt.Errorf("unifi.GetEvents(): %w", err)
}
for _, e := range events {
e := redactEvent(e, c.HashPII, c.DropPII)
logs = append(logs, e)
webserver.NewInputEvent(PluginName, s.ID+"_events", &webserver.Event{
Msg: e.Msg, Ts: e.Datetime, Tags: map[string]string{
"type": "event", "key": e.Key, "site_id": e.SiteID,
"site_name": e.SiteName, "source": e.SourceName,
},
})
}
}
}
return logs, nil
}
func (u *InputUnifi) collectSyslog(logs []any, sites []*unifi.Site, c *Controller) ([]any, error) {
if *c.SaveSyslog {
u.LogDebugf("Collecting controller syslog (v2): %s (%s)", c.URL, c.ID)
// Use v2 system-log API
req := unifi.DefaultSystemLogRequest(time.Hour)
entries, err := c.Unifi.GetSystemLog(sites, req)
if err != nil {
return logs, fmt.Errorf("unifi.GetSystemLog(): %w", err)
}
for _, e := range entries {
e := redactSystemLogEntry(e, c.HashPII, c.DropPII)
logs = append(logs, e)
webserver.NewInputEvent(PluginName, e.SiteName+"_syslog", &webserver.Event{
Msg: e.Msg(), Ts: e.Datetime(), Tags: map[string]string{
"type": "syslog", "key": e.Key, "event": e.Event,
"site_name": e.SiteName, "source": e.SourceName,
"category": e.Category, "subcategory": e.Subcategory,
"severity": e.Severity,
},
})
}
}
return logs, nil
}
func (u *InputUnifi) collectProtectLogs(logs []any, _ []*unifi.Site, c *Controller) ([]any, error) {
if *c.SaveProtectLogs {
u.LogDebugf("Collecting Protect logs: %s (%s)", c.URL, c.ID)
req := unifi.DefaultProtectLogRequest(0) // Uses default 24-hour window
entries, err := c.Unifi.GetProtectLogs(req)
if err != nil {
if errors.Is(err, unifi.ErrEndpointNotFound) {
u.Logf("[%s] Protect logs endpoint not available (404) — ensure UniFi Protect is installed, or disable save_protect_logs", c.URL)
return logs, nil
}
return logs, fmt.Errorf("unifi.GetProtectLogs(): %w", err)
}
for _, e := range entries {
e := redactProtectLogEntry(e, c.HashPII, c.DropPII)
// Fetch thumbnail if enabled and event has a camera (only camera events have real thumbnails)
// Skip access/adminActivity events - they don't have actual camera thumbnails
if *c.ProtectThumbnails && e.Thumbnail != "" && e.Camera != "" && hasProtectThumbnail(e.Type) {
// Thumbnail field is like "e-69499de2037add03e4015fa8" - strip "e-" prefix
thumbID := e.Thumbnail
if len(thumbID) > 2 && thumbID[:2] == "e-" {
thumbID = thumbID[2:]
}
thumbData, thumbErr := c.Unifi.GetProtectEventThumbnail(thumbID)
if thumbErr != nil {
u.LogDebugf("Failed to fetch thumbnail for event %s (thumb: %s): %v", e.ID, thumbID, thumbErr)
} else {
e.ThumbnailBase64 = base64.StdEncoding.EncodeToString(thumbData)
}
}
logs = append(logs, e)
webserver.NewInputEvent(PluginName, "protect_logs", &webserver.Event{
Msg: e.Msg(), Ts: e.Datetime(), Tags: map[string]string{
"type": "protect_log",
"event_type": e.GetEventType(),
"category": e.GetCategory(),
"subcategory": e.GetSubCategory(),
"severity": e.GetSeverity(),
"camera": e.Camera,
"source": e.SourceName,
},
})
}
}
return logs, nil
}
func (u *InputUnifi) collectIDs(logs []any, sites []*unifi.Site, c *Controller) ([]any, error) {
if *c.SaveIDs {
u.LogDebugf("Collecting controller IDs data: %s (%s)", c.URL, c.ID)
for _, s := range sites {
events, err := c.Unifi.GetIDSSite(s)
if errors.Is(err, unifi.ErrEndpointNotFound) {
// stat/ips/event was removed in Network 10.x. The path is per-site but
// the removal is controller-wide: if the first site returns 404, every
// site on the same controller will too. No replacement exists.
u.Logf("[%s] IDS/IPS endpoint removed (Network 10.x+): no replacement available", c.URL)
return logs, nil
}
if err != nil {
return logs, fmt.Errorf("unifi.GetIDS(): %w", err)
}
for _, e := range events {
logs = append(logs, e)
webserver.NewInputEvent(PluginName, s.ID+"_ids", &webserver.Event{
Ts: e.Datetime, Msg: e.Msg, Tags: map[string]string{
"type": "ids", "key": e.Key, "site_id": e.SiteID,
"site_name": e.SiteName, "source": e.SourceName,
},
})
}
}
}
return logs, nil
}
// redactEvent attempts to mask personally identying information from log messages.
// This currently misses the "msg" value entirely and leaks PII information.
func redactEvent(e *unifi.Event, hash *bool, dropPII *bool) *unifi.Event {
if !*hash && !*dropPII {
return e
}
// metrics.Events[i].Msg <-- not sure what to do here.
e.DestIPGeo = unifi.IPGeo{}
e.SourceIPGeo = unifi.IPGeo{}
if *dropPII {
e.Host = ""
e.Hostname = ""
e.DstMAC = ""
e.SrcMAC = ""
} else {
// hash it
e.Host = RedactNamePII(e.Host, hash, dropPII)
e.Hostname = RedactNamePII(e.Hostname, hash, dropPII)
e.DstMAC = RedactMacPII(e.DstMAC, hash, dropPII)
e.SrcMAC = RedactMacPII(e.SrcMAC, hash, dropPII)
}
return e
}
// redactSystemLogEntry attempts to mask personally identifying information from v2 system log entries.
func redactSystemLogEntry(e *unifi.SystemLogEntry, hash *bool, dropPII *bool) *unifi.SystemLogEntry {
if !*hash && !*dropPII {
return e
}
// Redact CLIENT parameter if present
if client, ok := e.Parameters["CLIENT"]; ok {
if *dropPII {
client.Hostname = ""
client.Name = ""
client.ID = ""
client.IP = ""
} else {
client.Hostname = RedactNamePII(client.Hostname, hash, dropPII)
client.Name = RedactNamePII(client.Name, hash, dropPII)
client.ID = RedactMacPII(client.ID, hash, dropPII)
client.IP = RedactIPPII(client.IP, hash, dropPII)
}
e.Parameters["CLIENT"] = client
}
// Redact IP parameter if present
if ip, ok := e.Parameters["IP"]; ok {
if *dropPII {
ip.ID = ""
ip.Name = ""
} else {
ip.ID = RedactIPPII(ip.ID, hash, dropPII)
ip.Name = RedactIPPII(ip.Name, hash, dropPII)
}
e.Parameters["IP"] = ip
}
// Redact ADMIN parameter if present
if admin, ok := e.Parameters["ADMIN"]; ok {
if *dropPII {
admin.Name = ""
} else {
admin.Name = RedactNamePII(admin.Name, hash, dropPII)
}
e.Parameters["ADMIN"] = admin
}
return e
}
// redactProtectLogEntry attempts to mask personally identifying information from Protect log entries.
func redactProtectLogEntry(e *unifi.ProtectLogEntry, hash *bool, dropPII *bool) *unifi.ProtectLogEntry {
if !*hash && !*dropPII {
return e
}
// Redact user names from message keys
if e.Description != nil {
for i, mk := range e.Description.MessageKeys {
if mk.Key == "userLink" || mk.Action == "viewUsers" {
if *dropPII {
e.Description.MessageKeys[i].Text = ""
} else {
e.Description.MessageKeys[i].Text = RedactNamePII(mk.Text, hash, dropPII)
}
}
}
}
return e
}
// hasProtectThumbnail returns true if the event type has actual camera thumbnails.
// Access and adminActivity events don't have real thumbnails (they're user activity logs).
func hasProtectThumbnail(eventType string) bool {
switch eventType {
case "motion", "smartDetectZone", "smartDetectLine", "ring", "sensorMotion",
"sensorContact", "sensorAlarm", "doorbell", "package", "person", "vehicle",
"animal", "face", "licensePlate":
return true
default:
return false
}
}
// extractDeviceNameFromAlarm attempts to extract a device name for an alarm by looking up
// MAC addresses found in the alarm message or fields. Returns empty string if no match found.
func (u *InputUnifi) extractDeviceNameFromAlarm(alarm *unifi.Alarm, macToName map[string]string) string {
// Try to extract MAC from message like "AP[fc:ec:da:89:a6:91] was disconnected"
// Look for pattern: [XX:XX:XX:XX:XX:XX] where X is hex digit
msg := alarm.Msg
// Simple regex-like search for MAC address in brackets
start := strings.Index(msg, "[")
end := strings.Index(msg, "]")
if start >= 0 && end > start {
potentialMAC := msg[start+1 : end]
// Basic validation: should be 17 characters and contain colons
if len(potentialMAC) == 17 && strings.Count(potentialMAC, ":") == 5 {
if name, ok := macToName[strings.ToLower(potentialMAC)]; ok {
return name
}
}
}
// Also try SrcMAC and DstMAC fields if present
if alarm.SrcMAC != "" {
if name, ok := macToName[strings.ToLower(alarm.SrcMAC)]; ok {
return name
}
}
if alarm.DstMAC != "" {
if name, ok := macToName[strings.ToLower(alarm.DstMAC)]; ok {
return name
}
}
return ""
}