package inputunifi // nolint: gosec import ( "crypto/md5" "fmt" "strings" "time" "github.com/unpoller/unifi/v5" "github.com/unpoller/unpoller/pkg/poller" ) const ( historySeconds = 86400 pollDuration = time.Second * historySeconds ) var ErrScrapeFilterMatchFailed = fmt.Errorf("scrape filter match failed, and filter is not http URL") func (u *InputUnifi) isNill(c *Controller) bool { u.RLock() defer u.RUnlock() return c.Unifi == nil } // newDynamicCntrlr creates and saves a controller definition for further use. // This is called when an unconfigured controller is requested. func (u *InputUnifi) newDynamicCntrlr(url string) (bool, *Controller) { u.Lock() defer u.Unlock() if c := u.dynamic[url]; c != nil { // it already exists. return false, c } ccopy := u.Default // copy defaults into new controller u.dynamic[url] = &ccopy u.dynamic[url].URL = url return true, u.dynamic[url] } func (u *InputUnifi) dynamicController(filter *poller.Filter) (*poller.Metrics, error) { if !strings.HasPrefix(filter.Path, "http") { return nil, ErrScrapeFilterMatchFailed } newCntrlr, c := u.newDynamicCntrlr(filter.Path) if newCntrlr { u.Logf("Authenticating to Dynamic UniFi Controller: %s", filter.Path) if err := u.getUnifi(c); err != nil { u.logController(c) return nil, fmt.Errorf("authenticating to %s: %w", filter.Path, err) } u.logController(c) } return u.collectController(c) } func (u *InputUnifi) collectController(c *Controller) (*poller.Metrics, error) { u.LogDebugf("Collecting controller data: %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) } } metrics, err := u.pollController(c) if err != nil { u.Logf("Re-authenticating to UniFi Controller: %s", c.URL) if authErr := u.getUnifi(c); authErr != nil { return metrics, fmt.Errorf("re-authenticating to %s: %w", c.URL, authErr) } // Brief delay to allow controller to process new authentication time.Sleep(500 * time.Millisecond) // Retry the poll after successful re-authentication u.LogDebugf("Retrying poll after re-authentication: %s", c.URL) metrics, err = u.pollController(c) } return metrics, err } //nolint:cyclop func (u *InputUnifi) pollController(c *Controller) (*poller.Metrics, error) { u.RLock() defer u.RUnlock() if c == nil { return nil, fmt.Errorf("controller is nil") } if c.Unifi == nil { return nil, fmt.Errorf("controller client is nil (e.g. after 429 or auth failure): %s", c.URL) } u.LogDebugf("Polling controller: %s (%s)", c.URL, c.ID) // Get the sites we care about. sites, err := u.getFilteredSites(c) if err != nil { return nil, fmt.Errorf("unifi.GetSites(): %w", err) } m := &Metrics{TS: time.Now(), Sites: sites} // FIXME needs to be last poll time maybe st := m.TS.Add(-1 * pollDuration) tp := unifi.EpochMillisTimePeriod{StartEpochMillis: st.UnixMilli(), EndEpochMillis: m.TS.UnixMilli()} if c.SaveRogue != nil && *c.SaveRogue { if m.RogueAPs, err = c.Unifi.GetRogueAPs(sites); err != nil { return nil, fmt.Errorf("unifi.GetRogueAPs(%s): %w", c.URL, err) } u.LogDebugf("Found %d RogueAPs entries", len(m.RogueAPs)) } if c.SaveDPI != nil && *c.SaveDPI { if m.SitesDPI, err = c.Unifi.GetSiteDPI(sites); err != nil { return nil, fmt.Errorf("unifi.GetSiteDPI(%s): %w", c.URL, err) } u.LogDebugf("Found %d SitesDPI entries", len(m.SitesDPI)) if m.ClientsDPI, err = c.Unifi.GetClientsDPI(sites); err != nil { return nil, fmt.Errorf("unifi.GetClientsDPI(%s): %w", c.URL, err) } u.LogDebugf("Found %d ClientsDPI entries", len(m.ClientsDPI)) } if c.SaveTraffic != nil && *c.SaveTraffic { if m.CountryTraffic, err = c.Unifi.GetCountryTraffic(sites, &tp); err != nil { return nil, fmt.Errorf("unifi.GetCountryTraffic(%s): %w", c.URL, err) } u.LogDebugf("Found %d CountryTraffic entries", len(m.CountryTraffic)) } if c.SaveTraffic != nil && *c.SaveTraffic && c.SaveDPI != nil && *c.SaveDPI { clientUsageByApp, err := c.Unifi.GetClientTraffic(sites, &tp, true) if err != nil { return nil, fmt.Errorf("unifi.GetClientTraffic(%s): %w", c.URL, err) } u.LogDebugf("Found %d ClientUsageByApp entries", len(clientUsageByApp)) b4 := len(m.ClientsDPI) u.convertToClientDPI(clientUsageByApp, m) u.LogDebugf("Added %d ClientDPI entries for a total of %d", len(m.ClientsDPI)-b4, len(m.ClientsDPI)) } // Get all the points. if m.Clients, err = c.Unifi.GetClients(sites); err != nil { return nil, fmt.Errorf("unifi.GetClients(%s): %w", c.URL, err) } u.LogDebugf("Found %d Clients entries", len(m.Clients)) if m.Devices, err = c.Unifi.GetDevices(sites); err != nil { return nil, fmt.Errorf("unifi.GetDevices(%s): %w", c.URL, err) } u.LogDebugf("Found %d UBB, %d UXG, %d PDU, %d UCI, %d UDB, %d UAP %d USG %d USW %d UDM devices", len(m.Devices.UBBs), len(m.Devices.UXGs), len(m.Devices.PDUs), len(m.Devices.UCIs), len(m.Devices.UDBs), len(m.Devices.UAPs), len(m.Devices.USGs), len(m.Devices.USWs), len(m.Devices.UDMs)) // Get speed test results for all WANs if m.SpeedTests, err = c.Unifi.GetSpeedTests(sites, historySeconds); err != nil { // Don't fail collection if speed tests fail - older controllers may not have this endpoint u.LogDebugf("unifi.GetSpeedTests(%s): %v (continuing)", c.URL, err) } else { u.LogDebugf("Found %d SpeedTests entries", len(m.SpeedTests)) } // Get DHCP leases with associations. // Wrapped in recover so a nil-pointer panic in the library (e.g. when a 401 causes nil devices) // never crashes the poller. See https://github.com/unpoller/unpoller/issues/965 func() { defer func() { if r := recover(); r != nil { u.LogErrorf("GetActiveDHCPLeasesWithAssociations panic recovered (see issue #965): %v", r) } }() if m.DHCPLeases, err = c.Unifi.GetActiveDHCPLeasesWithAssociations(sites); err != nil { // Don't fail collection if DHCP leases fail - older controllers may not have this endpoint u.LogDebugf("unifi.GetActiveDHCPLeasesWithAssociations(%s): %v (continuing)", c.URL, err) } else { u.LogDebugf("Found %d DHCPLeases entries", len(m.DHCPLeases)) } }() // Get WAN enriched configuration if m.WANConfigs, err = c.Unifi.GetWANEnrichedConfiguration(sites); err != nil { // Don't fail collection if WAN config fails - older controllers may not have this endpoint u.LogDebugf("unifi.GetWANEnrichedConfiguration(%s): %v (continuing)", c.URL, err) } else { u.LogDebugf("Found %d WAN configuration entries", len(m.WANConfigs)) } // Get firewall policies if m.FirewallPolicies, err = c.Unifi.GetFirewallPolicies(sites); err != nil { // Don't fail collection if firewall policies fail - older controllers may not have this endpoint u.LogDebugf("unifi.GetFirewallPolicies(%s): %v (continuing)", c.URL, err) } else { u.LogDebugf("Found %d FirewallPolicies entries", len(m.FirewallPolicies)) } // Get controller system info (UniFi OS only) if m.Sysinfos, err = c.Unifi.GetSysinfo(sites); err != nil { // Don't fail collection if sysinfo fails - older controllers may not have this endpoint u.LogDebugf("unifi.GetSysinfo(%s): %v (continuing)", c.URL, err) } else { u.LogDebugf("Found %d Sysinfo entries", len(m.Sysinfos)) } // Get network topology if m.Topologies, err = c.Unifi.GetTopology(sites); err != nil { // Don't fail collection if topology fails - older controllers may not have this endpoint u.LogDebugf("unifi.GetTopology(%s): %v (continuing)", c.URL, err) } else { u.LogDebugf("Found %d Topology entries", len(m.Topologies)) } // Update web UI only on success; call explicitly so we never run with nil c/c.Unifi (no defer). // Recover so a panic in updateWeb (e.g. old image, race) never kills the poller. if c != nil && c.Unifi != nil { func() { defer func() { if r := recover(); r != nil { u.LogErrorf("updateWeb panic recovered (upgrade image if this persists): %v", r) } }() updateWeb(c, m) }() } return u.augmentMetrics(c, m), nil } // FIXME this would be better implemented on FlexInt itself func (u *InputUnifi) intToFlexInt(i int) unifi.FlexInt { return unifi.FlexInt{ Val: float64(i), Txt: fmt.Sprintf("%d", i), } } // FIXME this would be better implemented on FlexInt itself func (u *InputUnifi) int64ToFlexInt(i int64) unifi.FlexInt { return unifi.FlexInt{ Val: float64(i), Txt: fmt.Sprintf("%d", i), } } func (u *InputUnifi) convertToClientDPI(clientUsageByApp []*unifi.ClientUsageByApp, metrics *Metrics) { for _, client := range clientUsageByApp { byApp := make([]unifi.DPIData, 0) byCat := make([]unifi.DPIData, 0) type catCount struct { BytesReceived int64 BytesTransmitted int64 } byCatMap := make(map[int]catCount) dpiClients := make([]*unifi.DPIClient, 0) // TODO create cat table for _, app := range client.UsageByApp { dpiData := unifi.DPIData{ App: u.intToFlexInt(app.Application), Cat: u.intToFlexInt(app.Category), Clients: dpiClients, KnownClients: u.intToFlexInt(0), RxBytes: u.int64ToFlexInt(app.BytesReceived), RxPackets: u.int64ToFlexInt(0), // We don't have packets from Unifi Controller TxBytes: u.int64ToFlexInt(app.BytesTransmitted), TxPackets: u.int64ToFlexInt(0), // We don't have packets from Unifi Controller } cat, ok := byCatMap[app.Category] if ok { cat.BytesReceived += app.BytesReceived cat.BytesTransmitted += app.BytesTransmitted } else { cat = catCount{ BytesReceived: app.BytesReceived, BytesTransmitted: app.BytesTransmitted, } byCatMap[app.Category] = cat } byApp = append(byApp, dpiData) } if len(byApp) <= 1 { byCat = byApp } else { for category, cat := range byCatMap { dpiData := unifi.DPIData{ App: u.intToFlexInt(16777215), // Unknown Cat: u.intToFlexInt(category), Clients: dpiClients, KnownClients: u.intToFlexInt(0), RxBytes: u.int64ToFlexInt(cat.BytesReceived), RxPackets: u.int64ToFlexInt(0), // We don't have packets from Unifi Controller TxBytes: u.int64ToFlexInt(cat.BytesTransmitted), TxPackets: u.int64ToFlexInt(0), // We don't have packets from Unifi Controller } byCat = append(byCat, dpiData) } } dpiTable := unifi.DPITable{ ByApp: byApp, ByCat: byCat, MAC: client.Client.Mac, Name: client.Client.Name, SiteName: client.TrafficSite.SiteName, SourceName: client.TrafficSite.SourceName, } metrics.ClientsDPI = append(metrics.ClientsDPI, &dpiTable) } } // augmentMetrics is our middleware layer between collecting metrics and writing them. // This is where we can manipuate the returned data or make arbitrary decisions. // This method currently adds parent device names to client metrics and hashes PII. // This method also converts our local *Metrics type into a slice of interfaces for poller. func (u *InputUnifi) augmentMetrics(c *Controller, metrics *Metrics) *poller.Metrics { if metrics == nil { return nil } m, devices, bssdIDs := extractDevices(metrics) // These come blank, so set them here. for _, client := range metrics.Clients { if devices[client.Mac] = client.Name; client.Name == "" { devices[client.Mac] = client.Hostname } client.Mac = RedactMacPII(client.Mac, c.HashPII, c.DropPII) client.Name = RedactNamePII(client.Name, c.HashPII, c.DropPII) client.Hostname = RedactNamePII(client.Hostname, c.HashPII, c.DropPII) client.SwName = devices[client.SwMac] client.ApName = devices[client.ApMac] client.GwName = devices[client.GwMac] client.RadioDescription = bssdIDs[client.Bssid] + client.RadioProto // Apply site name override for clients if configured if c.DefaultSiteNameOverride != "" && isDefaultSiteName(client.SiteName) { client.SiteName = c.DefaultSiteNameOverride } m.Clients = append(m.Clients, client) } for _, client := range metrics.ClientsDPI { // Name on Client DPI data also comes blank, find it based on MAC address. client.Name = devices[client.MAC] if client.Name == "" { client.Name = client.MAC } client.Name = RedactNamePII(client.Name, c.HashPII, c.DropPII) client.MAC = RedactMacPII(client.MAC, c.HashPII, c.DropPII) // Apply site name override for DPI clients if configured if c.DefaultSiteNameOverride != "" && isDefaultSiteName(client.SiteName) { client.SiteName = c.DefaultSiteNameOverride } m.ClientsDPI = append(m.ClientsDPI, client) } for _, ap := range metrics.RogueAPs { // XXX: do we need augment this data? m.RogueAPs = append(m.RogueAPs, ap) } if *c.SaveSites { for _, site := range metrics.Sites { // Apply site name override for sites if configured if c.DefaultSiteNameOverride != "" { if isDefaultSiteName(site.Name) { site.Name = c.DefaultSiteNameOverride } if isDefaultSiteName(site.SiteName) { site.SiteName = c.DefaultSiteNameOverride } } m.Sites = append(m.Sites, site) } for _, site := range metrics.SitesDPI { // Apply site name override for DPI sites if configured if c.DefaultSiteNameOverride != "" && isDefaultSiteName(site.SiteName) { site.SiteName = c.DefaultSiteNameOverride } m.SitesDPI = append(m.SitesDPI, site) } } for _, speedTest := range metrics.SpeedTests { // Apply site name override for speed tests if configured if c.DefaultSiteNameOverride != "" && isDefaultSiteName(speedTest.SiteName) { speedTest.SiteName = c.DefaultSiteNameOverride } m.SpeedTests = append(m.SpeedTests, speedTest) } for _, traffic := range metrics.CountryTraffic { // Apply site name override for country traffic if configured // UsageByCountry has TrafficSite.SiteName, not SiteName directly if c.DefaultSiteNameOverride != "" && isDefaultSiteName(traffic.TrafficSite.SiteName) { traffic.TrafficSite.SiteName = c.DefaultSiteNameOverride } m.CountryTraffic = append(m.CountryTraffic, traffic) } for _, lease := range metrics.DHCPLeases { // Apply site name override for DHCP leases if configured if c.DefaultSiteNameOverride != "" && isDefaultSiteName(lease.SiteName) { lease.SiteName = c.DefaultSiteNameOverride } m.DHCPLeases = append(m.DHCPLeases, lease) } for _, wanConfig := range metrics.WANConfigs { // WANEnrichedConfiguration doesn't have a SiteName field by default // The site context is preserved via the collector's site list m.WANConfigs = append(m.WANConfigs, wanConfig) } for _, sysinfo := range metrics.Sysinfos { m.Sysinfos = append(m.Sysinfos, sysinfo) } for _, policy := range metrics.FirewallPolicies { // Apply site name override for firewall policies if configured if c.DefaultSiteNameOverride != "" && isDefaultSiteName(policy.SiteName) { policy.SiteName = c.DefaultSiteNameOverride } m.FirewallPolicies = append(m.FirewallPolicies, policy) } for _, topo := range metrics.Topologies { // Apply site name override for topology if configured if c.DefaultSiteNameOverride != "" && isDefaultSiteName(topo.SiteName) { topo.SiteName = c.DefaultSiteNameOverride } m.Topologies = append(m.Topologies, topo) } // Apply default_site_name_override to all metrics if configured. // This must be done AFTER all metrics are added to m, so everything is included. // This allows us to use the console name for Cloud Gateways while keeping // the actual site name ("default") for API calls. if c.DefaultSiteNameOverride != "" { applySiteNameOverride(m, c.DefaultSiteNameOverride) } return m } // isDefaultSiteName checks if a site name represents a "default" site. // This handles variations like "default", "Default", "Default (default)", etc. func isDefaultSiteName(siteName string) bool { if siteName == "" { return false } lower := strings.ToLower(siteName) // Check for exact match or if it contains "default" as a word return lower == "default" || strings.Contains(lower, "default") } // applySiteNameOverride replaces "default" site names with the override name // in all devices, clients, and sites. This allows us to use console names // for Cloud Gateways in metrics while keeping "default" for API calls. // This makes metrics more compatible with existing dashboards that expect // meaningful site names instead of "Default" or "Default (default)". func applySiteNameOverride(m *poller.Metrics, overrideName string) { // Apply to all devices - use type switch for known device types for i := range m.Devices { switch d := m.Devices[i].(type) { case *unifi.UAP: if isDefaultSiteName(d.SiteName) { d.SiteName = overrideName } case *unifi.USG: if isDefaultSiteName(d.SiteName) { d.SiteName = overrideName } case *unifi.USW: if isDefaultSiteName(d.SiteName) { d.SiteName = overrideName } case *unifi.UDM: if isDefaultSiteName(d.SiteName) { d.SiteName = overrideName } case *unifi.UXG: if isDefaultSiteName(d.SiteName) { d.SiteName = overrideName } case *unifi.UBB: if isDefaultSiteName(d.SiteName) { d.SiteName = overrideName } case *unifi.UCI: if isDefaultSiteName(d.SiteName) { d.SiteName = overrideName } case *unifi.UDB: if isDefaultSiteName(d.SiteName) { d.SiteName = overrideName } case *unifi.PDU: if isDefaultSiteName(d.SiteName) { d.SiteName = overrideName } } } // Apply to all clients for i := range m.Clients { if client, ok := m.Clients[i].(*unifi.Client); ok { if isDefaultSiteName(client.SiteName) { client.SiteName = overrideName } } } // Apply to sites - check both Name and SiteName fields for i := range m.Sites { if site, ok := m.Sites[i].(*unifi.Site); ok { if isDefaultSiteName(site.Name) { site.Name = overrideName } if isDefaultSiteName(site.SiteName) { site.SiteName = overrideName } } } // Apply to rogue APs for i := range m.RogueAPs { if ap, ok := m.RogueAPs[i].(*unifi.RogueAP); ok { if isDefaultSiteName(ap.SiteName) { ap.SiteName = overrideName } } } // Apply to DHCP leases for i := range m.DHCPLeases { if lease, ok := m.DHCPLeases[i].(*unifi.DHCPLease); ok { if isDefaultSiteName(lease.SiteName) { lease.SiteName = overrideName } } } // Apply to sysinfo (controller metrics) for i := range m.Sysinfos { if s, ok := m.Sysinfos[i].(*unifi.Sysinfo); ok { if isDefaultSiteName(s.SiteName) { s.SiteName = overrideName } } } // Apply to WAN configs for i := range m.WANConfigs { if wanConfig, ok := m.WANConfigs[i].(*unifi.WANEnrichedConfiguration); ok { // WAN configs don't have SiteName field, but we'll add it in the exporter _ = wanConfig } } // Apply to firewall policies for i := range m.FirewallPolicies { if policy, ok := m.FirewallPolicies[i].(*unifi.FirewallPolicy); ok { if isDefaultSiteName(policy.SiteName) { policy.SiteName = overrideName } } } for i := range m.Topologies { if topo, ok := m.Topologies[i].(*unifi.Topology); ok { if isDefaultSiteName(topo.SiteName) { topo.SiteName = overrideName } } } } // this is a helper function for augmentMetrics. func extractDevices(metrics *Metrics) (*poller.Metrics, map[string]string, map[string]string) { m := &poller.Metrics{TS: metrics.TS} devices := make(map[string]string) bssdIDs := make(map[string]string) for _, r := range metrics.Devices.UAPs { devices[r.Mac] = r.Name m.Devices = append(m.Devices, r) for _, v := range r.VapTable { bssdIDs[v.Bssid] = fmt.Sprintf("%s %s %s:", r.Name, v.Radio, v.RadioName) } } for _, r := range metrics.Devices.USGs { devices[r.Mac] = r.Name m.Devices = append(m.Devices, r) } for _, r := range metrics.Devices.USWs { devices[r.Mac] = r.Name m.Devices = append(m.Devices, r) } for _, r := range metrics.Devices.UDMs { devices[r.Mac] = r.Name m.Devices = append(m.Devices, r) } for _, r := range metrics.Devices.UXGs { devices[r.Mac] = r.Name m.Devices = append(m.Devices, r) } for _, r := range metrics.Devices.UBBs { devices[r.Mac] = r.Name m.Devices = append(m.Devices, r) } for _, r := range metrics.Devices.UCIs { devices[r.Mac] = r.Name m.Devices = append(m.Devices, r) } for _, r := range metrics.Devices.UDBs { devices[r.Mac] = r.Name m.Devices = append(m.Devices, r) for _, v := range r.VapTable { bssdIDs[v.Bssid] = fmt.Sprintf("%s %s %s:", r.Name, v.Radio, v.RadioName) } } for _, r := range metrics.Devices.PDUs { devices[r.Mac] = r.Name m.Devices = append(m.Devices, r) } return m, devices, bssdIDs } // RedactNamePII converts a name string to an md5 hash (first 24 chars only). // Useful for maskiing out personally identifying information. func RedactNamePII(pii string, hash *bool, dropPII *bool) string { if dropPII != nil && *dropPII { return "" } if hash == nil || !*hash || pii == "" { return pii } s := fmt.Sprintf("%x", md5.Sum([]byte(pii))) // nolint: gosec // instead of 32 characters, only use 24. return s[:24] } // RedactMacPII converts a MAC address to an md5 hashed version (first 14 chars only). // Useful for maskiing out personally identifying information. func RedactMacPII(pii string, hash *bool, dropPII *bool) (output string) { if dropPII != nil && *dropPII { return "" } if hash == nil || !*hash || pii == "" { return pii } s := fmt.Sprintf("%x", md5.Sum([]byte(pii))) // nolint: gosec // This formats a "fake" mac address looking string. return fmt.Sprintf("%s:%s:%s:%s:%s:%s:%s", s[:2], s[2:4], s[4:6], s[6:8], s[8:10], s[10:12], s[12:14]) } // RedactIPPII converts an IP address to an md5 hashed version (first 12 chars only). // Useful for maskiing out personally identifying information. func RedactIPPII(pii string, hash *bool, dropPII *bool) string { if dropPII != nil && *dropPII { return "" } if hash == nil || !*hash || pii == "" { return pii } s := fmt.Sprintf("%x", md5.Sum([]byte(pii))) // nolint: gosec // Format as a "fake" IP-like string. return fmt.Sprintf("%s.%s.%s", s[:4], s[4:8], s[8:12]) } // getFilteredSites returns a list of sites to fetch data for. // Omits requested but unconfigured sites. Grabs the full list from the // controller and returns the sites provided in the config file. func (u *InputUnifi) getFilteredSites(c *Controller) ([]*unifi.Site, error) { u.RLock() defer u.RUnlock() sites, err := c.Unifi.GetSites() if err != nil { return nil, fmt.Errorf("controller: %w", err) } // Note: We do NOT override the site name here because it's used in API calls. // The API expects the actual site name (e.g., "default"), not the override. // The override will be applied later when augmenting metrics for display purposes. if len(c.Sites) == 0 || StringInSlice("all", c.Sites) { return sites, nil } i := 0 for _, s := range sites { // Only include valid sites in the request filter. if StringInSlice(s.Name, c.Sites) { sites[i] = s i++ } } return sites[:i], nil }