mirror of
https://github.com/unpoller/unpoller.git
synced 2026-03-31 06:24:21 -04:00
* fix(inputunifi): gracefully handle 404s from remote API event endpoints The UniFi remote API (api.ui.com) does not support legacy event endpoints such as /stat/event, causing repeated [ERROR] log lines for users who have save_events = true with a remote controller. When a remote controller returns an invalid HTTP status code (e.g. 404), log a warning and continue to the next event collector instead of propagating the error. This keeps metrics collection working and stops the noisy error loop. Fixes #966 Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com> * fix(inputunifi): log unsupported remote API event endpoints at Info not Error Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
444 lines
13 KiB
Go
444 lines
13 KiB
Go
package inputunifi
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/unpoller/unifi/v5"
|
|
"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)
|
|
}
|
|
}
|
|
|
|
var (
|
|
logs = []any{}
|
|
newLogs []any
|
|
)
|
|
|
|
// Get the sites we care about.
|
|
sites, err := u.getFilteredSites(c)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("unifi.GetSites(): %w", err)
|
|
}
|
|
|
|
type caller func([]any, []*unifi.Site, *Controller) ([]any, error)
|
|
|
|
for _, call := range []caller{u.collectIDs, u.collectAnomalies, u.collectAlarms, u.collectEvents, u.collectSyslog, u.collectProtectLogs} {
|
|
if newLogs, err = call(logs, sites, c); err != nil {
|
|
if c.Remote && errors.Is(err, unifi.ErrInvalidStatusCode) {
|
|
// The remote API (api.ui.com) does not support all event endpoints (e.g. /stat/event
|
|
// returns 404). Log a warning and continue so other collectors still run.
|
|
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 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
|
|
}
|
|
|
|
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 err != nil {
|
|
return logs, fmt.Errorf("unifi.GetAnomalies(): %w", err)
|
|
}
|
|
|
|
for _, e := range events {
|
|
// Apply site name override for anomalies if configured
|
|
if c.DefaultSiteNameOverride != "" {
|
|
lower := strings.ToLower(e.SiteName)
|
|
if lower == "default" || strings.Contains(lower, "default") {
|
|
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 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 {
|
|
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:]
|
|
}
|
|
if thumbData, err := c.Unifi.GetProtectEventThumbnail(thumbID); err == nil {
|
|
e.ThumbnailBase64 = base64.StdEncoding.EncodeToString(thumbData)
|
|
} else {
|
|
u.LogDebugf("Failed to fetch thumbnail for event %s (thumb: %s): %v", e.ID, thumbID, err)
|
|
}
|
|
}
|
|
|
|
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 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 ""
|
|
}
|