diff --git a/go.mod b/go.mod index 391fea1d..ce17998c 100644 --- a/go.mod +++ b/go.mod @@ -12,7 +12,7 @@ require ( github.com/prometheus/common v0.67.5 github.com/spf13/pflag v1.0.10 github.com/stretchr/testify v1.11.1 - github.com/unpoller/unifi/v5 v5.25.0 + github.com/unpoller/unifi/v5 v5.26.0 go.opentelemetry.io/otel v1.43.0 go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0 go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0 @@ -36,7 +36,7 @@ require ( go.opentelemetry.io/otel/trace v1.43.0 // indirect go.opentelemetry.io/proto/otlp v1.10.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect - golang.org/x/net v0.52.0 // indirect + golang.org/x/net v0.53.0 // indirect golang.org/x/text v0.36.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect diff --git a/go.sum b/go.sum index 3fa31dc3..b0f0fa61 100644 --- a/go.sum +++ b/go.sum @@ -87,8 +87,8 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/unpoller/unifi/v5 v5.25.0 h1:smX4nXSnCoZ7JenZdD9fktpja/yVUHUlXojYqQ7Be+Q= -github.com/unpoller/unifi/v5 v5.25.0/go.mod h1:0R6t/SKaS8eoOrTkSYwzVb292KG5eQfbKEuevuES0So= +github.com/unpoller/unifi/v5 v5.26.0 h1:W4wGiWsw+BnA+8Ozykwv7zneQkbq5ozTtLJnxjA/mbI= +github.com/unpoller/unifi/v5 v5.26.0/go.mod h1:G1PKfGQsflTd4nVgoLFUkvce+wcdd8tu5ICS42X3MJY= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= @@ -120,8 +120,8 @@ golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= -golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= -golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= +golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= diff --git a/pkg/datadogunifi/datadog.go b/pkg/datadogunifi/datadog.go index 72cb1d98..1fa13cf7 100644 --- a/pkg/datadogunifi/datadog.go +++ b/pkg/datadogunifi/datadog.go @@ -337,6 +337,90 @@ func (u *DatadogUnifi) loopPoints(r report) { for _, v := range m.VPNMeshes { u.switchExport(r, v) } + + for _, v := range m.PortForwards { + u.switchExport(r, v) + } + + for _, v := range m.SSLCertificates { + u.switchExport(r, v) + } + + for _, v := range m.UPSDevices { + u.switchExport(r, v) + } + + for _, v := range m.WANStatuses { + u.switchExport(r, v) + } + + for _, v := range m.IntegrationDevStats { + u.switchExport(r, v) + } + + for _, v := range m.WifiBroadcasts { + u.switchExport(r, v) + } + + for _, v := range m.FirewallZones { + u.switchExport(r, v) + } + + for _, v := range m.ACLRules { + u.switchExport(r, v) + } + + for _, v := range m.VPNServers { + u.switchExport(r, v) + } + + for _, v := range m.SiteToSiteTunnels { + u.switchExport(r, v) + } + + for _, v := range m.LAGs { + u.switchExport(r, v) + } + + for _, v := range m.MCLAGDomains { + u.switchExport(r, v) + } + + for _, v := range m.SwitchStacks { + u.switchExport(r, v) + } + + for _, v := range m.DNSPolicies { + u.switchExport(r, v) + } + + for _, v := range m.RADIUSProfiles { + u.switchExport(r, v) + } + + for _, v := range m.TrafficMatchingLists { + u.switchExport(r, v) + } + + for _, v := range m.HotspotVouchers { + u.switchExport(r, v) + } + + for _, v := range m.DPIApplications { + u.switchExport(r, v) + } + + for _, v := range m.DPICategories { + u.switchExport(r, v) + } + + for _, v := range m.PendingDevices { + u.switchExport(r, v) + } + + for _, v := range m.Countries { + u.switchExport(r, v) + } } func (u *DatadogUnifi) switchExport(r report, v any) { //nolint:cyclop @@ -385,6 +469,48 @@ func (u *DatadogUnifi) switchExport(r report, v any) { //nolint:cyclop u.batchPortAnomaly(r, v) case *unifi.MagicSiteToSiteVPN: u.batchMagicSiteToSiteVPN(r, v) + case *unifi.PortForward: + u.batchPortForward(r, v) + case *unifi.SSLCertificate: + u.batchSSLCertificate(r, v) + case *unifi.UPSDeviceSelector: + u.batchUPSDevice(r, v) + case *unifi.WANStatus: + u.batchWANStatus(r, v) + case *unifi.IntegrationDeviceStats: + u.batchIntegrationDevStats(r, v) + case *unifi.WifiBroadcast: + u.batchWifiBroadcast(r, v) + case *unifi.FirewallZone: + u.batchFirewallZone(r, v) + case *unifi.ACLRule: + u.batchACLRule(r, v) + case *unifi.VPNServer: + u.batchVPNServer(r, v) + case *unifi.SiteToSiteTunnel: + u.batchSiteToSiteTunnel(r, v) + case *unifi.LAG: + u.batchLAG(r, v) + case *unifi.MCLAGDomain: + u.batchMCLAGDomain(r, v) + case *unifi.SwitchStack: + u.batchSwitchStack(r, v) + case *unifi.DNSPolicy: + u.batchDNSPolicy(r, v) + case *unifi.RADIUSProfile: + u.batchRADIUSProfile(r, v) + case *unifi.TrafficMatchingList: + u.batchTrafficMatchingList(r, v) + case *unifi.HotspotVoucher: + u.batchHotspotVoucher(r, v) + case *unifi.DPIApplication: + u.batchDPIApplication(r, v) + case *unifi.DPICategory: + u.batchDPICategory(r, v) + case *unifi.PendingDevice: + u.batchPendingDevice(r, v) + case *unifi.Country: + u.batchCountry(r, v) default: if u.Collector != nil && u.Collector.Poller().LogUnknownTypes { u.LogDebugf("unknown export type: %T", v) diff --git a/pkg/datadogunifi/integration_devices.go b/pkg/datadogunifi/integration_devices.go new file mode 100644 index 00000000..0dca8cbb --- /dev/null +++ b/pkg/datadogunifi/integration_devices.go @@ -0,0 +1,52 @@ +package datadogunifi + +import ( + "github.com/unpoller/unifi/v5" +) + +// batchIntegrationDevStats generates integration device statistics datapoints for Datadog. +func (u *DatadogUnifi) batchIntegrationDevStats(r report, ids *unifi.IntegrationDeviceStats) { + if ids == nil { + return + } + + metricName := metricNamespace("integration_device") + + tags := cleanTags(map[string]string{ + "device_id": ids.DeviceID, + }) + + tagSlice := tagMapToTags(tags) + + _ = r.reportGauge(metricName("cpu_utilization_pct"), ids.CPUUtilizationPct.Val, tagSlice) + _ = r.reportGauge(metricName("memory_utilization_pct"), ids.MemoryUtilizationPct.Val, tagSlice) + _ = r.reportGauge(metricName("uptime_sec"), ids.UptimeSec.Val, tagSlice) + _ = r.reportGauge(metricName("load_average_1min"), ids.LoadAverage1Min.Val, tagSlice) + _ = r.reportGauge(metricName("load_average_5min"), ids.LoadAverage5Min.Val, tagSlice) + _ = r.reportGauge(metricName("load_average_15min"), ids.LoadAverage15Min.Val, tagSlice) + + radioMetric := metricNamespace("integration_device_radio") + + for i := range ids.Radios { + radio := &ids.Radios[i] + + radioTags := append(tagSlice, + tag("frequency_ghz", radio.FrequencyGHz.Val), + ) + + _ = r.reportGauge(radioMetric("tx_retries_pct"), radio.TxRetriesPct.Val, radioTags) + } + + uplinkMetric := metricNamespace("integration_device_uplink") + + for i := range ids.Uplinks { + uplink := &ids.Uplinks[i] + + uplinkTags := append(tagSlice, + tag("uplink_index", i), + ) + + _ = r.reportGauge(uplinkMetric("rx_rate_bps"), uplink.RxRateBps.Val, uplinkTags) + _ = r.reportGauge(uplinkMetric("tx_rate_bps"), uplink.TxRateBps.Val, uplinkTags) + } +} diff --git a/pkg/datadogunifi/integration_global.go b/pkg/datadogunifi/integration_global.go new file mode 100644 index 00000000..03c14240 --- /dev/null +++ b/pkg/datadogunifi/integration_global.go @@ -0,0 +1,75 @@ +package datadogunifi + +import ( + "github.com/unpoller/unifi/v5" +) + +// batchDPIApplication generates DPI application catalogue datapoints for Datadog. +// DPIApplications are global (no site scope). +func (u *DatadogUnifi) batchDPIApplication(r report, da *unifi.DPIApplication) { + if da == nil { + return + } + + metricName := metricNamespace("dpi_application") + + tags := cleanTags(map[string]string{ + "name": da.Name, + }) + + _ = r.reportGauge(metricName("id"), da.ID.Val, tagMapToTags(tags)) +} + +// batchDPICategory generates DPI category catalogue datapoints for Datadog. +// DPICategories are global (no site scope). +func (u *DatadogUnifi) batchDPICategory(r report, dc *unifi.DPICategory) { + if dc == nil { + return + } + + metricName := metricNamespace("dpi_category") + + tags := cleanTags(map[string]string{ + "name": dc.Name, + }) + + _ = r.reportGauge(metricName("id"), dc.ID.Val, tagMapToTags(tags)) +} + +// batchPendingDevice generates PendingDevice (adoption queue) datapoints for Datadog. +// PendingDevices are global (no site scope). +func (u *DatadogUnifi) batchPendingDevice(r report, pd *unifi.PendingDevice) { + if pd == nil { + return + } + + metricName := metricNamespace("pending_device") + + tags := cleanTags(map[string]string{ + "mac_address": pd.MACAddress, + "ip_address": pd.IPAddress, + "model": pd.Model, + "state": pd.State, + }) + + _ = r.reportGauge(metricName("supported"), boolToFloat64(pd.Supported), tagMapToTags(tags)) + _ = r.reportGauge(metricName("firmware_updatable"), boolToFloat64(pd.FirmwareUpdatable), tagMapToTags(tags)) +} + +// batchCountry generates Country datapoints for Datadog. +// Countries are global (no site scope). +func (u *DatadogUnifi) batchCountry(r report, co *unifi.Country) { + if co == nil { + return + } + + metricName := metricNamespace("country") + + tags := cleanTags(map[string]string{ + "code": co.Code, + "name": co.Name, + }) + + // Emit a presence gauge (1.0 = country entry exists in the catalogue). + _ = r.reportGauge(metricName("present"), 1.0, tagMapToTags(tags)) +} diff --git a/pkg/datadogunifi/integration_network.go b/pkg/datadogunifi/integration_network.go new file mode 100644 index 00000000..aa4f6d4b --- /dev/null +++ b/pkg/datadogunifi/integration_network.go @@ -0,0 +1,235 @@ +package datadogunifi + +import ( + "github.com/unpoller/unifi/v5" +) + +// batchWifiBroadcast generates WifiBroadcast (SSID) datapoints for Datadog. +func (u *DatadogUnifi) batchWifiBroadcast(r report, wb *unifi.WifiBroadcast) { + if wb == nil { + return + } + + metricName := metricNamespace("wifi_broadcast") + + tags := cleanTags(map[string]string{ + "site_name": wb.SiteName, + "id": wb.ID, + "name": wb.Name, + "network": wb.Network, + "security_type": wb.SecurityConfiguration.Type, + }) + + _ = r.reportGauge(metricName("enabled"), boolToFloat64(wb.Enabled), tagMapToTags(tags)) +} + +// batchFirewallZone generates FirewallZone datapoints for Datadog. +func (u *DatadogUnifi) batchFirewallZone(r report, fz *unifi.FirewallZone) { + if fz == nil { + return + } + + metricName := metricNamespace("firewall_zone") + + tags := cleanTags(map[string]string{ + "site_name": fz.SiteName, + "id": fz.ID, + "name": fz.Name, + "origin": fz.Metadata.Origin, + }) + + _ = r.reportGauge(metricName("network_count"), float64(len(fz.NetworkIDs)), tagMapToTags(tags)) +} + +// batchACLRule generates ACLRule datapoints for Datadog. +func (u *DatadogUnifi) batchACLRule(r report, acl *unifi.ACLRule) { + if acl == nil { + return + } + + metricName := metricNamespace("acl_rule") + + tags := cleanTags(map[string]string{ + "site_name": acl.SiteName, + "id": acl.ID, + "name": acl.Name, + "action": acl.Action, + "source_filter": acl.SourceFilter, + }) + + _ = r.reportGauge(metricName("enabled"), boolToFloat64(acl.Enabled), tagMapToTags(tags)) + _ = r.reportGauge(metricName("index"), acl.Index.Val, tagMapToTags(tags)) + _ = r.reportGauge(metricName("enforcing_device_count"), float64(len(acl.EnforcingDeviceFilter)), tagMapToTags(tags)) +} + +// batchVPNServer generates VPNServer datapoints for Datadog. +func (u *DatadogUnifi) batchVPNServer(r report, vs *unifi.VPNServer) { + if vs == nil { + return + } + + metricName := metricNamespace("vpn_server") + + tags := cleanTags(map[string]string{ + "site_name": vs.SiteName, + "id": vs.ID, + "name": vs.Name, + "type": vs.Type, + "origin": vs.Metadata.Origin, + }) + + _ = r.reportGauge(metricName("enabled"), boolToFloat64(vs.Enabled), tagMapToTags(tags)) +} + +// batchSiteToSiteTunnel generates SiteToSiteTunnel datapoints for Datadog. +func (u *DatadogUnifi) batchSiteToSiteTunnel(r report, tun *unifi.SiteToSiteTunnel) { + if tun == nil { + return + } + + metricName := metricNamespace("site_to_site_tunnel") + + tags := cleanTags(map[string]string{ + "site_name": tun.SiteName, + "id": tun.ID, + "name": tun.Name, + "type": tun.Type, + "origin": tun.Metadata.Origin, + }) + + // Emit a presence gauge (1.0 = tunnel is configured). + _ = r.reportGauge(metricName("present"), 1.0, tagMapToTags(tags)) +} + +// batchLAG generates LAG (link aggregation group) datapoints for Datadog. +func (u *DatadogUnifi) batchLAG(r report, lag *unifi.LAG) { + if lag == nil { + return + } + + metricName := metricNamespace("lag") + + tags := cleanTags(map[string]string{ + "site_name": lag.SiteName, + "id": lag.ID, + "type": lag.Type, + "origin": lag.Metadata.Origin, + }) + + _ = r.reportGauge(metricName("member_count"), float64(len(lag.Members)), tagMapToTags(tags)) +} + +// batchMCLAGDomain generates MCLAGDomain datapoints for Datadog. +func (u *DatadogUnifi) batchMCLAGDomain(r report, mcd *unifi.MCLAGDomain) { + if mcd == nil { + return + } + + metricName := metricNamespace("mclag_domain") + + tags := cleanTags(map[string]string{ + "site_name": mcd.SiteName, + "id": mcd.ID, + "name": mcd.Name, + "origin": mcd.Metadata.Origin, + }) + + _ = r.reportGauge(metricName("peer_count"), float64(len(mcd.Peers)), tagMapToTags(tags)) + _ = r.reportGauge(metricName("lag_count"), float64(len(mcd.LAGs)), tagMapToTags(tags)) +} + +// batchSwitchStack generates SwitchStack datapoints for Datadog. +func (u *DatadogUnifi) batchSwitchStack(r report, ss *unifi.SwitchStack) { + if ss == nil { + return + } + + metricName := metricNamespace("switch_stack") + + tags := cleanTags(map[string]string{ + "site_name": ss.SiteName, + "id": ss.ID, + "name": ss.Name, + "origin": ss.Metadata.Origin, + }) + + _ = r.reportGauge(metricName("member_count"), float64(len(ss.Members)), tagMapToTags(tags)) + _ = r.reportGauge(metricName("lag_count"), float64(len(ss.LAGs)), tagMapToTags(tags)) +} + +// batchDNSPolicy generates DNSPolicy datapoints for Datadog. +func (u *DatadogUnifi) batchDNSPolicy(r report, dp *unifi.DNSPolicy) { + if dp == nil { + return + } + + metricName := metricNamespace("dns_policy") + + tags := cleanTags(map[string]string{ + "site_name": dp.SiteName, + "id": dp.ID, + "type": dp.Type, + "domain": dp.Domain, + }) + + _ = r.reportGauge(metricName("enabled"), boolToFloat64(dp.Enabled), tagMapToTags(tags)) +} + +// batchRADIUSProfile generates RADIUSProfile datapoints for Datadog. +func (u *DatadogUnifi) batchRADIUSProfile(r report, rp *unifi.RADIUSProfile) { + if rp == nil { + return + } + + metricName := metricNamespace("radius_profile") + + tags := cleanTags(map[string]string{ + "site_name": rp.SiteName, + "id": rp.ID, + "name": rp.Name, + "origin": rp.Metadata.Origin, + }) + + // Emit a presence gauge (1.0 = profile exists). + _ = r.reportGauge(metricName("present"), 1.0, tagMapToTags(tags)) +} + +// batchTrafficMatchingList generates TrafficMatchingList datapoints for Datadog. +func (u *DatadogUnifi) batchTrafficMatchingList(r report, tml *unifi.TrafficMatchingList) { + if tml == nil { + return + } + + metricName := metricNamespace("traffic_matching_list") + + tags := cleanTags(map[string]string{ + "site_name": tml.SiteName, + "id": tml.ID, + "name": tml.Name, + "type": tml.Type, + }) + + // Emit a presence gauge (1.0 = list exists). + _ = r.reportGauge(metricName("present"), 1.0, tagMapToTags(tags)) +} + +// batchHotspotVoucher generates HotspotVoucher datapoints for Datadog. +func (u *DatadogUnifi) batchHotspotVoucher(r report, hv *unifi.HotspotVoucher) { + if hv == nil { + return + } + + metricName := metricNamespace("hotspot_voucher") + + tags := cleanTags(map[string]string{ + "site_name": hv.SiteName, + "id": hv.ID, + "name": hv.Name, + "expires_at": hv.ExpiresAt, + }) + + _ = r.reportGauge(metricName("authorized_guest_count"), hv.AuthorizedGuestCount.Val, tagMapToTags(tags)) + _ = r.reportGauge(metricName("authorized_guest_limit"), hv.AuthorizedGuestLimit.Val, tagMapToTags(tags)) + _ = r.reportGauge(metricName("data_usage_limit_mbytes"), hv.DataUsageLimitMBytes.Val, tagMapToTags(tags)) + _ = r.reportGauge(metricName("time_limit_minutes"), hv.TimeLimitMinutes.Val, tagMapToTags(tags)) +} diff --git a/pkg/datadogunifi/port_forward.go b/pkg/datadogunifi/port_forward.go new file mode 100644 index 00000000..696d323a --- /dev/null +++ b/pkg/datadogunifi/port_forward.go @@ -0,0 +1,54 @@ +package datadogunifi + +import ( + "github.com/unpoller/unifi/v5" +) + +// batchPortForward generates PortForward datapoints for Datadog. +func (u *DatadogUnifi) batchPortForward(r report, pf *unifi.PortForward) { + if pf == nil { + return + } + + metricName := metricNamespace("port_forward") + + tags := cleanTags(map[string]string{ + "site_name": pf.SiteName, + "source": pf.SourceName, + "id": pf.ID, + "name": pf.Name, + "proto": pf.Proto, + "fwd_ip": pf.FwdIP, + "fwd_port": pf.FwdPort, + "dst_port": pf.DstPort, + "pf_iface": pf.PfwdPf, + }) + + _ = r.reportGauge(metricName("enabled"), boolToFloat64(pf.Enabled.Val), tagMapToTags(tags)) + _ = r.reportGauge(metricName("log"), boolToFloat64(pf.Log.Val), tagMapToTags(tags)) +} + +// batchSSLCertificate generates SSLCertificate datapoints for Datadog. +func (u *DatadogUnifi) batchSSLCertificate(r report, cert *unifi.SSLCertificate) { + if cert == nil || cert.ID == "" { + return + } + + metricName := metricNamespace("ssl_cert") + + tags := cleanTags(map[string]string{ + "site_name": cert.SiteName, + "id": cert.ID, + "cert_type": cert.CertType, + "status": cert.Status, + "issuer": cert.Issuer, + "subject": cert.Subject, + "fingerprint": cert.Fingerprint, + }) + + _ = r.reportGauge(metricName("is_active"), boolToFloat64(cert.IsActive.Val), tagMapToTags(tags)) + _ = r.reportGauge(metricName("is_valid"), boolToFloat64(cert.IsValid.Val), tagMapToTags(tags)) + _ = r.reportGauge(metricName("valid_from"), cert.ValidFrom.Val, tagMapToTags(tags)) + _ = r.reportGauge(metricName("valid_to"), cert.ValidTo.Val, tagMapToTags(tags)) + _ = r.reportGauge(metricName("chain_length"), float64(len(cert.Chain)), tagMapToTags(tags)) +} diff --git a/pkg/datadogunifi/ups.go b/pkg/datadogunifi/ups.go new file mode 100644 index 00000000..e923974c --- /dev/null +++ b/pkg/datadogunifi/ups.go @@ -0,0 +1,52 @@ +package datadogunifi + +import ( + "github.com/unpoller/unifi/v5" +) + +// batchUPSDevice generates UPS device selector datapoints for Datadog. +func (u *DatadogUnifi) batchUPSDevice(r report, ups *unifi.UPSDeviceSelector) { + if ups == nil { + return + } + + metricName := metricNamespace("ups_device") + + tags := cleanTags(map[string]string{ + "site_name": ups.SiteName, + "source": ups.SourceName, + "id": ups.ID, + "mac": ups.MAC, + "label": ups.Label, + }) + + // Emit a presence gauge (1.0 = device exists in the adoption list). + _ = r.reportGauge(metricName("present"), 1.0, tagMapToTags(tags)) +} + +// batchWANStatus generates WAN status datapoints for Datadog. +func (u *DatadogUnifi) batchWANStatus(r report, ws *unifi.WANStatus) { + if ws == nil { + return + } + + metricName := metricNamespace("wan_status") + + for i := range ws.WANInterfaces { + iface := &ws.WANInterfaces[i] + + active := 0.0 + if iface.State == "ACTIVE" { + active = 1.0 + } + + tags := cleanTags(map[string]string{ + "site_name": ws.SiteName, + "wan_name": iface.Name, + "wan_networkgroup": iface.WANNetworkgroup, + "state": iface.State, + }) + + _ = r.reportGauge(metricName("active"), active, tagMapToTags(tags)) + } +} diff --git a/pkg/influxunifi/influxdb.go b/pkg/influxunifi/influxdb.go index 6678e704..ca54c91c 100644 --- a/pkg/influxunifi/influxdb.go +++ b/pkg/influxunifi/influxdb.go @@ -450,6 +450,133 @@ func (u *InfluxUnifi) loopPoints(r report) { for _, v := range m.VPNMeshes { u.switchExport(r, v) } + + // v5.26.0 additions. + for _, ds := range m.IntegrationDevStats { + if d, ok := ds.(*unifi.IntegrationDeviceStats); ok { + u.batchIntegrationDeviceStats(r, d) + } + } + + for _, ws := range m.WANStatuses { + if w, ok := ws.(*unifi.WANStatus); ok { + u.batchWANStatus(r, w) + } + } + + for _, pf := range m.PortForwards { + if v, ok := pf.(*unifi.PortForward); ok { + u.batchPortForward(r, v) + } + } + + for _, sc := range m.SSLCertificates { + if v, ok := sc.(*unifi.SSLCertificate); ok { + u.batchSSLCertificate(r, v) + } + } + + for _, ud := range m.UPSDevices { + if v, ok := ud.(*unifi.UPSDeviceSelector); ok { + u.batchUPSDevice(r, v) + } + } + + for _, wb := range m.WifiBroadcasts { + if v, ok := wb.(*unifi.WifiBroadcast); ok { + u.batchWifiBroadcast(r, v) + } + } + + for _, fz := range m.FirewallZones { + if v, ok := fz.(*unifi.FirewallZone); ok { + u.batchFirewallZone(r, v) + } + } + + for _, ar := range m.ACLRules { + if v, ok := ar.(*unifi.ACLRule); ok { + u.batchACLRule(r, v) + } + } + + for _, vs := range m.VPNServers { + if v, ok := vs.(*unifi.VPNServer); ok { + u.batchVPNServer(r, v) + } + } + + for _, st := range m.SiteToSiteTunnels { + if v, ok := st.(*unifi.SiteToSiteTunnel); ok { + u.batchSiteToSiteTunnel(r, v) + } + } + + for _, lag := range m.LAGs { + if v, ok := lag.(*unifi.LAG); ok { + u.batchLAG(r, v) + } + } + + for _, md := range m.MCLAGDomains { + if v, ok := md.(*unifi.MCLAGDomain); ok { + u.batchMCLAGDomain(r, v) + } + } + + for _, ss := range m.SwitchStacks { + if v, ok := ss.(*unifi.SwitchStack); ok { + u.batchSwitchStack(r, v) + } + } + + for _, dp := range m.DNSPolicies { + if v, ok := dp.(*unifi.DNSPolicy); ok { + u.batchDNSPolicy(r, v) + } + } + + for _, rp := range m.RADIUSProfiles { + if v, ok := rp.(*unifi.RADIUSProfile); ok { + u.batchRADIUSProfile(r, v) + } + } + + for _, tml := range m.TrafficMatchingLists { + if v, ok := tml.(*unifi.TrafficMatchingList); ok { + u.batchTrafficMatchingList(r, v) + } + } + + for _, hv := range m.HotspotVouchers { + if v, ok := hv.(*unifi.HotspotVoucher); ok { + u.batchHotspotVoucher(r, v) + } + } + + for _, da := range m.DPIApplications { + if v, ok := da.(*unifi.DPIApplication); ok { + u.batchDPIApplication(r, v) + } + } + + for _, dc := range m.DPICategories { + if v, ok := dc.(*unifi.DPICategory); ok { + u.batchDPICategory(r, v) + } + } + + for _, pd := range m.PendingDevices { + if v, ok := pd.(*unifi.PendingDevice); ok { + u.batchPendingDevice(r, v) + } + } + + for _, co := range m.Countries { + if v, ok := co.(*unifi.Country); ok { + u.batchCountry(r, v) + } + } } func (u *InfluxUnifi) switchExport(r report, v any) { //nolint:cyclop diff --git a/pkg/influxunifi/integration_devices.go b/pkg/influxunifi/integration_devices.go new file mode 100644 index 00000000..cf602361 --- /dev/null +++ b/pkg/influxunifi/integration_devices.go @@ -0,0 +1,60 @@ +package influxunifi + +import ( + "fmt" + + "github.com/unpoller/unifi/v5" +) + +// batchIntegrationDeviceStats generates InfluxDB points for Integration/v1 device statistics. +func (u *InfluxUnifi) batchIntegrationDeviceStats(r report, ds *unifi.IntegrationDeviceStats) { + if ds == nil { + return + } + + tags := map[string]string{ + "device_id": ds.DeviceID, + } + + fields := map[string]any{ + "cpu_utilization_pct": ds.CPUUtilizationPct.Val, + "memory_utilization_pct": ds.MemoryUtilizationPct.Val, + "load_average_1min": ds.LoadAverage1Min.Val, + "load_average_5min": ds.LoadAverage5Min.Val, + "load_average_15min": ds.LoadAverage15Min.Val, + "uptime_sec": ds.UptimeSec.Val, + } + + r.send(&metric{Table: "integration_device_stats", Tags: tags, Fields: fields}) + + for _, radio := range ds.Radios { + radioTags := map[string]string{ + "device_id": ds.DeviceID, + "frequency_ghz": radio.FrequencyGHz.Txt, + } + + r.send(&metric{ + Table: "integration_device_radio", + Tags: radioTags, + Fields: map[string]any{ + "tx_retries_pct": radio.TxRetriesPct.Val, + }, + }) + } + + for i, uplink := range ds.Uplinks { + uplinkTags := map[string]string{ + "device_id": ds.DeviceID, + "uplink_index": fmt.Sprint(i), + } + + r.send(&metric{ + Table: "integration_device_uplink", + Tags: uplinkTags, + Fields: map[string]any{ + "rx_rate_bps": uplink.RxRateBps.Val, + "tx_rate_bps": uplink.TxRateBps.Val, + }, + }) + } +} diff --git a/pkg/influxunifi/integration_global.go b/pkg/influxunifi/integration_global.go new file mode 100644 index 00000000..e0280c35 --- /dev/null +++ b/pkg/influxunifi/integration_global.go @@ -0,0 +1,93 @@ +package influxunifi + +import ( + "github.com/unpoller/unifi/v5" +) + +// batchDPIApplication generates InfluxDB points for a DPI application catalogue entry. +// These are global (no site) reference records; we emit a presence gauge keyed by ID. +func (u *InfluxUnifi) batchDPIApplication(r report, app *unifi.DPIApplication) { + if app == nil { + return + } + + tags := map[string]string{ + "app_id": app.ID.Txt, + "app_name": app.Name, + } + + r.send(&metric{ + Table: "dpi_application", + Tags: tags, + Fields: map[string]any{"app_id_val": app.ID.Val}, + }) +} + +// batchDPICategory generates InfluxDB points for a DPI category catalogue entry. +func (u *InfluxUnifi) batchDPICategory(r report, cat *unifi.DPICategory) { + if cat == nil { + return + } + + tags := map[string]string{ + "cat_id": cat.ID.Txt, + "cat_name": cat.Name, + } + + r.send(&metric{ + Table: "dpi_category", + Tags: tags, + Fields: map[string]any{"cat_id_val": cat.ID.Val}, + }) +} + +// batchPendingDevice generates InfluxDB points for a device awaiting adoption. +func (u *InfluxUnifi) batchPendingDevice(r report, d *unifi.PendingDevice) { + if d == nil { + return + } + + tags := map[string]string{ + "mac": d.MACAddress, + "model": d.Model, + "state": d.State, + "ip": d.IPAddress, + } + + firmwareUpdatable := 0 + if d.FirmwareUpdatable { + firmwareUpdatable = 1 + } + + supported := 0 + if d.Supported { + supported = 1 + } + + fields := map[string]any{ + "firmware_updatable": firmwareUpdatable, + "supported": supported, + "feature_count": len(d.Features), + } + + r.send(&metric{Table: "pending_device", Tags: tags, Fields: fields}) +} + +// batchCountry generates InfluxDB points for a country entry used in geo-based firewall filters. +// Countries are global reference data with no numeric metrics; we emit a presence gauge. +func (u *InfluxUnifi) batchCountry(r report, c *unifi.Country) { + if c == nil { + return + } + + tags := map[string]string{ + "code": c.Code, + "name": c.Name, + } + + r.send(&metric{ + Table: "country", + Tags: tags, + Fields: map[string]any{"present": 1}, + }) +} diff --git a/pkg/influxunifi/integration_network.go b/pkg/influxunifi/integration_network.go new file mode 100644 index 00000000..58370120 --- /dev/null +++ b/pkg/influxunifi/integration_network.go @@ -0,0 +1,302 @@ +package influxunifi + +import ( + "fmt" + + "github.com/unpoller/unifi/v5" +) + +// batchWifiBroadcast generates InfluxDB points for a WiFi SSID broadcast configuration. +func (u *InfluxUnifi) batchWifiBroadcast(r report, wb *unifi.WifiBroadcast) { + if wb == nil { + return + } + + tags := map[string]string{ + "site_name": wb.SiteName, + "broadcast_id": wb.ID, + "broadcast_name": wb.Name, + "network": wb.Network, + "security_type": wb.SecurityConfiguration.Type, + } + + enabled := 0 + if wb.Enabled { + enabled = 1 + } + + fields := map[string]any{ + "enabled": enabled, + "broadcasting_device_count": len(wb.BroadcastingDeviceFilter), + } + + r.send(&metric{Table: "wifi_broadcast", Tags: tags, Fields: fields}) +} + +// batchFirewallZone generates InfluxDB points for a firewall zone. +func (u *InfluxUnifi) batchFirewallZone(r report, fz *unifi.FirewallZone) { + if fz == nil { + return + } + + tags := map[string]string{ + "site_name": fz.SiteName, + "zone_id": fz.ID, + "zone_name": fz.Name, + "origin": fz.Metadata.Origin, + } + + fields := map[string]any{ + "network_count": len(fz.NetworkIDs), + } + + r.send(&metric{Table: "firewall_zone", Tags: tags, Fields: fields}) +} + +// batchACLRule generates InfluxDB points for an ACL rule. +func (u *InfluxUnifi) batchACLRule(r report, rule *unifi.ACLRule) { + if rule == nil { + return + } + + tags := map[string]string{ + "site_name": rule.SiteName, + "rule_id": rule.ID, + "rule_name": rule.Name, + "action": rule.Action, + "source_filter": rule.SourceFilter, + } + + enabled := 0 + if rule.Enabled { + enabled = 1 + } + + fields := map[string]any{ + "enabled": enabled, + "index": rule.Index.Val, + "enforcing_device_count": len(rule.EnforcingDeviceFilter), + } + + r.send(&metric{Table: "acl_rule", Tags: tags, Fields: fields}) +} + +// batchVPNServer generates InfluxDB points for a VPN server configuration. +func (u *InfluxUnifi) batchVPNServer(r report, vs *unifi.VPNServer) { + if vs == nil { + return + } + + tags := map[string]string{ + "site_name": vs.SiteName, + "server_id": vs.ID, + "server_name": vs.Name, + "vpn_type": vs.Type, + "origin": vs.Metadata.Origin, + } + + enabled := 0 + if vs.Enabled { + enabled = 1 + } + + fields := map[string]any{ + "enabled": enabled, + } + + r.send(&metric{Table: "vpn_server", Tags: tags, Fields: fields}) +} + +// batchSiteToSiteTunnel generates InfluxDB points for a site-to-site VPN tunnel. +func (u *InfluxUnifi) batchSiteToSiteTunnel(r report, t *unifi.SiteToSiteTunnel) { + if t == nil { + return + } + + tags := map[string]string{ + "site_name": t.SiteName, + "tunnel_id": t.ID, + "tunnel_name": t.Name, + "tunnel_type": t.Type, + "origin": t.Metadata.Origin, + } + + r.send(&metric{ + Table: "site_to_site_tunnel", + Tags: tags, + Fields: map[string]any{"present": 1}, + }) +} + +// batchLAG generates InfluxDB points for a link aggregation group. +func (u *InfluxUnifi) batchLAG(r report, lag *unifi.LAG) { + if lag == nil { + return + } + + tags := map[string]string{ + "site_name": lag.SiteName, + "lag_id": lag.ID, + "lag_type": lag.Type, + "origin": lag.Metadata.Origin, + } + + totalPorts := 0 + + for _, m := range lag.Members { + totalPorts += len(m.PortIndexes) + } + + fields := map[string]any{ + "member_count": len(lag.Members), + "port_count": totalPorts, + } + + r.send(&metric{Table: "lag", Tags: tags, Fields: fields}) +} + +// batchMCLAGDomain generates InfluxDB points for a multi-chassis LAG domain. +func (u *InfluxUnifi) batchMCLAGDomain(r report, d *unifi.MCLAGDomain) { + if d == nil { + return + } + + tags := map[string]string{ + "site_name": d.SiteName, + "domain_id": d.ID, + "domain_name": d.Name, + "origin": d.Metadata.Origin, + } + + fields := map[string]any{ + "peer_count": len(d.Peers), + "lag_count": len(d.LAGs), + } + + r.send(&metric{Table: "mclag_domain", Tags: tags, Fields: fields}) + + for i, peer := range d.Peers { + peerTags := map[string]string{ + "site_name": d.SiteName, + "domain_id": d.ID, + "domain_name": d.Name, + "peer_index": fmt.Sprint(i), + "device_id": peer.DeviceID, + "role": peer.Role, + } + + r.send(&metric{ + Table: "mclag_peer", + Tags: peerTags, + Fields: map[string]any{"link_port_count": len(peer.LinkPorts)}, + }) + } +} + +// batchSwitchStack generates InfluxDB points for a switch stack. +func (u *InfluxUnifi) batchSwitchStack(r report, ss *unifi.SwitchStack) { + if ss == nil { + return + } + + tags := map[string]string{ + "site_name": ss.SiteName, + "stack_id": ss.ID, + "stack_name": ss.Name, + "origin": ss.Metadata.Origin, + } + + fields := map[string]any{ + "member_count": len(ss.Members), + "lag_count": len(ss.LAGs), + } + + r.send(&metric{Table: "switch_stack", Tags: tags, Fields: fields}) +} + +// batchDNSPolicy generates InfluxDB points for a DNS policy. +func (u *InfluxUnifi) batchDNSPolicy(r report, p *unifi.DNSPolicy) { + if p == nil { + return + } + + tags := map[string]string{ + "site_name": p.SiteName, + "policy_id": p.ID, + "policy_type": p.Type, + "domain": p.Domain, + } + + enabled := 0 + if p.Enabled { + enabled = 1 + } + + fields := map[string]any{ + "enabled": enabled, + } + + r.send(&metric{Table: "dns_policy", Tags: tags, Fields: fields}) +} + +// batchRADIUSProfile generates InfluxDB points for a RADIUS profile. +func (u *InfluxUnifi) batchRADIUSProfile(r report, p *unifi.RADIUSProfile) { + if p == nil { + return + } + + tags := map[string]string{ + "site_name": p.SiteName, + "profile_id": p.ID, + "profile_name": p.Name, + "origin": p.Metadata.Origin, + } + + r.send(&metric{ + Table: "radius_profile", + Tags: tags, + Fields: map[string]any{"present": 1}, + }) +} + +// batchTrafficMatchingList generates InfluxDB points for a traffic matching list. +func (u *InfluxUnifi) batchTrafficMatchingList(r report, l *unifi.TrafficMatchingList) { + if l == nil { + return + } + + tags := map[string]string{ + "site_name": l.SiteName, + "list_id": l.ID, + "list_name": l.Name, + "list_type": l.Type, + } + + r.send(&metric{ + Table: "traffic_matching_list", + Tags: tags, + Fields: map[string]any{"present": 1}, + }) +} + +// batchHotspotVoucher generates InfluxDB points for a hotspot voucher. +func (u *InfluxUnifi) batchHotspotVoucher(r report, v *unifi.HotspotVoucher) { + if v == nil { + return + } + + tags := map[string]string{ + "site_name": v.SiteName, + "voucher_id": v.ID, + "voucher_name": v.Name, + } + + fields := map[string]any{ + "authorized_guest_count": v.AuthorizedGuestCount.Val, + "authorized_guest_limit": v.AuthorizedGuestLimit.Val, + "data_usage_limit_mbytes": v.DataUsageLimitMBytes.Val, + "time_limit_minutes": v.TimeLimitMinutes.Val, + } + + r.send(&metric{Table: "hotspot_voucher", Tags: tags, Fields: fields}) +} diff --git a/pkg/influxunifi/port_forward.go b/pkg/influxunifi/port_forward.go new file mode 100644 index 00000000..f6b82038 --- /dev/null +++ b/pkg/influxunifi/port_forward.go @@ -0,0 +1,72 @@ +package influxunifi + +import ( + "github.com/unpoller/unifi/v5" +) + +// batchPortForward generates InfluxDB points for a port forwarding rule. +func (u *InfluxUnifi) batchPortForward(r report, pf *unifi.PortForward) { + if pf == nil { + return + } + + tags := map[string]string{ + "site_name": pf.SiteName, + "source": pf.SourceName, + "rule_id": pf.ID, + "rule_name": pf.Name, + "proto": pf.Proto, + } + + enabled := 0 + if pf.Enabled.Val { + enabled = 1 + } + + logged := 0 + if pf.Log.Val { + logged = 1 + } + + fields := map[string]any{ + "enabled": enabled, + "logged": logged, + } + + r.send(&metric{Table: "port_forward", Tags: tags, Fields: fields}) +} + +// batchSSLCertificate generates InfluxDB points for an SSL certificate. +func (u *InfluxUnifi) batchSSLCertificate(r report, cert *unifi.SSLCertificate) { + if cert == nil || cert.ID == "" { + return + } + + tags := map[string]string{ + "site_name": cert.SiteName, + "cert_id": cert.ID, + "cert_type": cert.CertType, + "status": cert.Status, + "fingerprint": cert.Fingerprint, + } + + isActive := 0 + if cert.IsActive.Val { + isActive = 1 + } + + isValid := 0 + if cert.IsValid.Val { + isValid = 1 + } + + fields := map[string]any{ + "is_active": isActive, + "is_valid": isValid, + "valid_from": cert.ValidFrom.Val, + "valid_to": cert.ValidTo.Val, + "chain_len": len(cert.Chain), + } + + r.send(&metric{Table: "ssl_certificate", Tags: tags, Fields: fields}) +} diff --git a/pkg/influxunifi/ups.go b/pkg/influxunifi/ups.go new file mode 100644 index 00000000..bd113d19 --- /dev/null +++ b/pkg/influxunifi/ups.go @@ -0,0 +1,28 @@ +package influxunifi + +import ( + "github.com/unpoller/unifi/v5" +) + +// batchUPSDevice generates InfluxDB points for a UPS device selector entry. +// UPSDeviceSelector is a lightweight inventory record; the only meaningful +// time-series value is a presence/count gauge (1 per device per poll cycle). +func (u *InfluxUnifi) batchUPSDevice(r report, d *unifi.UPSDeviceSelector) { + if d == nil { + return + } + + tags := map[string]string{ + "site_name": d.SiteName, + "source": d.SourceName, + "device_id": d.ID, + "mac": d.MAC, + "label": d.Label, + } + + r.send(&metric{ + Table: "ups_device", + Tags: tags, + Fields: map[string]any{"present": 1}, + }) +} diff --git a/pkg/influxunifi/wan_status.go b/pkg/influxunifi/wan_status.go new file mode 100644 index 00000000..89aee821 --- /dev/null +++ b/pkg/influxunifi/wan_status.go @@ -0,0 +1,26 @@ +package influxunifi + +import ( + "github.com/unpoller/unifi/v5" +) + +// batchWANStatus generates InfluxDB points for WAN interface state. +func (u *InfluxUnifi) batchWANStatus(r report, ws *unifi.WANStatus) { + if ws == nil { + return + } + + for _, iface := range ws.WANInterfaces { + tags := map[string]string{ + "site_name": ws.SiteName, + "wan_interface": iface.Name, + "wan_networkgroup": iface.WANNetworkgroup, + } + + r.send(&metric{ + Table: "wan_status", + Tags: tags, + Fields: map[string]any{"state": iface.State}, + }) + } +} diff --git a/pkg/inputunifi/collectevents.go b/pkg/inputunifi/collectevents.go index 05ec4f47..b84736f8 100644 --- a/pkg/inputunifi/collectevents.go +++ b/pkg/inputunifi/collectevents.go @@ -39,9 +39,15 @@ func (u *InputUnifi) collectControllerEvents(c *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. + 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 @@ -121,6 +127,14 @@ func (u *InputUnifi) collectAlarms(logs []any, sites []*unifi.Site, c *Controlle 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 err != nil { return logs, fmt.Errorf("unifi.GetAlarms(): %w", err) } @@ -150,17 +164,21 @@ func (u *InputUnifi) collectAnomalies(logs []any, sites []*unifi.Site, c *Contro 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 { - // 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 - } + if c.DefaultSiteNameOverride != "" && isDefaultSiteName(e.SiteName) { + e.SiteName = c.DefaultSiteNameOverride } logs = append(logs, e) @@ -183,6 +201,15 @@ func (u *InputUnifi) collectEvents(logs []any, sites []*unifi.Site, c *Controlle 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) } @@ -242,6 +269,12 @@ func (u *InputUnifi) collectProtectLogs(logs []any, _ []*unifi.Site, c *Controll 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) } @@ -257,10 +290,11 @@ func (u *InputUnifi) collectProtectLogs(logs []any, _ []*unifi.Site, c *Controll thumbID = thumbID[2:] } - if thumbData, err := c.Unifi.GetProtectEventThumbnail(thumbID); err == nil { - e.ThumbnailBase64 = base64.StdEncoding.EncodeToString(thumbData) + 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 { - u.LogDebugf("Failed to fetch thumbnail for event %s (thumb: %s): %v", e.ID, thumbID, err) + e.ThumbnailBase64 = base64.StdEncoding.EncodeToString(thumbData) } } @@ -289,6 +323,15 @@ func (u *InputUnifi) collectIDs(logs []any, sites []*unifi.Site, c *Controller) 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) } diff --git a/pkg/inputunifi/collector.go b/pkg/inputunifi/collector.go index 6d5ed209..6b1343f9 100644 --- a/pkg/inputunifi/collector.go +++ b/pkg/inputunifi/collector.go @@ -3,6 +3,7 @@ package inputunifi // nolint: gosec import ( "crypto/md5" + "errors" "fmt" "strings" "time" @@ -78,7 +79,7 @@ func (u *InputUnifi) collectController(c *Controller) (*poller.Metrics, error) { metrics, err := u.pollController(c) if err != nil { - u.Logf("Re-authenticating to UniFi Controller: %s", c.URL) + 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) @@ -262,6 +263,14 @@ func (u *InputUnifi) pollController(c *Controller) (*poller.Metrics, error) { 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) + } + // 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 { @@ -458,6 +467,182 @@ func (u *InputUnifi) convertToSiteDPI(clientUsageByApp []*unifi.ClientUsageByApp } } +// 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)) + } +} + // 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. @@ -612,6 +797,156 @@ func (u *InputUnifi) augmentMetrics(c *Controller, metrics *Metrics) *poller.Met 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) + } + // 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 @@ -772,6 +1107,103 @@ func applySiteNameOverride(m *poller.Metrics, overrideName string) { } } } + + // 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. diff --git a/pkg/inputunifi/input.go b/pkg/inputunifi/input.go index b5a8f1ad..ff1d0719 100644 --- a/pkg/inputunifi/input.go +++ b/pkg/inputunifi/input.go @@ -94,6 +94,28 @@ type Metrics struct { Topologies []*unifi.Topology PortAnomalies []*unifi.PortAnomaly VPNMeshes []*unifi.MagicSiteToSiteVPN + // Added in v5.26.0 integration: + WANStatuses []*unifi.WANStatus + PortForwards []*unifi.PortForward + SSLCertificates []*unifi.SSLCertificate + UPSDevices []*unifi.UPSDeviceSelector + IntegrationDevStats []*unifi.IntegrationDeviceStats + WifiBroadcasts []*unifi.WifiBroadcast + FirewallZones []*unifi.FirewallZone + ACLRules []*unifi.ACLRule + VPNServers []*unifi.VPNServer + SiteToSiteTunnels []*unifi.SiteToSiteTunnel + LAGs []*unifi.LAG + MCLAGDomains []*unifi.MCLAGDomain + SwitchStacks []*unifi.SwitchStack + DNSPolicies []*unifi.DNSPolicy + RADIUSProfiles []*unifi.RADIUSProfile + TrafficMatchingLists []*unifi.TrafficMatchingList + HotspotVouchers []*unifi.HotspotVoucher + DPIApplications []*unifi.DPIApplication + DPICategories []*unifi.DPICategory + PendingDevices []*unifi.PendingDevice + Countries []*unifi.Country } func init() { // nolint: gochecknoinits diff --git a/pkg/poller/config.go b/pkg/poller/config.go index d211e063..4986638b 100644 --- a/pkg/poller/config.go +++ b/pkg/poller/config.go @@ -107,6 +107,28 @@ type Metrics struct { PortAnomalies []any VPNMeshes []any ControllerStatuses []ControllerStatus + // Added in v5.26.0 integration: + WANStatuses []any // *unifi.WANStatus — per-site WAN failover state + PortForwards []any // *unifi.PortForward — port forwarding rules + SSLCertificates []any // *unifi.SSLCertificate — controller SSL cert info + UPSDevices []any // *unifi.UPSDeviceSelector — UPS selector list + IntegrationDevStats []any // *unifi.IntegrationDeviceStats — CPU/mem/radio/uplink per device + WifiBroadcasts []any // *unifi.WifiBroadcast — WiFi SSID broadcasts + FirewallZones []any // *unifi.FirewallZone — firewall zones + ACLRules []any // *unifi.ACLRule — access control rules + VPNServers []any // *unifi.VPNServer — VPN server inventory + SiteToSiteTunnels []any // *unifi.SiteToSiteTunnel — site-to-site VPN tunnels + LAGs []any // *unifi.LAG — link aggregation groups + MCLAGDomains []any // *unifi.MCLAGDomain — multi-chassis LAG domains + SwitchStacks []any // *unifi.SwitchStack — switch stacks + DNSPolicies []any // *unifi.DNSPolicy — DNS policies + RADIUSProfiles []any // *unifi.RADIUSProfile — RADIUS profiles + TrafficMatchingLists []any // *unifi.TrafficMatchingList — traffic matching lists + HotspotVouchers []any // *unifi.HotspotVoucher — hotspot vouchers + DPIApplications []any // *unifi.DPIApplication — DPI app catalogue (global) + DPICategories []any // *unifi.DPICategory — DPI category catalogue (global) + PendingDevices []any // *unifi.PendingDevice — devices awaiting adoption (global) + Countries []any // *unifi.Country — country list for geo-filters (global) } // Events defines the type for log entries. diff --git a/pkg/poller/inputs.go b/pkg/poller/inputs.go index ee1dc6e0..c3a913d5 100644 --- a/pkg/poller/inputs.go +++ b/pkg/poller/inputs.go @@ -282,6 +282,27 @@ func AppendMetrics(existing *Metrics, m *Metrics) *Metrics { existing.PortAnomalies = append(existing.PortAnomalies, m.PortAnomalies...) existing.VPNMeshes = append(existing.VPNMeshes, m.VPNMeshes...) existing.ControllerStatuses = append(existing.ControllerStatuses, m.ControllerStatuses...) + existing.WANStatuses = append(existing.WANStatuses, m.WANStatuses...) + existing.PortForwards = append(existing.PortForwards, m.PortForwards...) + existing.SSLCertificates = append(existing.SSLCertificates, m.SSLCertificates...) + existing.UPSDevices = append(existing.UPSDevices, m.UPSDevices...) + existing.IntegrationDevStats = append(existing.IntegrationDevStats, m.IntegrationDevStats...) + existing.WifiBroadcasts = append(existing.WifiBroadcasts, m.WifiBroadcasts...) + existing.FirewallZones = append(existing.FirewallZones, m.FirewallZones...) + existing.ACLRules = append(existing.ACLRules, m.ACLRules...) + existing.VPNServers = append(existing.VPNServers, m.VPNServers...) + existing.SiteToSiteTunnels = append(existing.SiteToSiteTunnels, m.SiteToSiteTunnels...) + existing.LAGs = append(existing.LAGs, m.LAGs...) + existing.MCLAGDomains = append(existing.MCLAGDomains, m.MCLAGDomains...) + existing.SwitchStacks = append(existing.SwitchStacks, m.SwitchStacks...) + existing.DNSPolicies = append(existing.DNSPolicies, m.DNSPolicies...) + existing.RADIUSProfiles = append(existing.RADIUSProfiles, m.RADIUSProfiles...) + existing.TrafficMatchingLists = append(existing.TrafficMatchingLists, m.TrafficMatchingLists...) + existing.HotspotVouchers = append(existing.HotspotVouchers, m.HotspotVouchers...) + existing.DPIApplications = append(existing.DPIApplications, m.DPIApplications...) + existing.DPICategories = append(existing.DPICategories, m.DPICategories...) + existing.PendingDevices = append(existing.PendingDevices, m.PendingDevices...) + existing.Countries = append(existing.Countries, m.Countries...) return existing } diff --git a/pkg/promunifi/collector.go b/pkg/promunifi/collector.go index 91c2df78..a3cacf75 100644 --- a/pkg/promunifi/collector.go +++ b/pkg/promunifi/collector.go @@ -36,24 +36,45 @@ const ( var ErrMetricFetchFailed = fmt.Errorf("metric fetch failed") type promUnifi struct { - *Config `json:"prometheus" toml:"prometheus" xml:"prometheus" yaml:"prometheus"` - Client *uclient - Device *unifiDevice - UAP *uap - USG *usg - USW *usw - PDU *pdu - Site *site - RogueAP *rogueap - SpeedTest *speedtest - CountryTraffic *ucountrytraffic - DHCPLease *dhcplease - WAN *wan - Controller *controller - FirewallPolicy *firewallpolicy - Topology *topology - PortAnomaly *portanomaly - VPNMesh *vpnmesh + *Config `json:"prometheus" toml:"prometheus" xml:"prometheus" yaml:"prometheus"` + Client *uclient + Device *unifiDevice + UAP *uap + USG *usg + USW *usw + PDU *pdu + Site *site + RogueAP *rogueap + SpeedTest *speedtest + CountryTraffic *ucountrytraffic + DHCPLease *dhcplease + WAN *wan + Controller *controller + FirewallPolicy *firewallpolicy + Topology *topology + PortAnomaly *portanomaly + VPNMesh *vpnmesh + IntegrationDevice *integrationDevice + WANStatus *wanStatus + PortForward *portForward + SSLCertificate *sslCertificate + UPSDevice *upsDevice + WifiBroadcast *wifiBroadcast + FirewallZone *firewallZone + ACLRule *aclRule + VPNServer *vpnServer + SiteToSiteTunnel *siteToSiteTunnel + LAG *lag + MCLAGDomain *mclagDomain + SwitchStack *switchStack + DNSPolicy *dnsPolicy + RADIUSProfile *radiusProfile + TrafficMatchingList *trafficMatchingList + HotspotVoucher *hotspotVoucher + DPIApplication *dpiApplication + DPICategory *dpiCategory + PendingDevice *pendingDevice + Country *country // controllerUp tracks per-controller poll success (1) or failure (0). controllerUp *prometheus.GaugeVec // This interface is passed to the Collect() method. The Collect method uses @@ -223,6 +244,27 @@ func (u *promUnifi) Run(c poller.Collect) error { u.Topology = descTopology(u.Namespace + "_") u.PortAnomaly = descPortAnomaly(u.Namespace + "_") u.VPNMesh = descVPNMesh(u.Namespace + "_") + u.IntegrationDevice = descIntegrationDevice(u.Namespace + "_device_") + u.WANStatus = descWANStatus(u.Namespace + "_") + u.PortForward = descPortForward(u.Namespace + "_") + u.SSLCertificate = descSSLCertificate(u.Namespace + "_") + u.UPSDevice = descUPSDevice(u.Namespace + "_") + u.WifiBroadcast = descWifiBroadcast(u.Namespace + "_") + u.FirewallZone = descFirewallZone(u.Namespace + "_") + u.ACLRule = descACLRule(u.Namespace + "_") + u.VPNServer = descVPNServer(u.Namespace + "_") + u.SiteToSiteTunnel = descSiteToSiteTunnel(u.Namespace + "_") + u.LAG = descLAG(u.Namespace + "_") + u.MCLAGDomain = descMCLAGDomain(u.Namespace + "_") + u.SwitchStack = descSwitchStack(u.Namespace + "_") + u.DNSPolicy = descDNSPolicy(u.Namespace + "_") + u.RADIUSProfile = descRADIUSProfile(u.Namespace + "_") + u.TrafficMatchingList = descTrafficMatchingList(u.Namespace + "_") + u.HotspotVoucher = descHotspotVoucher(u.Namespace + "_") + u.DPIApplication = descDPIApplication(u.Namespace + "_") + u.DPICategory = descDPICategory(u.Namespace + "_") + u.PendingDevice = descPendingDevice(u.Namespace + "_") + u.Country = descCountry(u.Namespace + "_") u.controllerUp = prometheus.NewGaugeVec(prometheus.GaugeOpts{ Name: u.Namespace + "_controller_up", Help: "Whether the last poll of the UniFi controller succeeded (1) or failed (0).", @@ -311,7 +353,16 @@ func (t *target) Describe(ch chan<- *prometheus.Desc) { // Describe satisfies the prometheus Collector. This returns all of the // metric descriptions that this packages produces. func (u *promUnifi) Describe(ch chan<- *prometheus.Desc) { - for _, f := range []any{u.Client, u.Device, u.UAP, u.USG, u.USW, u.PDU, u.Site, u.SpeedTest, u.DHCPLease, u.WAN, u.FirewallPolicy, u.Topology, u.PortAnomaly, u.VPNMesh} { + for _, f := range []any{ + u.Client, u.Device, u.UAP, u.USG, u.USW, u.PDU, u.Site, u.SpeedTest, + u.DHCPLease, u.WAN, u.FirewallPolicy, u.Topology, u.PortAnomaly, u.VPNMesh, + u.IntegrationDevice, u.WANStatus, + u.PortForward, u.SSLCertificate, u.UPSDevice, u.WifiBroadcast, + u.FirewallZone, u.ACLRule, u.VPNServer, u.SiteToSiteTunnel, + u.LAG, u.MCLAGDomain, u.SwitchStack, u.DNSPolicy, u.RADIUSProfile, + u.TrafficMatchingList, u.HotspotVoucher, + u.DPIApplication, u.DPICategory, u.PendingDevice, u.Country, + } { v := reflect.Indirect(reflect.ValueOf(f)) // Loop each struct member and send it to the provided channel. @@ -510,6 +561,133 @@ func (u *promUnifi) loopExports(r report) { } } + // v5.26.0 additions. + for _, ds := range m.IntegrationDevStats { + if d, ok := ds.(*unifi.IntegrationDeviceStats); ok { + u.exportIntegrationDeviceStats(r, d) + } + } + + for _, ws := range m.WANStatuses { + if w, ok := ws.(*unifi.WANStatus); ok { + u.exportWANStatus(r, w) + } + } + + for _, pf := range m.PortForwards { + if v, ok := pf.(*unifi.PortForward); ok { + u.exportPortForward(r, v) + } + } + + for _, sc := range m.SSLCertificates { + if v, ok := sc.(*unifi.SSLCertificate); ok { + u.exportSSLCertificate(r, v) + } + } + + for _, ud := range m.UPSDevices { + if v, ok := ud.(*unifi.UPSDeviceSelector); ok { + u.exportUPSDevice(r, v) + } + } + + for _, wb := range m.WifiBroadcasts { + if v, ok := wb.(*unifi.WifiBroadcast); ok { + u.exportWifiBroadcast(r, v) + } + } + + for _, fz := range m.FirewallZones { + if v, ok := fz.(*unifi.FirewallZone); ok { + u.exportFirewallZone(r, v) + } + } + + for _, ar := range m.ACLRules { + if v, ok := ar.(*unifi.ACLRule); ok { + u.exportACLRule(r, v) + } + } + + for _, vs := range m.VPNServers { + if v, ok := vs.(*unifi.VPNServer); ok { + u.exportVPNServer(r, v) + } + } + + for _, st := range m.SiteToSiteTunnels { + if v, ok := st.(*unifi.SiteToSiteTunnel); ok { + u.exportSiteToSiteTunnel(r, v) + } + } + + for _, l := range m.LAGs { + if v, ok := l.(*unifi.LAG); ok { + u.exportLAG(r, v) + } + } + + for _, md := range m.MCLAGDomains { + if v, ok := md.(*unifi.MCLAGDomain); ok { + u.exportMCLAGDomain(r, v) + } + } + + for _, ss := range m.SwitchStacks { + if v, ok := ss.(*unifi.SwitchStack); ok { + u.exportSwitchStack(r, v) + } + } + + for _, dp := range m.DNSPolicies { + if v, ok := dp.(*unifi.DNSPolicy); ok { + u.exportDNSPolicy(r, v) + } + } + + for _, rp := range m.RADIUSProfiles { + if v, ok := rp.(*unifi.RADIUSProfile); ok { + u.exportRADIUSProfile(r, v) + } + } + + for _, tml := range m.TrafficMatchingLists { + if v, ok := tml.(*unifi.TrafficMatchingList); ok { + u.exportTrafficMatchingList(r, v) + } + } + + for _, hv := range m.HotspotVouchers { + if v, ok := hv.(*unifi.HotspotVoucher); ok { + u.exportHotspotVoucher(r, v) + } + } + + for _, app := range m.DPIApplications { + if v, ok := app.(*unifi.DPIApplication); ok { + u.exportDPIApplication(r, v) + } + } + + for _, cat := range m.DPICategories { + if v, ok := cat.(*unifi.DPICategory); ok { + u.exportDPICategory(r, v) + } + } + + for _, pd := range m.PendingDevices { + if v, ok := pd.(*unifi.PendingDevice); ok { + u.exportPendingDevice(r, v) + } + } + + for _, c := range m.Countries { + if v, ok := c.(*unifi.Country); ok { + u.exportCountry(r, v) + } + } + u.exportClientDPItotals(r, appTotal, catTotal) } diff --git a/pkg/promunifi/integration_devices.go b/pkg/promunifi/integration_devices.go new file mode 100644 index 00000000..81490855 --- /dev/null +++ b/pkg/promunifi/integration_devices.go @@ -0,0 +1,72 @@ +package promunifi + +import ( + "fmt" + + "github.com/prometheus/client_golang/prometheus" + "github.com/unpoller/unifi/v5" +) + +type integrationDevice struct { + CPUUtilizationPct *prometheus.Desc + MemoryUtilizationPct *prometheus.Desc + LoadAverage1Min *prometheus.Desc + LoadAverage5Min *prometheus.Desc + LoadAverage15Min *prometheus.Desc + UptimeSec *prometheus.Desc + RadioTxRetriesPct *prometheus.Desc + UplinkRxRateBps *prometheus.Desc + UplinkTxRateBps *prometheus.Desc +} + +func descIntegrationDevice(ns string) *integrationDevice { + labels := []string{"device_id"} + radioLabels := []string{"device_id", "frequency_ghz"} + uplinkLabels := []string{"device_id", "uplink_index"} + + return &integrationDevice{ + CPUUtilizationPct: prometheus.NewDesc(ns+"cpu_utilization_pct", "Device CPU utilization percentage (Integration/v1)", labels, nil), + MemoryUtilizationPct: prometheus.NewDesc(ns+"memory_utilization_pct", "Device memory utilization percentage (Integration/v1)", labels, nil), + LoadAverage1Min: prometheus.NewDesc(ns+"load_average_1min", "Device 1-minute load average (Integration/v1)", labels, nil), + LoadAverage5Min: prometheus.NewDesc(ns+"load_average_5min", "Device 5-minute load average (Integration/v1)", labels, nil), + LoadAverage15Min: prometheus.NewDesc(ns+"load_average_15min", "Device 15-minute load average (Integration/v1)", labels, nil), + UptimeSec: prometheus.NewDesc(ns+"uptime_seconds", "Device uptime in seconds (Integration/v1)", labels, nil), + RadioTxRetriesPct: prometheus.NewDesc(ns+"radio_tx_retries_pct", "Per-radio TX retry percentage (Integration/v1)", radioLabels, nil), + UplinkRxRateBps: prometheus.NewDesc(ns+"uplink_rx_rate_bps", "Per-uplink receive rate in bps (Integration/v1)", uplinkLabels, nil), + UplinkTxRateBps: prometheus.NewDesc(ns+"uplink_tx_rate_bps", "Per-uplink transmit rate in bps (Integration/v1)", uplinkLabels, nil), + } +} + +func (u *promUnifi) exportIntegrationDeviceStats(r report, ds *unifi.IntegrationDeviceStats) { + if ds == nil { + return + } + + labels := []string{ds.DeviceID} + + r.send([]*metric{ + {u.IntegrationDevice.CPUUtilizationPct, gauge, ds.CPUUtilizationPct, labels}, + {u.IntegrationDevice.MemoryUtilizationPct, gauge, ds.MemoryUtilizationPct, labels}, + {u.IntegrationDevice.LoadAverage1Min, gauge, ds.LoadAverage1Min, labels}, + {u.IntegrationDevice.LoadAverage5Min, gauge, ds.LoadAverage5Min, labels}, + {u.IntegrationDevice.LoadAverage15Min, gauge, ds.LoadAverage15Min, labels}, + {u.IntegrationDevice.UptimeSec, gauge, ds.UptimeSec, labels}, + }) + + for _, radio := range ds.Radios { + radioLabels := []string{ds.DeviceID, radio.FrequencyGHz.Txt} + + r.send([]*metric{ + {u.IntegrationDevice.RadioTxRetriesPct, gauge, radio.TxRetriesPct, radioLabels}, + }) + } + + for i, uplink := range ds.Uplinks { + uplinkLabels := []string{ds.DeviceID, fmt.Sprint(i)} + + r.send([]*metric{ + {u.IntegrationDevice.UplinkRxRateBps, gauge, uplink.RxRateBps, uplinkLabels}, + {u.IntegrationDevice.UplinkTxRateBps, gauge, uplink.TxRateBps, uplinkLabels}, + }) + } +} diff --git a/pkg/promunifi/integration_global.go b/pkg/promunifi/integration_global.go new file mode 100644 index 00000000..778f7cfb --- /dev/null +++ b/pkg/promunifi/integration_global.go @@ -0,0 +1,133 @@ +package promunifi + +import ( + "github.com/prometheus/client_golang/prometheus" + "github.com/unpoller/unifi/v5" +) + +// dpiApplication holds Prometheus descriptors for DPI application catalogue metrics. +// No site label — this is a global catalogue. +type dpiApplication struct { + Presence *prometheus.Desc +} + +func descDPIApplication(ns string) *dpiApplication { + labels := []string{"app_id", "name"} + + return &dpiApplication{ + Presence: prometheus.NewDesc(ns+"dpi_application_present", + "DPI application catalogue entry present (always 1)", + labels, nil), + } +} + +func (u *promUnifi) exportDPIApplication(r report, app *unifi.DPIApplication) { + if app == nil { + return + } + + labels := []string{app.ID.Txt, app.Name} + + r.send([]*metric{ + {u.DPIApplication.Presence, gauge, 1.0, labels}, + }) +} + +// dpiCategory holds Prometheus descriptors for DPI category catalogue metrics. +// No site label — this is a global catalogue. +type dpiCategory struct { + Presence *prometheus.Desc +} + +func descDPICategory(ns string) *dpiCategory { + labels := []string{"cat_id", "name"} + + return &dpiCategory{ + Presence: prometheus.NewDesc(ns+"dpi_category_present", + "DPI category catalogue entry present (always 1)", + labels, nil), + } +} + +func (u *promUnifi) exportDPICategory(r report, cat *unifi.DPICategory) { + if cat == nil { + return + } + + labels := []string{cat.ID.Txt, cat.Name} + + r.send([]*metric{ + {u.DPICategory.Presence, gauge, 1.0, labels}, + }) +} + +// pendingDevice holds Prometheus descriptors for pending-adoption device metrics. +// No site label — these are controller-global. +type pendingDevice struct { + FirmwareUpdatable *prometheus.Desc + Supported *prometheus.Desc +} + +func descPendingDevice(ns string) *pendingDevice { + labels := []string{"mac_address", "model", "state", "firmware_version"} + + return &pendingDevice{ + FirmwareUpdatable: prometheus.NewDesc(ns+"pending_device_firmware_updatable", + "Pending device has a firmware update available (1=yes, 0=no)", + labels, nil), + Supported: prometheus.NewDesc(ns+"pending_device_supported", + "Pending device model is supported by the controller (1=yes, 0=no)", + labels, nil), + } +} + +func (u *promUnifi) exportPendingDevice(r report, pd *unifi.PendingDevice) { + if pd == nil { + return + } + + firmwareUpdatable := 0.0 + if pd.FirmwareUpdatable { + firmwareUpdatable = 1.0 + } + + supported := 0.0 + if pd.Supported { + supported = 1.0 + } + + labels := []string{pd.MACAddress, pd.Model, pd.State, pd.FirmwareVersion} + + r.send([]*metric{ + {u.PendingDevice.FirmwareUpdatable, gauge, firmwareUpdatable, labels}, + {u.PendingDevice.Supported, gauge, supported, labels}, + }) +} + +// country holds Prometheus descriptors for country list metrics. +// No site label — this is a global geo-filter catalogue. +type country struct { + Presence *prometheus.Desc +} + +func descCountry(ns string) *country { + labels := []string{"code", "name"} + + return &country{ + Presence: prometheus.NewDesc(ns+"country_present", + "Country entry present in geo-filter catalogue (always 1)", + labels, nil), + } +} + +func (u *promUnifi) exportCountry(r report, c *unifi.Country) { + if c == nil { + return + } + + labels := []string{c.Code, c.Name} + + r.send([]*metric{ + {u.Country.Presence, gauge, 1.0, labels}, + }) +} diff --git a/pkg/promunifi/integration_network.go b/pkg/promunifi/integration_network.go new file mode 100644 index 00000000..86c0e482 --- /dev/null +++ b/pkg/promunifi/integration_network.go @@ -0,0 +1,375 @@ +package promunifi + +import ( + "github.com/prometheus/client_golang/prometheus" + "github.com/unpoller/unifi/v5" +) + +// wifiBroadcast holds Prometheus descriptors for WiFi SSID broadcast metrics. +type wifiBroadcast struct { + Enabled *prometheus.Desc +} + +func descWifiBroadcast(ns string) *wifiBroadcast { + labels := []string{"site_name", "name", "network", "security_type"} + + return &wifiBroadcast{ + Enabled: prometheus.NewDesc(ns+"wifi_broadcast_enabled", + "WiFi SSID broadcast enabled (1=enabled, 0=disabled)", + labels, nil), + } +} + +func (u *promUnifi) exportWifiBroadcast(r report, wb *unifi.WifiBroadcast) { + if wb == nil { + return + } + + enabled := 0.0 + if wb.Enabled { + enabled = 1.0 + } + + labels := []string{wb.SiteName, wb.Name, wb.Network, wb.SecurityConfiguration.Type} + + r.send([]*metric{ + {u.WifiBroadcast.Enabled, gauge, enabled, labels}, + }) +} + +// firewallZone holds Prometheus descriptors for firewall zone metrics. +type firewallZone struct { + NetworkCount *prometheus.Desc +} + +func descFirewallZone(ns string) *firewallZone { + labels := []string{"site_name", "name", "origin"} + + return &firewallZone{ + NetworkCount: prometheus.NewDesc(ns+"firewall_zone_network_count", + "Number of networks assigned to the firewall zone", + labels, nil), + } +} + +func (u *promUnifi) exportFirewallZone(r report, fz *unifi.FirewallZone) { + if fz == nil { + return + } + + labels := []string{fz.SiteName, fz.Name, fz.Metadata.Origin} + + r.send([]*metric{ + {u.FirewallZone.NetworkCount, gauge, float64(len(fz.NetworkIDs)), labels}, + }) +} + +// aclRule holds Prometheus descriptors for ACL rule metrics. +type aclRule struct { + Enabled *prometheus.Desc + Index *prometheus.Desc +} + +func descACLRule(ns string) *aclRule { + labels := []string{"site_name", "name", "action"} + + return &aclRule{ + Enabled: prometheus.NewDesc(ns+"acl_rule_enabled", + "ACL rule enabled (1=enabled, 0=disabled)", + labels, nil), + Index: prometheus.NewDesc(ns+"acl_rule_index", + "ACL rule evaluation order index", + labels, nil), + } +} + +func (u *promUnifi) exportACLRule(r report, ar *unifi.ACLRule) { + if ar == nil { + return + } + + enabled := 0.0 + if ar.Enabled { + enabled = 1.0 + } + + labels := []string{ar.SiteName, ar.Name, ar.Action} + + r.send([]*metric{ + {u.ACLRule.Enabled, gauge, enabled, labels}, + {u.ACLRule.Index, gauge, ar.Index, labels}, + }) +} + +// vpnServer holds Prometheus descriptors for VPN server metrics. +type vpnServer struct { + Enabled *prometheus.Desc +} + +func descVPNServer(ns string) *vpnServer { + labels := []string{"site_name", "name", "vpn_type", "origin"} + + return &vpnServer{ + Enabled: prometheus.NewDesc(ns+"vpn_server_enabled", + "VPN server enabled (1=enabled, 0=disabled)", + labels, nil), + } +} + +func (u *promUnifi) exportVPNServer(r report, vs *unifi.VPNServer) { + if vs == nil { + return + } + + enabled := 0.0 + if vs.Enabled { + enabled = 1.0 + } + + labels := []string{vs.SiteName, vs.Name, vs.Type, vs.Metadata.Origin} + + r.send([]*metric{ + {u.VPNServer.Enabled, gauge, enabled, labels}, + }) +} + +// siteToSiteTunnel holds Prometheus descriptors for site-to-site VPN tunnel metrics. +type siteToSiteTunnel struct { + Presence *prometheus.Desc +} + +func descSiteToSiteTunnel(ns string) *siteToSiteTunnel { + labels := []string{"site_name", "name", "tunnel_type", "origin"} + + return &siteToSiteTunnel{ + Presence: prometheus.NewDesc(ns+"site_to_site_tunnel_present", + "Site-to-site VPN tunnel configured (always 1 when present)", + labels, nil), + } +} + +func (u *promUnifi) exportSiteToSiteTunnel(r report, t *unifi.SiteToSiteTunnel) { + if t == nil { + return + } + + labels := []string{t.SiteName, t.Name, t.Type, t.Metadata.Origin} + + r.send([]*metric{ + {u.SiteToSiteTunnel.Presence, gauge, 1.0, labels}, + }) +} + +// lag holds Prometheus descriptors for Link Aggregation Group metrics. +type lag struct { + MemberCount *prometheus.Desc +} + +func descLAG(ns string) *lag { + labels := []string{"site_name", "lag_id", "lag_type", "origin"} + + return &lag{ + MemberCount: prometheus.NewDesc(ns+"lag_member_count", + "Number of member entries in the LAG", + labels, nil), + } +} + +func (u *promUnifi) exportLAG(r report, l *unifi.LAG) { + if l == nil { + return + } + + labels := []string{l.SiteName, l.ID, l.Type, l.Metadata.Origin} + + r.send([]*metric{ + {u.LAG.MemberCount, gauge, float64(len(l.Members)), labels}, + }) +} + +// mclagDomain holds Prometheus descriptors for MC-LAG domain metrics. +type mclagDomain struct { + LAGCount *prometheus.Desc + PeerCount *prometheus.Desc +} + +func descMCLAGDomain(ns string) *mclagDomain { + labels := []string{"site_name", "name", "origin"} + + return &mclagDomain{ + LAGCount: prometheus.NewDesc(ns+"mclag_domain_lag_count", + "Number of LAGs in the MC-LAG domain", + labels, nil), + PeerCount: prometheus.NewDesc(ns+"mclag_domain_peer_count", + "Number of peer devices in the MC-LAG domain", + labels, nil), + } +} + +func (u *promUnifi) exportMCLAGDomain(r report, d *unifi.MCLAGDomain) { + if d == nil { + return + } + + labels := []string{d.SiteName, d.Name, d.Metadata.Origin} + + r.send([]*metric{ + {u.MCLAGDomain.LAGCount, gauge, float64(len(d.LAGs)), labels}, + {u.MCLAGDomain.PeerCount, gauge, float64(len(d.Peers)), labels}, + }) +} + +// switchStack holds Prometheus descriptors for switch stack metrics. +type switchStack struct { + MemberCount *prometheus.Desc +} + +func descSwitchStack(ns string) *switchStack { + labels := []string{"site_name", "name", "origin"} + + return &switchStack{ + MemberCount: prometheus.NewDesc(ns+"switch_stack_member_count", + "Number of member devices in the switch stack", + labels, nil), + } +} + +func (u *promUnifi) exportSwitchStack(r report, s *unifi.SwitchStack) { + if s == nil { + return + } + + labels := []string{s.SiteName, s.Name, s.Metadata.Origin} + + r.send([]*metric{ + {u.SwitchStack.MemberCount, gauge, float64(len(s.Members)), labels}, + }) +} + +// dnsPolicy holds Prometheus descriptors for DNS policy metrics. +type dnsPolicy struct { + Enabled *prometheus.Desc +} + +func descDNSPolicy(ns string) *dnsPolicy { + labels := []string{"site_name", "domain", "policy_type"} + + return &dnsPolicy{ + Enabled: prometheus.NewDesc(ns+"dns_policy_enabled", + "DNS policy enabled (1=enabled, 0=disabled)", + labels, nil), + } +} + +func (u *promUnifi) exportDNSPolicy(r report, dp *unifi.DNSPolicy) { + if dp == nil { + return + } + + enabled := 0.0 + if dp.Enabled { + enabled = 1.0 + } + + labels := []string{dp.SiteName, dp.Domain, dp.Type} + + r.send([]*metric{ + {u.DNSPolicy.Enabled, gauge, enabled, labels}, + }) +} + +// radiusProfile holds Prometheus descriptors for RADIUS profile metrics. +type radiusProfile struct { + Presence *prometheus.Desc +} + +func descRADIUSProfile(ns string) *radiusProfile { + labels := []string{"site_name", "name", "origin"} + + return &radiusProfile{ + Presence: prometheus.NewDesc(ns+"radius_profile_present", + "RADIUS profile configured (always 1 when present)", + labels, nil), + } +} + +func (u *promUnifi) exportRADIUSProfile(r report, rp *unifi.RADIUSProfile) { + if rp == nil { + return + } + + labels := []string{rp.SiteName, rp.Name, rp.Metadata.Origin} + + r.send([]*metric{ + {u.RADIUSProfile.Presence, gauge, 1.0, labels}, + }) +} + +// trafficMatchingList holds Prometheus descriptors for traffic matching list metrics. +type trafficMatchingList struct { + Presence *prometheus.Desc +} + +func descTrafficMatchingList(ns string) *trafficMatchingList { + labels := []string{"site_name", "name", "list_type"} + + return &trafficMatchingList{ + Presence: prometheus.NewDesc(ns+"traffic_matching_list_present", + "Traffic matching list configured (always 1 when present)", + labels, nil), + } +} + +func (u *promUnifi) exportTrafficMatchingList(r report, tml *unifi.TrafficMatchingList) { + if tml == nil { + return + } + + labels := []string{tml.SiteName, tml.Name, tml.Type} + + r.send([]*metric{ + {u.TrafficMatchingList.Presence, gauge, 1.0, labels}, + }) +} + +// hotspotVoucher holds Prometheus descriptors for hotspot voucher metrics. +type hotspotVoucher struct { + AuthorizedGuestCount *prometheus.Desc + AuthorizedGuestLimit *prometheus.Desc + DataUsageLimitMBytes *prometheus.Desc + TimeLimitMinutes *prometheus.Desc +} + +func descHotspotVoucher(ns string) *hotspotVoucher { + labels := []string{"site_name", "name", "code"} + + return &hotspotVoucher{ + AuthorizedGuestCount: prometheus.NewDesc(ns+"hotspot_voucher_authorized_guest_count", + "Number of guests currently authorized with this voucher", + labels, nil), + AuthorizedGuestLimit: prometheus.NewDesc(ns+"hotspot_voucher_authorized_guest_limit", + "Maximum number of guests allowed with this voucher (0=unlimited)", + labels, nil), + DataUsageLimitMBytes: prometheus.NewDesc(ns+"hotspot_voucher_data_usage_limit_mbytes", + "Data usage cap for the voucher in MBytes (0=no limit)", + labels, nil), + TimeLimitMinutes: prometheus.NewDesc(ns+"hotspot_voucher_time_limit_minutes", + "Time limit for the voucher in minutes (0=no limit)", + labels, nil), + } +} + +func (u *promUnifi) exportHotspotVoucher(r report, hv *unifi.HotspotVoucher) { + if hv == nil { + return + } + + labels := []string{hv.SiteName, hv.Name, hv.Code} + + r.send([]*metric{ + {u.HotspotVoucher.AuthorizedGuestCount, gauge, hv.AuthorizedGuestCount, labels}, + {u.HotspotVoucher.AuthorizedGuestLimit, gauge, hv.AuthorizedGuestLimit, labels}, + {u.HotspotVoucher.DataUsageLimitMBytes, gauge, hv.DataUsageLimitMBytes, labels}, + {u.HotspotVoucher.TimeLimitMinutes, gauge, hv.TimeLimitMinutes, labels}, + }) +} diff --git a/pkg/promunifi/port_forward.go b/pkg/promunifi/port_forward.go new file mode 100644 index 00000000..ac010258 --- /dev/null +++ b/pkg/promunifi/port_forward.go @@ -0,0 +1,79 @@ +package promunifi + +import ( + "github.com/prometheus/client_golang/prometheus" + "github.com/unpoller/unifi/v5" +) + +type portForward struct { + Enabled *prometheus.Desc + Log *prometheus.Desc +} + +func descPortForward(ns string) *portForward { + labels := []string{"site_name", "name", "proto", "fwd_ip", "fwd_port", "dst_port"} + + return &portForward{ + Enabled: prometheus.NewDesc(ns+"port_forward_enabled", + "Port forward rule enabled (1=enabled, 0=disabled)", + labels, nil), + Log: prometheus.NewDesc(ns+"port_forward_log", + "Port forward rule logging enabled (1=on, 0=off)", + labels, nil), + } +} + +func (u *promUnifi) exportPortForward(r report, pf *unifi.PortForward) { + if pf == nil { + return + } + + labels := []string{pf.SiteName, pf.Name, pf.Proto, pf.FwdIP, pf.FwdPort, pf.DstPort} + + r.send([]*metric{ + {u.PortForward.Enabled, gauge, pf.Enabled.Val, labels}, + {u.PortForward.Log, gauge, pf.Log.Val, labels}, + }) +} + +// sslCertificate holds Prometheus descriptors for SSL certificate metrics. +type sslCertificate struct { + IsActive *prometheus.Desc + IsValid *prometheus.Desc + ValidFrom *prometheus.Desc + ValidTo *prometheus.Desc +} + +func descSSLCertificate(ns string) *sslCertificate { + labels := []string{"site_name", "cert_type", "subject", "issuer", "status"} + + return &sslCertificate{ + IsActive: prometheus.NewDesc(ns+"ssl_cert_active", + "SSL certificate is the active certificate (1=active, 0=inactive)", + labels, nil), + IsValid: prometheus.NewDesc(ns+"ssl_cert_valid", + "SSL certificate passes validity checks (1=valid, 0=invalid)", + labels, nil), + ValidFrom: prometheus.NewDesc(ns+"ssl_cert_valid_from_seconds", + "SSL certificate validity start time (Unix epoch)", + labels, nil), + ValidTo: prometheus.NewDesc(ns+"ssl_cert_valid_to_seconds", + "SSL certificate expiry time (Unix epoch)", + labels, nil), + } +} + +func (u *promUnifi) exportSSLCertificate(r report, cert *unifi.SSLCertificate) { + if cert == nil || cert.ID == "" { + return + } + + labels := []string{cert.SiteName, cert.CertType, cert.Subject, cert.Issuer, cert.Status} + + r.send([]*metric{ + {u.SSLCertificate.IsActive, gauge, cert.IsActive.Val, labels}, + {u.SSLCertificate.IsValid, gauge, cert.IsValid.Val, labels}, + {u.SSLCertificate.ValidFrom, gauge, cert.ValidFrom, labels}, + {u.SSLCertificate.ValidTo, gauge, cert.ValidTo, labels}, + }) +} diff --git a/pkg/promunifi/ups.go b/pkg/promunifi/ups.go new file mode 100644 index 00000000..93cbf9cc --- /dev/null +++ b/pkg/promunifi/ups.go @@ -0,0 +1,35 @@ +package promunifi + +import ( + "github.com/prometheus/client_golang/prometheus" + "github.com/unpoller/unifi/v5" +) + +// upsDevice holds Prometheus descriptors for UPS device selector metrics. +// UPSDeviceSelector has no numeric fields — we emit a presence gauge so the +// device appears in Prometheus at all. +type upsDevice struct { + Presence *prometheus.Desc +} + +func descUPSDevice(ns string) *upsDevice { + labels := []string{"site_name", "mac", "label"} + + return &upsDevice{ + Presence: prometheus.NewDesc(ns+"ups_device_present", + "UPS device detected on site (always 1 when present)", + labels, nil), + } +} + +func (u *promUnifi) exportUPSDevice(r report, d *unifi.UPSDeviceSelector) { + if d == nil { + return + } + + labels := []string{d.SiteName, d.MAC, d.Label} + + r.send([]*metric{ + {u.UPSDevice.Presence, gauge, 1.0, labels}, + }) +} diff --git a/pkg/promunifi/wan_status.go b/pkg/promunifi/wan_status.go new file mode 100644 index 00000000..120a3323 --- /dev/null +++ b/pkg/promunifi/wan_status.go @@ -0,0 +1,42 @@ +package promunifi + +import ( + "github.com/prometheus/client_golang/prometheus" + "github.com/unpoller/unifi/v5" +) + +type wanStatus struct { + InterfaceState *prometheus.Desc +} + +func descWANStatus(ns string) *wanStatus { + labels := []string{"site_name", "wan_interface", "wan_networkgroup"} + + return &wanStatus{ + InterfaceState: prometheus.NewDesc(ns+"wan_interface_state", + "WAN interface state: 1=ACTIVE, 0=other", + labels, nil), + } +} + +func wanStateValue(state string) float64 { + if state == "ACTIVE" { + return 1 + } + + return 0 +} + +func (u *promUnifi) exportWANStatus(r report, ws *unifi.WANStatus) { + if ws == nil { + return + } + + for _, iface := range ws.WANInterfaces { + labels := []string{ws.SiteName, iface.Name, iface.WANNetworkgroup} + + r.send([]*metric{ + {u.WANStatus.InterfaceState, gauge, wanStateValue(iface.State), labels}, + }) + } +}