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

1453 lines
44 KiB
Go

package inputunifi
// nolint: gosec
import (
"crypto/md5"
"errors"
"fmt"
"strings"
"time"
"github.com/unpoller/unifi/v6"
"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 (poll error: %v)", c.URL, err)
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)
m := &Metrics{TS: time.Now()}
// A Protect-only console (UNVR) has no Network application: no sites, and nothing behind
// /proxy/network to poll. Skipping the whole Network pass is what lets it reach the
// Protect collection below, which is not site-scoped. See unpoller/unpoller#1066.
if !*c.DisableNetwork {
// Get the sites we care about.
sites, err := u.getFilteredSites(c)
if err != nil {
return nil, fmt.Errorf("unifi.GetSites(): %w", err)
}
m.Sites = sites
if err := u.pollNetwork(c, sites, m); err != nil {
return nil, err
}
}
// Protect Integration API — opt-in, requires protect_api_key (or api_key as a fallback).
if c.SaveProtectDevices != nil && *c.SaveProtectDevices && (c.ProtectAPIKey != "" || c.APIKey != "") {
u.collectProtect(c, m)
}
// 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
}
// pollNetwork collects everything served by the UniFi Network application into m. It is
// split out of pollController so a Protect-only console, which has no Network application at
// all, can skip the entire pass rather than failing on its first site-scoped call.
//
//nolint:cyclop
func (u *InputUnifi) pollNetwork(c *Controller, sites []*unifi.Site, m *Metrics) error {
var err error
// 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 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 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 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 fmt.Errorf("unifi.GetCountryTraffic(%s): %w", c.URL, err)
}
u.LogDebugf("Found %d CountryTraffic entries", len(m.CountryTraffic))
}
if c.SaveDPI != nil && *c.SaveDPI {
// Supplement DPI data with the v2 traffic API, which works on newer firmware
// (Network 9.1+) where the legacy /stat/stadpi and /stat/sitedpi endpoints
// return empty results. GetClientTraffic is called regardless of SaveTraffic
// because it provides DPI-equivalent per-client app/category breakdowns.
clientUsageByApp, err := c.Unifi.GetClientTraffic(sites, &tp, true)
if err != nil {
u.LogDebugf("unifi.GetClientTraffic(%s): %v (legacy DPI endpoints will be used if available)", c.URL, err)
} else {
u.LogDebugf("Found %d ClientUsageByApp entries", len(clientUsageByApp))
b4 := len(m.ClientsDPI)
u.convertToClientDPI(clientUsageByApp, m)
u.LogDebugf("Added %d ClientDPI entries from v2 traffic API for a total of %d", len(m.ClientsDPI)-b4, len(m.ClientsDPI))
b4Sites := len(m.SitesDPI)
u.convertToSiteDPI(clientUsageByApp, m)
u.LogDebugf("Added %d SitesDPI entries from v2 traffic API for a total of %d", len(m.SitesDPI)-b4Sites, len(m.SitesDPI))
}
}
// Get all the points.
if m.Clients, err = c.Unifi.GetClients(sites); err != nil {
return 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 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 c.SaveSpeedTest != nil && *c.SaveSpeedTest {
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))
}
// Get port anomalies
if m.PortAnomalies, err = c.Unifi.GetPortAnomalies(sites); err != nil {
// Don't fail collection if port anomalies fail - older controllers may not have this endpoint
u.LogDebugf("unifi.GetPortAnomalies(%s): %v (continuing)", c.URL, err)
} else {
u.LogDebugf("Found %d PortAnomalies entries", len(m.PortAnomalies))
}
// Get Site Magic site-to-site VPN mesh data
if m.VPNMeshes, err = c.Unifi.GetMagicSiteToSiteVPN(sites); err != nil {
// Don't fail collection if VPN data fails - older controllers may not have this endpoint
u.LogDebugf("unifi.GetMagicSiteToSiteVPN(%s): %v (continuing)", c.URL, err)
} else {
u.LogDebugf("Found %d VPNMeshes entries", len(m.VPNMeshes))
}
// Legacy API additions (v5.26.0) — available on most firmware, no API key required.
u.collectLegacyPerSite(c, sites, m)
// Integration/v1 API additions (v5.26.0) — require API key and Network 9.3.43+.
if c.APIKey != "" {
u.collectIntegrationV1(c, sites, m)
}
return 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)
}
}
// convertToSiteDPI aggregates v2 client traffic data into per-site DPITable entries.
// It only adds a site entry if the site doesn't already have one from the legacy API,
// so old-firmware users are unaffected.
func (u *InputUnifi) convertToSiteDPI(clientUsageByApp []*unifi.ClientUsageByApp, metrics *Metrics) {
// Build a set of sites already covered by the legacy API.
existing := make(map[string]bool)
for _, s := range metrics.SitesDPI {
existing[s.SiteName] = true
}
type appKey struct {
App int
Cat int
}
type siteAgg struct {
byApp map[appKey]*unifi.DPIData
byCat map[int]*unifi.DPIData
sourceName string
}
siteMap := make(map[string]*siteAgg)
for _, client := range clientUsageByApp {
siteName := client.TrafficSite.SiteName
if existing[siteName] {
continue
}
agg, ok := siteMap[siteName]
if !ok {
agg = &siteAgg{
byApp: make(map[appKey]*unifi.DPIData),
byCat: make(map[int]*unifi.DPIData),
sourceName: client.TrafficSite.SourceName,
}
siteMap[siteName] = agg
}
for _, app := range client.UsageByApp {
k := appKey{App: app.Application, Cat: app.Category}
if d, ok := agg.byApp[k]; ok {
d.RxBytes.Val += float64(app.BytesReceived)
d.TxBytes.Val += float64(app.BytesTransmitted)
} else {
agg.byApp[k] = &unifi.DPIData{
App: u.intToFlexInt(app.Application),
Cat: u.intToFlexInt(app.Category),
RxBytes: u.int64ToFlexInt(app.BytesReceived),
RxPackets: u.int64ToFlexInt(0),
TxBytes: u.int64ToFlexInt(app.BytesTransmitted),
TxPackets: u.int64ToFlexInt(0),
}
}
if d, ok := agg.byCat[app.Category]; ok {
d.RxBytes.Val += float64(app.BytesReceived)
d.TxBytes.Val += float64(app.BytesTransmitted)
} else {
agg.byCat[app.Category] = &unifi.DPIData{
App: u.intToFlexInt(16777215), // unknown app — category aggregate
Cat: u.intToFlexInt(app.Category),
RxBytes: u.int64ToFlexInt(app.BytesReceived),
RxPackets: u.int64ToFlexInt(0),
TxBytes: u.int64ToFlexInt(app.BytesTransmitted),
TxPackets: u.int64ToFlexInt(0),
}
}
}
}
for siteName, agg := range siteMap {
byApp := make([]unifi.DPIData, 0, len(agg.byApp))
for _, d := range agg.byApp {
byApp = append(byApp, *d)
}
byCat := make([]unifi.DPIData, 0, len(agg.byCat))
for _, d := range agg.byCat {
byCat = append(byCat, *d)
}
metrics.SitesDPI = append(metrics.SitesDPI, &unifi.DPITable{
ByApp: byApp,
ByCat: byCat,
SiteName: siteName,
SourceName: agg.sourceName,
})
}
}
// collectLegacyPerSite collects v5.26.0 additions that use the legacy API (no API key needed).
// Failures are non-fatal: older firmware may not expose these endpoints.
func (u *InputUnifi) collectLegacyPerSite(c *Controller, sites []*unifi.Site, m *Metrics) {
for _, site := range sites {
if wan, err := c.Unifi.GetWANStatus(site); err != nil {
u.LogDebugf("unifi.GetWANStatus(%s, %s): %v (continuing)", c.URL, site.Name, err)
} else {
m.WANStatuses = append(m.WANStatuses, wan)
}
if forwards, err := c.Unifi.GetPortForwards(site); err != nil {
u.LogDebugf("unifi.GetPortForwards(%s, %s): %v (continuing)", c.URL, site.Name, err)
} else {
m.PortForwards = append(m.PortForwards, forwards...)
}
if cert, err := c.Unifi.GetSSLCertificate(site); err != nil {
u.LogDebugf("unifi.GetSSLCertificate(%s, %s): %v (continuing)", c.URL, site.Name, err)
} else if cert.ID != "" {
m.SSLCertificates = append(m.SSLCertificates, cert)
}
if upsList, err := c.Unifi.GetUPSDeviceList(site); err != nil {
u.LogDebugf("unifi.GetUPSDeviceList(%s, %s): %v (continuing)", c.URL, site.Name, err)
} else {
m.UPSDevices = append(m.UPSDevices, upsList...)
}
}
}
// collectIntegrationV1 collects all Integration/v1 endpoints (Network 9.3.43+, API key required).
// Only called when c.APIKey != "", so ErrAPIKeyRequired will not be returned.
// ErrEndpointNotFound is expected on firmware older than Network 9.3.43.
//
//nolint:cyclop,funlen
func (u *InputUnifi) collectIntegrationV1(c *Controller, sites []*unifi.Site, m *Metrics) {
// Fetch integration sites — required for all per-site Integration/v1 calls.
integrationSites, err := c.Unifi.GetIntegrationSites()
if err != nil {
if errors.Is(err, unifi.ErrEndpointNotFound) {
// Integration/v1 requires Network 9.3.43+. Controllers below that return 404.
u.LogDebugf("unifi.GetIntegrationSites(%s): Integration/v1 not available (Network 9.3.43+ required)", c.URL)
} else {
// Unexpected failure (auth expiry, network error, 500) while an API key is configured.
// All per-site Integration/v1 data will be absent until this resolves.
u.Logf("unifi.GetIntegrationSites(%s): %v (skipping Integration/v1 per-site collection)", c.URL, err)
}
return
}
u.LogDebugf("Found %d IntegrationSites", len(integrationSites))
// Build a map from legacy site name → IntegrationSite to match user-configured sites.
// IntegrationSite.InternalReference is the same short name used in the legacy API (e.g. "default").
intSiteByName := make(map[string]*unifi.IntegrationSite, len(integrationSites))
for _, is := range integrationSites {
intSiteByName[is.InternalReference] = is
}
// Per-site Integration/v1 collections — only for user-configured sites.
for _, site := range sites {
is, ok := intSiteByName[site.Name]
if !ok {
continue
}
if devStats, err := c.Unifi.GetAllIntegrationDeviceStats(is); err != nil {
u.LogDebugf("unifi.GetAllIntegrationDeviceStats(%s, %s): %v (continuing)", c.URL, is.Name, err)
} else {
m.IntegrationDevStats = append(m.IntegrationDevStats, devStats...)
}
if broadcasts, err := c.Unifi.GetWifiBroadcasts(is); err != nil {
u.LogDebugf("unifi.GetWifiBroadcasts(%s, %s): %v (continuing)", c.URL, is.Name, err)
} else {
m.WifiBroadcasts = append(m.WifiBroadcasts, broadcasts...)
}
if zones, err := c.Unifi.GetFirewallZones(is); err != nil {
u.LogDebugf("unifi.GetFirewallZones(%s, %s): %v (continuing)", c.URL, is.Name, err)
} else {
m.FirewallZones = append(m.FirewallZones, zones...)
}
if rules, err := c.Unifi.GetACLRules(is); err != nil {
u.LogDebugf("unifi.GetACLRules(%s, %s): %v (continuing)", c.URL, is.Name, err)
} else {
m.ACLRules = append(m.ACLRules, rules...)
}
if servers, err := c.Unifi.GetVPNServers(is); err != nil {
u.LogDebugf("unifi.GetVPNServers(%s, %s): %v (continuing)", c.URL, is.Name, err)
} else {
m.VPNServers = append(m.VPNServers, servers...)
}
if tunnels, err := c.Unifi.GetSiteToSiteTunnels(is); err != nil {
u.LogDebugf("unifi.GetSiteToSiteTunnels(%s, %s): %v (continuing)", c.URL, is.Name, err)
} else {
m.SiteToSiteTunnels = append(m.SiteToSiteTunnels, tunnels...)
}
if lags, err := c.Unifi.GetLAGs(is); err != nil {
u.LogDebugf("unifi.GetLAGs(%s, %s): %v (continuing)", c.URL, is.Name, err)
} else {
m.LAGs = append(m.LAGs, lags...)
}
if mclags, err := c.Unifi.GetMCLAGDomains(is); err != nil {
u.LogDebugf("unifi.GetMCLAGDomains(%s, %s): %v (continuing)", c.URL, is.Name, err)
} else {
m.MCLAGDomains = append(m.MCLAGDomains, mclags...)
}
if stacks, err := c.Unifi.GetSwitchStacks(is); err != nil {
u.LogDebugf("unifi.GetSwitchStacks(%s, %s): %v (continuing)", c.URL, is.Name, err)
} else {
m.SwitchStacks = append(m.SwitchStacks, stacks...)
}
if policies, err := c.Unifi.GetDNSPolicies(is); err != nil {
u.LogDebugf("unifi.GetDNSPolicies(%s, %s): %v (continuing)", c.URL, is.Name, err)
} else {
m.DNSPolicies = append(m.DNSPolicies, policies...)
}
if profiles, err := c.Unifi.GetRADIUSProfiles(is); err != nil {
u.LogDebugf("unifi.GetRADIUSProfiles(%s, %s): %v (continuing)", c.URL, is.Name, err)
} else {
m.RADIUSProfiles = append(m.RADIUSProfiles, profiles...)
}
if lists, err := c.Unifi.GetTrafficMatchingLists(is); err != nil {
u.LogDebugf("unifi.GetTrafficMatchingLists(%s, %s): %v (continuing)", c.URL, is.Name, err)
} else {
m.TrafficMatchingLists = append(m.TrafficMatchingLists, lists...)
}
if vouchers, err := c.Unifi.GetHotspotVouchers(is); err != nil {
u.LogDebugf("unifi.GetHotspotVouchers(%s, %s): %v (continuing)", c.URL, is.Name, err)
} else {
m.HotspotVouchers = append(m.HotspotVouchers, vouchers...)
}
}
// Global Integration/v1 collections (not per-site).
if apps, err := c.Unifi.GetDPIApplications(); err != nil {
u.LogDebugf("unifi.GetDPIApplications(%s): %v (continuing)", c.URL, err)
} else {
m.DPIApplications = append(m.DPIApplications, apps...)
u.LogDebugf("Found %d DPIApplications", len(apps))
}
if cats, err := c.Unifi.GetDPICategories(); err != nil {
u.LogDebugf("unifi.GetDPICategories(%s): %v (continuing)", c.URL, err)
} else {
m.DPICategories = append(m.DPICategories, cats...)
u.LogDebugf("Found %d DPICategories", len(cats))
}
if pending, err := c.Unifi.GetPendingDevices(); err != nil {
u.LogDebugf("unifi.GetPendingDevices(%s): %v (continuing)", c.URL, err)
} else {
m.PendingDevices = append(m.PendingDevices, pending...)
u.LogDebugf("Found %d PendingDevices", len(pending))
}
if countries, err := c.Unifi.GetCountries(); err != nil {
u.LogDebugf("unifi.GetCountries(%s): %v (continuing)", c.URL, err)
} else {
m.Countries = append(m.Countries, countries...)
u.LogDebugf("Found %d Countries", len(countries))
}
}
// collectProtect collects UniFi Protect device metrics via the Protect Integration API.
// Only called when SaveProtectDevices is enabled and a protect_api_key (or api_key fallback) is
// configured. ErrEndpointNotFound is expected when Protect is not installed on the controller.
func (u *InputUnifi) collectProtect(c *Controller, m *Metrics) {
info, err := c.Unifi.GetProtectMetaInfo()
if err != nil {
if errors.Is(err, unifi.ErrEndpointNotFound) {
u.LogDebugf("unifi.GetProtectMetaInfo(%s): Protect not installed (404), disable save_protect_devices", c.URL)
} else {
u.Logf("unifi.GetProtectMetaInfo(%s): %v (check protect_api_key; skipping Protect collection)", c.URL, err)
}
return
}
devices, err := c.Unifi.GetProtectDevices()
if err != nil {
u.Logf("unifi.GetProtectDevices(%s): %v (check protect_api_key; skipping Protect collection)", c.URL, err)
return
}
devices.Version = info.ApplicationVersion
m.ProtectDevices = devices
u.LogDebugf("Found %d Protect Sensors, %d Cameras, %d Lights, %d Bridges, %d LinkStations",
len(devices.Sensors), len(devices.Cameras), len(devices.Lights), len(devices.Bridges), len(devices.LinkStations))
}
// redactProtectDevices masks the Name and MAC on every Protect device kind in place.
func redactProtectDevices(pd *unifi.ProtectDevices, hash *bool, dropPII *bool) {
if pd.NVR != nil {
pd.NVR.Name = RedactNamePII(pd.NVR.Name, hash, dropPII)
pd.NVR.MAC = RedactMacPII(pd.NVR.MAC, hash, dropPII)
}
for _, s := range pd.Sensors {
s.Name = RedactNamePII(s.Name, hash, dropPII)
s.MAC = RedactMacPII(s.MAC, hash, dropPII)
}
for _, c := range pd.Cameras {
c.Name = RedactNamePII(c.Name, hash, dropPII)
c.MAC = RedactMacPII(c.MAC, hash, dropPII)
}
for _, l := range pd.Lights {
l.Name = RedactNamePII(l.Name, hash, dropPII)
l.MAC = RedactMacPII(l.MAC, hash, dropPII)
}
for _, b := range pd.Bridges {
b.Name = RedactNamePII(b.Name, hash, dropPII)
b.MAC = RedactMacPII(b.MAC, hash, dropPII)
}
for _, ls := range pd.LinkStations {
ls.Name = RedactNamePII(ls.Name, hash, dropPII)
ls.MAC = RedactMacPII(ls.MAC, hash, dropPII)
}
}
// 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)
}
for _, anomaly := range metrics.PortAnomalies {
if c.DefaultSiteNameOverride != "" && isDefaultSiteName(anomaly.SiteName) {
anomaly.SiteName = c.DefaultSiteNameOverride
}
m.PortAnomalies = append(m.PortAnomalies, anomaly)
}
for _, mesh := range metrics.VPNMeshes {
if c.DefaultSiteNameOverride != "" && isDefaultSiteName(mesh.SiteName) {
mesh.SiteName = c.DefaultSiteNameOverride
}
m.VPNMeshes = append(m.VPNMeshes, mesh)
}
// v5.26.0 additions — pass through with site name override applied.
for _, ws := range metrics.WANStatuses {
if c.DefaultSiteNameOverride != "" && isDefaultSiteName(ws.SiteName) {
ws.SiteName = c.DefaultSiteNameOverride
}
m.WANStatuses = append(m.WANStatuses, ws)
}
for _, pf := range metrics.PortForwards {
if c.DefaultSiteNameOverride != "" && isDefaultSiteName(pf.SiteName) {
pf.SiteName = c.DefaultSiteNameOverride
}
m.PortForwards = append(m.PortForwards, pf)
}
for _, cert := range metrics.SSLCertificates {
if c.DefaultSiteNameOverride != "" && isDefaultSiteName(cert.SiteName) {
cert.SiteName = c.DefaultSiteNameOverride
}
m.SSLCertificates = append(m.SSLCertificates, cert)
}
for _, ups := range metrics.UPSDevices {
if c.DefaultSiteNameOverride != "" && isDefaultSiteName(ups.SiteName) {
ups.SiteName = c.DefaultSiteNameOverride
}
m.UPSDevices = append(m.UPSDevices, ups)
}
for _, ds := range metrics.IntegrationDevStats {
m.IntegrationDevStats = append(m.IntegrationDevStats, ds)
}
for _, wb := range metrics.WifiBroadcasts {
if c.DefaultSiteNameOverride != "" && isDefaultSiteName(wb.SiteName) {
wb.SiteName = c.DefaultSiteNameOverride
}
m.WifiBroadcasts = append(m.WifiBroadcasts, wb)
}
for _, fz := range metrics.FirewallZones {
if c.DefaultSiteNameOverride != "" && isDefaultSiteName(fz.SiteName) {
fz.SiteName = c.DefaultSiteNameOverride
}
m.FirewallZones = append(m.FirewallZones, fz)
}
for _, rule := range metrics.ACLRules {
if c.DefaultSiteNameOverride != "" && isDefaultSiteName(rule.SiteName) {
rule.SiteName = c.DefaultSiteNameOverride
}
m.ACLRules = append(m.ACLRules, rule)
}
for _, vs := range metrics.VPNServers {
if c.DefaultSiteNameOverride != "" && isDefaultSiteName(vs.SiteName) {
vs.SiteName = c.DefaultSiteNameOverride
}
m.VPNServers = append(m.VPNServers, vs)
}
for _, t := range metrics.SiteToSiteTunnels {
if c.DefaultSiteNameOverride != "" && isDefaultSiteName(t.SiteName) {
t.SiteName = c.DefaultSiteNameOverride
}
m.SiteToSiteTunnels = append(m.SiteToSiteTunnels, t)
}
for _, lag := range metrics.LAGs {
if c.DefaultSiteNameOverride != "" && isDefaultSiteName(lag.SiteName) {
lag.SiteName = c.DefaultSiteNameOverride
}
m.LAGs = append(m.LAGs, lag)
}
for _, mc := range metrics.MCLAGDomains {
if c.DefaultSiteNameOverride != "" && isDefaultSiteName(mc.SiteName) {
mc.SiteName = c.DefaultSiteNameOverride
}
m.MCLAGDomains = append(m.MCLAGDomains, mc)
}
for _, ss := range metrics.SwitchStacks {
if c.DefaultSiteNameOverride != "" && isDefaultSiteName(ss.SiteName) {
ss.SiteName = c.DefaultSiteNameOverride
}
m.SwitchStacks = append(m.SwitchStacks, ss)
}
for _, dp := range metrics.DNSPolicies {
if c.DefaultSiteNameOverride != "" && isDefaultSiteName(dp.SiteName) {
dp.SiteName = c.DefaultSiteNameOverride
}
m.DNSPolicies = append(m.DNSPolicies, dp)
}
for _, rp := range metrics.RADIUSProfiles {
if c.DefaultSiteNameOverride != "" && isDefaultSiteName(rp.SiteName) {
rp.SiteName = c.DefaultSiteNameOverride
}
m.RADIUSProfiles = append(m.RADIUSProfiles, rp)
}
for _, tl := range metrics.TrafficMatchingLists {
if c.DefaultSiteNameOverride != "" && isDefaultSiteName(tl.SiteName) {
tl.SiteName = c.DefaultSiteNameOverride
}
m.TrafficMatchingLists = append(m.TrafficMatchingLists, tl)
}
for _, hv := range metrics.HotspotVouchers {
if c.DefaultSiteNameOverride != "" && isDefaultSiteName(hv.SiteName) {
hv.SiteName = c.DefaultSiteNameOverride
}
m.HotspotVouchers = append(m.HotspotVouchers, hv)
}
// Global types — no site name to override.
for _, app := range metrics.DPIApplications {
m.DPIApplications = append(m.DPIApplications, app)
}
for _, cat := range metrics.DPICategories {
m.DPICategories = append(m.DPICategories, cat)
}
for _, pd := range metrics.PendingDevices {
m.PendingDevices = append(m.PendingDevices, pd)
}
for _, co := range metrics.Countries {
m.Countries = append(m.Countries, co)
}
if pd := metrics.ProtectDevices; pd != nil {
redactProtectDevices(pd, c.HashPII, c.DropPII)
m.ProtectDevices = append(m.ProtectDevices, pd)
}
// 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
}
}
}
for i := range m.PortAnomalies {
if anomaly, ok := m.PortAnomalies[i].(*unifi.PortAnomaly); ok {
if isDefaultSiteName(anomaly.SiteName) {
anomaly.SiteName = overrideName
}
}
}
for i := range m.VPNMeshes {
if mesh, ok := m.VPNMeshes[i].(*unifi.MagicSiteToSiteVPN); ok {
if isDefaultSiteName(mesh.SiteName) {
mesh.SiteName = overrideName
}
}
}
// v5.26.0 additions.
for i := range m.WANStatuses {
if ws, ok := m.WANStatuses[i].(*unifi.WANStatus); ok && isDefaultSiteName(ws.SiteName) {
ws.SiteName = overrideName
}
}
for i := range m.PortForwards {
if pf, ok := m.PortForwards[i].(*unifi.PortForward); ok && isDefaultSiteName(pf.SiteName) {
pf.SiteName = overrideName
}
}
for i := range m.SSLCertificates {
if cert, ok := m.SSLCertificates[i].(*unifi.SSLCertificate); ok && isDefaultSiteName(cert.SiteName) {
cert.SiteName = overrideName
}
}
for i := range m.UPSDevices {
if ups, ok := m.UPSDevices[i].(*unifi.UPSDeviceSelector); ok && isDefaultSiteName(ups.SiteName) {
ups.SiteName = overrideName
}
}
for i := range m.WifiBroadcasts {
if wb, ok := m.WifiBroadcasts[i].(*unifi.WifiBroadcast); ok && isDefaultSiteName(wb.SiteName) {
wb.SiteName = overrideName
}
}
for i := range m.FirewallZones {
if fz, ok := m.FirewallZones[i].(*unifi.FirewallZone); ok && isDefaultSiteName(fz.SiteName) {
fz.SiteName = overrideName
}
}
for i := range m.ACLRules {
if r, ok := m.ACLRules[i].(*unifi.ACLRule); ok && isDefaultSiteName(r.SiteName) {
r.SiteName = overrideName
}
}
for i := range m.VPNServers {
if vs, ok := m.VPNServers[i].(*unifi.VPNServer); ok && isDefaultSiteName(vs.SiteName) {
vs.SiteName = overrideName
}
}
for i := range m.SiteToSiteTunnels {
if t, ok := m.SiteToSiteTunnels[i].(*unifi.SiteToSiteTunnel); ok && isDefaultSiteName(t.SiteName) {
t.SiteName = overrideName
}
}
for i := range m.LAGs {
if lag, ok := m.LAGs[i].(*unifi.LAG); ok && isDefaultSiteName(lag.SiteName) {
lag.SiteName = overrideName
}
}
for i := range m.MCLAGDomains {
if mc, ok := m.MCLAGDomains[i].(*unifi.MCLAGDomain); ok && isDefaultSiteName(mc.SiteName) {
mc.SiteName = overrideName
}
}
for i := range m.SwitchStacks {
if ss, ok := m.SwitchStacks[i].(*unifi.SwitchStack); ok && isDefaultSiteName(ss.SiteName) {
ss.SiteName = overrideName
}
}
for i := range m.DNSPolicies {
if dp, ok := m.DNSPolicies[i].(*unifi.DNSPolicy); ok && isDefaultSiteName(dp.SiteName) {
dp.SiteName = overrideName
}
}
for i := range m.RADIUSProfiles {
if rp, ok := m.RADIUSProfiles[i].(*unifi.RADIUSProfile); ok && isDefaultSiteName(rp.SiteName) {
rp.SiteName = overrideName
}
}
for i := range m.TrafficMatchingLists {
if tl, ok := m.TrafficMatchingLists[i].(*unifi.TrafficMatchingList); ok && isDefaultSiteName(tl.SiteName) {
tl.SiteName = overrideName
}
}
for i := range m.HotspotVouchers {
if hv, ok := m.HotspotVouchers[i].(*unifi.HotspotVoucher); ok && isDefaultSiteName(hv.SiteName) {
hv.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)
// Devices is nil whenever GetDevices never ran -- a Protect-only console skips the whole
// Network pass -- and every loop below would otherwise dereference it.
if metrics.Devices == nil {
metrics.Devices = &unifi.Devices{}
}
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
}