feat: add Site Magic site-to-site VPN metrics (closes #926) (#983)

* feat: add Site Magic site-to-site VPN metrics (closes #926)

Bump github.com/unpoller/unifi/v5 to v5.25.0 which adds:
- GetMagicSiteToSiteVPN / GetMagicSiteToSiteVPNSite API methods
- MagicSiteToSiteVPN types with mesh, connection, device, and status structs
- Missing VPN health fields on Site.Health (SiteToSiteNumActive/Inactive,
  SiteToSiteRxBytes/TxBytes/RxPackets/TxPackets)

Implement VPN metrics collection across all output plugins:
- Collect Site Magic VPN mesh data per-site in inputunifi pollController
- Propagate VPNMeshes through poller.Metrics / AppendMetrics
- Apply DefaultSiteNameOverride for VPN meshes in augmentMetrics /
  applySiteNameOverride
- influxunifi: vpn_mesh, vpn_mesh_connection, vpn_mesh_status tables
- promunifi: vpn_mesh_*, vpn_tunnel_*, vpn_mesh_status_* gauges
- datadogunifi: unifi.vpn_mesh.*, unifi.vpn_tunnel.*, unifi.vpn_mesh_status.*

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>

* feat(otelunifi): add Site Magic VPN metrics to OpenTelemetry output

Adds exportVPNMeshes to the otel output plugin, emitting the same
unifi_vpn_mesh_*, unifi_vpn_tunnel_*, and unifi_vpn_mesh_status_*
gauges as the other output plugins.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Cody Lee
2026-03-23 21:08:09 -05:00
committed by GitHub
parent a81a6e6e16
commit 18c6e66a8e
14 changed files with 395 additions and 27 deletions

2
go.mod
View File

@@ -12,7 +12,7 @@ require (
github.com/prometheus/common v0.67.5 github.com/prometheus/common v0.67.5
github.com/spf13/pflag v1.0.10 github.com/spf13/pflag v1.0.10
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.11.1
github.com/unpoller/unifi/v5 v5.24.0 github.com/unpoller/unifi/v5 v5.25.0
go.opentelemetry.io/otel v1.42.0 go.opentelemetry.io/otel v1.42.0
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.42.0 go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.42.0
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.42.0 go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.42.0

8
go.sum
View File

@@ -87,12 +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.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 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/unpoller/unifi/v5 v5.22.0 h1:ftLZcdXCtSfmd1a9nytajVCPuUoDxB1JyOPqoxPt8cI= github.com/unpoller/unifi/v5 v5.25.0 h1:smX4nXSnCoZ7JenZdD9fktpja/yVUHUlXojYqQ7Be+Q=
github.com/unpoller/unifi/v5 v5.22.0/go.mod h1:0R6t/SKaS8eoOrTkSYwzVb292KG5eQfbKEuevuES0So= github.com/unpoller/unifi/v5 v5.25.0/go.mod h1:0R6t/SKaS8eoOrTkSYwzVb292KG5eQfbKEuevuES0So=
github.com/unpoller/unifi/v5 v5.23.0 h1:aJ7qM/UNtNNa9+iCfd6Quom8F7riFPQOe5g9rMsX8os=
github.com/unpoller/unifi/v5 v5.23.0/go.mod h1:0R6t/SKaS8eoOrTkSYwzVb292KG5eQfbKEuevuES0So=
github.com/unpoller/unifi/v5 v5.24.0 h1:+NBem1gff4n3XCbDm6FijxdwO9BYoJDAz0TCeNojxxI=
github.com/unpoller/unifi/v5 v5.24.0/go.mod h1:0R6t/SKaS8eoOrTkSYwzVb292KG5eQfbKEuevuES0So=
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= 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 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=

View File

@@ -333,6 +333,10 @@ func (u *DatadogUnifi) loopPoints(r report) {
for _, a := range m.PortAnomalies { for _, a := range m.PortAnomalies {
u.switchExport(r, a) u.switchExport(r, a)
} }
for _, v := range m.VPNMeshes {
u.switchExport(r, v)
}
} }
func (u *DatadogUnifi) switchExport(r report, v any) { //nolint:cyclop func (u *DatadogUnifi) switchExport(r report, v any) { //nolint:cyclop
@@ -379,6 +383,8 @@ func (u *DatadogUnifi) switchExport(r report, v any) { //nolint:cyclop
u.batchTopology(r, v) u.batchTopology(r, v)
case *unifi.PortAnomaly: case *unifi.PortAnomaly:
u.batchPortAnomaly(r, v) u.batchPortAnomaly(r, v)
case *unifi.MagicSiteToSiteVPN:
u.batchMagicSiteToSiteVPN(r, v)
default: default:
if u.Collector != nil && u.Collector.Poller().LogUnknownTypes { if u.Collector != nil && u.Collector.Poller().LogUnknownTypes {
u.LogDebugf("unknown export type: %T", v) u.LogDebugf("unknown export type: %T", v)

67
pkg/datadogunifi/vpn.go Normal file
View File

@@ -0,0 +1,67 @@
package datadogunifi
import (
"github.com/unpoller/unifi/v5"
)
// batchMagicSiteToSiteVPN generates Site Magic VPN datapoints for Datadog.
func (u *DatadogUnifi) batchMagicSiteToSiteVPN(r report, m *unifi.MagicSiteToSiteVPN) {
if m == nil {
return
}
meshMetric := metricNamespace("vpn_mesh")
meshTags := []string{
tag("site_name", m.SiteName),
tag("source", m.SourceName),
tag("mesh_name", m.Name),
}
paused := 0.0
if m.Pause.Val {
paused = 1.0
}
_ = r.reportGauge(meshMetric("paused"), paused, meshTags)
_ = r.reportGauge(meshMetric("connections_total"), float64(len(m.Connections)), meshTags)
_ = r.reportGauge(meshMetric("devices_total"), float64(len(m.Devices)), meshTags)
tunnelMetric := metricNamespace("vpn_tunnel")
statusMetric := metricNamespace("vpn_mesh_status")
for i := range m.Status {
s := &m.Status[i]
statusTags := []string{
tag("site_name", m.SiteName),
tag("source", m.SourceName),
tag("mesh_name", m.Name),
tag("status_site", s.SiteID),
}
_ = r.reportGauge(statusMetric("errors"), float64(len(s.Errors)), statusTags)
_ = r.reportGauge(statusMetric("warnings"), float64(len(s.Warnings)), statusTags)
for j := range s.Connections {
conn := &s.Connections[j]
connected := 0.0
if conn.Connected.Val {
connected = 1.0
}
connTags := []string{
tag("site_name", m.SiteName),
tag("source", m.SourceName),
tag("mesh_name", m.Name),
tag("connection_id", conn.ConnectionID),
tag("status_site", s.SiteID),
}
_ = r.reportGauge(tunnelMetric("connected"), connected, connTags)
_ = r.reportGauge(tunnelMetric("association_time"), conn.AssociationTime.Val, connTags)
_ = r.reportGauge(tunnelMetric("errors"), float64(len(conn.Errors)), connTags)
}
}
}

View File

@@ -446,6 +446,10 @@ func (u *InfluxUnifi) loopPoints(r report) {
for _, a := range m.PortAnomalies { for _, a := range m.PortAnomalies {
u.switchExport(r, a) u.switchExport(r, a)
} }
for _, v := range m.VPNMeshes {
u.switchExport(r, v)
}
} }
func (u *InfluxUnifi) switchExport(r report, v any) { //nolint:cyclop func (u *InfluxUnifi) switchExport(r report, v any) { //nolint:cyclop
@@ -492,6 +496,8 @@ func (u *InfluxUnifi) switchExport(r report, v any) { //nolint:cyclop
u.batchTopology(r, v) u.batchTopology(r, v)
case *unifi.PortAnomaly: case *unifi.PortAnomaly:
u.batchPortAnomaly(r, v) u.batchPortAnomaly(r, v)
case *unifi.MagicSiteToSiteVPN:
u.batchMagicSiteToSiteVPN(r, v)
default: default:
if u.Collector.Poller().LogUnknownTypes { if u.Collector.Poller().LogUnknownTypes {
u.LogDebugf("unknown export type: %T", v) u.LogDebugf("unknown export type: %T", v)

75
pkg/influxunifi/vpn.go Normal file
View File

@@ -0,0 +1,75 @@
package influxunifi
import (
"github.com/unpoller/unifi/v5"
)
// batchMagicSiteToSiteVPN generates Site Magic VPN datapoints for InfluxDB.
func (u *InfluxUnifi) batchMagicSiteToSiteVPN(r report, m *unifi.MagicSiteToSiteVPN) {
if m == nil {
return
}
meshTags := map[string]string{
"site_name": m.SiteName,
"source": m.SourceName,
"mesh_id": m.ID,
"mesh_name": m.Name,
}
paused := 0.0
if m.Pause.Val {
paused = 1.0
}
meshFields := map[string]any{
"paused": paused,
"connections_total": len(m.Connections),
"devices_total": len(m.Devices),
}
r.send(&metric{Table: "vpn_mesh", Tags: meshTags, Fields: meshFields})
for i := range m.Status {
s := &m.Status[i]
for j := range s.Connections {
conn := &s.Connections[j]
connected := 0.0
if conn.Connected.Val {
connected = 1.0
}
connTags := map[string]string{
"site_name": m.SiteName,
"source": m.SourceName,
"mesh_name": m.Name,
"connection_id": conn.ConnectionID,
"status_site": s.SiteID,
}
connFields := map[string]any{
"connected": connected,
"association_time": conn.AssociationTime.Val,
"errors": len(conn.Errors),
}
r.send(&metric{Table: "vpn_mesh_connection", Tags: connTags, Fields: connFields})
}
statusTags := map[string]string{
"site_name": m.SiteName,
"source": m.SourceName,
"mesh_name": m.Name,
"status_site": s.SiteID,
}
statusFields := map[string]any{
"errors": len(s.Errors),
"warnings": len(s.Warnings),
}
r.send(&metric{Table: "vpn_mesh_status", Tags: statusTags, Fields: statusFields})
}
}

View File

@@ -103,6 +103,7 @@ func (u *InputUnifi) pollController(c *Controller) (*poller.Metrics, error) {
if c == nil { if c == nil {
return nil, fmt.Errorf("controller is nil") return nil, fmt.Errorf("controller is nil")
} }
if c.Unifi == nil { if c.Unifi == nil {
return nil, fmt.Errorf("controller client is nil (e.g. after 429 or auth failure): %s", c.URL) return nil, fmt.Errorf("controller client is nil (e.g. after 429 or auth failure): %s", c.URL)
} }
@@ -246,6 +247,14 @@ func (u *InputUnifi) pollController(c *Controller) (*poller.Metrics, error) {
u.LogDebugf("Found %d PortAnomalies entries", len(m.PortAnomalies)) u.LogDebugf("Found %d PortAnomalies entries", len(m.PortAnomalies))
} }
// Get Site Magic site-to-site VPN mesh data
if m.VPNMeshes, err = c.Unifi.GetMagicSiteToSiteVPN(sites); err != nil {
// Don't fail collection if VPN data fails - older controllers may not have this endpoint
u.LogDebugf("unifi.GetMagicSiteToSiteVPN(%s): %v (continuing)", c.URL, err)
} else {
u.LogDebugf("Found %d VPNMeshes entries", len(m.VPNMeshes))
}
// Update web UI only on success; call explicitly so we never run with nil c/c.Unifi (no defer). // 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. // Recover so a panic in updateWeb (e.g. old image, race) never kills the poller.
if c != nil && c.Unifi != nil { if c != nil && c.Unifi != nil {
@@ -255,9 +264,11 @@ func (u *InputUnifi) pollController(c *Controller) (*poller.Metrics, error) {
u.LogErrorf("updateWeb panic recovered (upgrade image if this persists): %v", r) u.LogErrorf("updateWeb panic recovered (upgrade image if this persists): %v", r)
} }
}() }()
updateWeb(c, m) updateWeb(c, m)
}() }()
} }
return u.augmentMetrics(c, m), nil return u.augmentMetrics(c, m), nil
} }
@@ -410,10 +421,12 @@ func (u *InputUnifi) augmentMetrics(c *Controller, metrics *Metrics) *poller.Met
if isDefaultSiteName(site.Name) { if isDefaultSiteName(site.Name) {
site.Name = c.DefaultSiteNameOverride site.Name = c.DefaultSiteNameOverride
} }
if isDefaultSiteName(site.SiteName) { if isDefaultSiteName(site.SiteName) {
site.SiteName = c.DefaultSiteNameOverride site.SiteName = c.DefaultSiteNameOverride
} }
} }
m.Sites = append(m.Sites, site) m.Sites = append(m.Sites, site)
} }
@@ -422,6 +435,7 @@ func (u *InputUnifi) augmentMetrics(c *Controller, metrics *Metrics) *poller.Met
if c.DefaultSiteNameOverride != "" && isDefaultSiteName(site.SiteName) { if c.DefaultSiteNameOverride != "" && isDefaultSiteName(site.SiteName) {
site.SiteName = c.DefaultSiteNameOverride site.SiteName = c.DefaultSiteNameOverride
} }
m.SitesDPI = append(m.SitesDPI, site) m.SitesDPI = append(m.SitesDPI, site)
} }
} }
@@ -431,6 +445,7 @@ func (u *InputUnifi) augmentMetrics(c *Controller, metrics *Metrics) *poller.Met
if c.DefaultSiteNameOverride != "" && isDefaultSiteName(speedTest.SiteName) { if c.DefaultSiteNameOverride != "" && isDefaultSiteName(speedTest.SiteName) {
speedTest.SiteName = c.DefaultSiteNameOverride speedTest.SiteName = c.DefaultSiteNameOverride
} }
m.SpeedTests = append(m.SpeedTests, speedTest) m.SpeedTests = append(m.SpeedTests, speedTest)
} }
@@ -440,6 +455,7 @@ func (u *InputUnifi) augmentMetrics(c *Controller, metrics *Metrics) *poller.Met
if c.DefaultSiteNameOverride != "" && isDefaultSiteName(traffic.TrafficSite.SiteName) { if c.DefaultSiteNameOverride != "" && isDefaultSiteName(traffic.TrafficSite.SiteName) {
traffic.TrafficSite.SiteName = c.DefaultSiteNameOverride traffic.TrafficSite.SiteName = c.DefaultSiteNameOverride
} }
m.CountryTraffic = append(m.CountryTraffic, traffic) m.CountryTraffic = append(m.CountryTraffic, traffic)
} }
@@ -448,6 +464,7 @@ func (u *InputUnifi) augmentMetrics(c *Controller, metrics *Metrics) *poller.Met
if c.DefaultSiteNameOverride != "" && isDefaultSiteName(lease.SiteName) { if c.DefaultSiteNameOverride != "" && isDefaultSiteName(lease.SiteName) {
lease.SiteName = c.DefaultSiteNameOverride lease.SiteName = c.DefaultSiteNameOverride
} }
m.DHCPLeases = append(m.DHCPLeases, lease) m.DHCPLeases = append(m.DHCPLeases, lease)
} }
@@ -487,6 +504,14 @@ func (u *InputUnifi) augmentMetrics(c *Controller, metrics *Metrics) *poller.Met
m.PortAnomalies = append(m.PortAnomalies, anomaly) m.PortAnomalies = append(m.PortAnomalies, anomaly)
} }
for _, mesh := range metrics.VPNMeshes {
if c.DefaultSiteNameOverride != "" && isDefaultSiteName(mesh.SiteName) {
mesh.SiteName = c.DefaultSiteNameOverride
}
m.VPNMeshes = append(m.VPNMeshes, mesh)
}
// Apply default_site_name_override to all metrics if configured. // 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 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 // This allows us to use the console name for Cloud Gateways while keeping
@@ -504,6 +529,7 @@ func isDefaultSiteName(siteName string) bool {
if siteName == "" { if siteName == "" {
return false return false
} }
lower := strings.ToLower(siteName) lower := strings.ToLower(siteName)
// Check for exact match or if it contains "default" as a word // Check for exact match or if it contains "default" as a word
return lower == "default" || strings.Contains(lower, "default") return lower == "default" || strings.Contains(lower, "default")
@@ -572,6 +598,7 @@ func applySiteNameOverride(m *poller.Metrics, overrideName string) {
if isDefaultSiteName(site.Name) { if isDefaultSiteName(site.Name) {
site.Name = overrideName site.Name = overrideName
} }
if isDefaultSiteName(site.SiteName) { if isDefaultSiteName(site.SiteName) {
site.SiteName = overrideName site.SiteName = overrideName
} }
@@ -637,6 +664,14 @@ func applySiteNameOverride(m *poller.Metrics, overrideName string) {
} }
} }
} }
for i := range m.VPNMeshes {
if mesh, ok := m.VPNMeshes[i].(*unifi.MagicSiteToSiteVPN); ok {
if isDefaultSiteName(mesh.SiteName) {
mesh.SiteName = overrideName
}
}
}
} }
// this is a helper function for augmentMetrics. // this is a helper function for augmentMetrics.

View File

@@ -60,7 +60,7 @@ type Controller struct {
Sites []string `json:"sites" toml:"sites" xml:"site" yaml:"sites"` Sites []string `json:"sites" toml:"sites" xml:"site" yaml:"sites"`
DefaultSiteNameOverride string `json:"default_site_name_override" toml:"default_site_name_override" xml:"default_site_name_override" yaml:"default_site_name_override"` DefaultSiteNameOverride string `json:"default_site_name_override" toml:"default_site_name_override" xml:"default_site_name_override" yaml:"default_site_name_override"`
Remote bool `json:"remote" toml:"remote" xml:"remote,attr" yaml:"remote"` Remote bool `json:"remote" toml:"remote" xml:"remote,attr" yaml:"remote"`
ConsoleID string `json:"console_id,omitempty" toml:"console_id,omitempty" xml:"console_id,omitempty" yaml:"console_id,omitempty"` ConsoleID string `json:"console_id,omitempty" toml:"console_id,omitempty" xml:"console_id,omitempty" yaml:"console_id,omitempty"`
Unifi *unifi.Unifi `json:"-" toml:"-" xml:"-" yaml:"-"` Unifi *unifi.Unifi `json:"-" toml:"-" xml:"-" yaml:"-"`
ID string `json:"id,omitempty"` // this is an output, not an input. ID string `json:"id,omitempty"` // this is an output, not an input.
} }
@@ -68,31 +68,32 @@ type Controller struct {
// Config contains our configuration data. // Config contains our configuration data.
type Config struct { type Config struct {
sync.RWMutex // locks the Unifi struct member when re-authing to unifi. sync.RWMutex // locks the Unifi struct member when re-authing to unifi.
Default Controller `json:"defaults" toml:"defaults" xml:"default" yaml:"defaults"` Default Controller `json:"defaults" toml:"defaults" xml:"default" yaml:"defaults"`
Disable bool `json:"disable" toml:"disable" xml:"disable,attr" yaml:"disable"` Disable bool `json:"disable" toml:"disable" xml:"disable,attr" yaml:"disable"`
Dynamic bool `json:"dynamic" toml:"dynamic" xml:"dynamic,attr" yaml:"dynamic"` Dynamic bool `json:"dynamic" toml:"dynamic" xml:"dynamic,attr" yaml:"dynamic"`
Remote bool `json:"remote" toml:"remote" xml:"remote,attr" yaml:"remote"` Remote bool `json:"remote" toml:"remote" xml:"remote,attr" yaml:"remote"`
RemoteAPIKey string `json:"remote_api_key" toml:"remote_api_key" xml:"remote_api_key" yaml:"remote_api_key"` RemoteAPIKey string `json:"remote_api_key" toml:"remote_api_key" xml:"remote_api_key" yaml:"remote_api_key"`
Controllers []*Controller `json:"controllers" toml:"controller" xml:"controller" yaml:"controllers"` Controllers []*Controller `json:"controllers" toml:"controller" xml:"controller" yaml:"controllers"`
} }
// Metrics is simply a useful container for everything. // Metrics is simply a useful container for everything.
type Metrics struct { type Metrics struct {
TS time.Time TS time.Time
Sites []*unifi.Site Sites []*unifi.Site
Clients []*unifi.Client Clients []*unifi.Client
SitesDPI []*unifi.DPITable SitesDPI []*unifi.DPITable
ClientsDPI []*unifi.DPITable ClientsDPI []*unifi.DPITable
CountryTraffic []*unifi.UsageByCountry CountryTraffic []*unifi.UsageByCountry
RogueAPs []*unifi.RogueAP RogueAPs []*unifi.RogueAP
SpeedTests []*unifi.SpeedTestResult SpeedTests []*unifi.SpeedTestResult
Devices *unifi.Devices Devices *unifi.Devices
DHCPLeases []*unifi.DHCPLease DHCPLeases []*unifi.DHCPLease
WANConfigs []*unifi.WANEnrichedConfiguration WANConfigs []*unifi.WANEnrichedConfiguration
Sysinfos []*unifi.Sysinfo Sysinfos []*unifi.Sysinfo
FirewallPolicies []*unifi.FirewallPolicy FirewallPolicies []*unifi.FirewallPolicy
Topologies []*unifi.Topology Topologies []*unifi.Topology
PortAnomalies []*unifi.PortAnomaly PortAnomalies []*unifi.PortAnomaly
VPNMeshes []*unifi.MagicSiteToSiteVPN
} }
func init() { // nolint: gochecknoinits func init() { // nolint: gochecknoinits
@@ -158,17 +159,20 @@ func (u *InputUnifi) getUnifi(c *Controller) error {
} }
var lastErr error var lastErr error
backoff := 30 * time.Second backoff := 30 * time.Second
for attempt := 0; attempt < maxAuthRetries; attempt++ { for attempt := 0; attempt < maxAuthRetries; attempt++ {
c.Unifi, lastErr = unifi.NewUnifi(cfg) c.Unifi, lastErr = unifi.NewUnifi(cfg)
if lastErr == nil { if lastErr == nil {
u.LogDebugf("Authenticated with controller successfully, %s", c.URL) u.LogDebugf("Authenticated with controller successfully, %s", c.URL)
return nil return nil
} }
if !errors.Is(lastErr, unifi.ErrTooManyRequests) { if !errors.Is(lastErr, unifi.ErrTooManyRequests) {
c.Unifi = nil c.Unifi = nil
return fmt.Errorf("unifi controller: %w", lastErr) return fmt.Errorf("unifi controller: %w", lastErr)
} }
@@ -181,6 +185,7 @@ func (u *InputUnifi) getUnifi(c *Controller) error {
u.Logf("Controller %s returned 429 Too Many Requests; waiting %v before retry (%d/%d)", u.Logf("Controller %s returned 429 Too Many Requests; waiting %v before retry (%d/%d)",
c.URL, backoff, attempt+1, maxAuthRetries) c.URL, backoff, attempt+1, maxAuthRetries)
time.Sleep(backoff) time.Sleep(backoff)
if backoff < 5*time.Minute { if backoff < 5*time.Minute {
backoff = backoff * 2 backoff = backoff * 2
} }
@@ -188,6 +193,7 @@ func (u *InputUnifi) getUnifi(c *Controller) error {
} }
c.Unifi = nil c.Unifi = nil
return fmt.Errorf("unifi controller: %w (gave up after %d retries)", lastErr, maxAuthRetries) return fmt.Errorf("unifi controller: %w (gave up after %d retries)", lastErr, maxAuthRetries)
} }
@@ -451,6 +457,7 @@ func (u *InputUnifi) setControllerDefaults(c *Controller) *Controller { //nolint
if c.APIKey == "" { if c.APIKey == "" {
c.APIKey = u.Default.APIKey c.APIKey = u.Default.APIKey
} }
c.User = "" c.User = ""
c.Pass = "" c.Pass = ""
} else { } else {

View File

@@ -50,6 +50,7 @@ func (u *OtelOutput) reportMetrics(m *poller.Metrics, _ *poller.Events) (*Report
u.exportFirewallPolicies(ctx, meter, m, r) u.exportFirewallPolicies(ctx, meter, m, r)
u.exportTopology(ctx, meter, m, r) u.exportTopology(ctx, meter, m, r)
u.exportPortAnomalies(ctx, meter, m, r) u.exportPortAnomalies(ctx, meter, m, r)
u.exportVPNMeshes(ctx, meter, m, r)
r.Elapsed = time.Since(start) r.Elapsed = time.Since(start)
@@ -215,6 +216,7 @@ func (u *OtelOutput) recordGauge(
g, err := meter.Float64ObservableGauge(name, metric.WithDescription(description)) g, err := meter.Float64ObservableGauge(name, metric.WithDescription(description))
if err != nil { if err != nil {
r.Errors++ r.Errors++
u.LogDebugf("otel: creating gauge %s: %v", name, err) u.LogDebugf("otel: creating gauge %s: %v", name, err)
return return
@@ -227,6 +229,7 @@ func (u *OtelOutput) recordGauge(
}, g) }, g)
if err != nil { if err != nil {
r.Errors++ r.Errors++
u.LogDebugf("otel: registering callback for %s: %v", name, err) u.LogDebugf("otel: registering callback for %s: %v", name, err)
return return

79
pkg/otelunifi/vpn.go Normal file
View File

@@ -0,0 +1,79 @@
package otelunifi
import (
"context"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/metric"
"github.com/unpoller/unifi/v5"
"github.com/unpoller/unpoller/pkg/poller"
)
// exportVPNMeshes emits Site Magic site-to-site VPN mesh metrics.
func (u *OtelOutput) exportVPNMeshes(ctx context.Context, meter metric.Meter, m *poller.Metrics, r *Report) {
for _, item := range m.VPNMeshes {
mesh, ok := item.(*unifi.MagicSiteToSiteVPN)
if !ok {
continue
}
meshAttrs := attribute.NewSet(
attribute.String("site_name", mesh.SiteName),
attribute.String("source", mesh.SourceName),
attribute.String("mesh_name", mesh.Name),
)
paused := 0.0
if mesh.Pause.Val {
paused = 1.0
}
u.recordGauge(ctx, meter, r, "unifi_vpn_mesh_paused",
"Site Magic VPN mesh paused (1/0)", paused, meshAttrs)
u.recordGauge(ctx, meter, r, "unifi_vpn_mesh_connections_total",
"Total connections in Site Magic VPN mesh", float64(len(mesh.Connections)), meshAttrs)
u.recordGauge(ctx, meter, r, "unifi_vpn_mesh_devices_total",
"Total devices in Site Magic VPN mesh", float64(len(mesh.Devices)), meshAttrs)
for i := range mesh.Status {
s := &mesh.Status[i]
statusAttrs := attribute.NewSet(
attribute.String("site_name", mesh.SiteName),
attribute.String("source", mesh.SourceName),
attribute.String("mesh_name", mesh.Name),
attribute.String("status_site", s.SiteID),
)
u.recordGauge(ctx, meter, r, "unifi_vpn_mesh_status_errors",
"Number of errors for a site in a Site Magic VPN mesh", float64(len(s.Errors)), statusAttrs)
u.recordGauge(ctx, meter, r, "unifi_vpn_mesh_status_warnings",
"Number of warnings for a site in a Site Magic VPN mesh", float64(len(s.Warnings)), statusAttrs)
for j := range s.Connections {
conn := &s.Connections[j]
connected := 0.0
if conn.Connected.Val {
connected = 1.0
}
connAttrs := attribute.NewSet(
attribute.String("site_name", mesh.SiteName),
attribute.String("source", mesh.SourceName),
attribute.String("mesh_name", mesh.Name),
attribute.String("connection_id", conn.ConnectionID),
attribute.String("status_site", s.SiteID),
)
u.recordGauge(ctx, meter, r, "unifi_vpn_tunnel_connected",
"Site Magic VPN tunnel connection status (1=connected, 0=disconnected)", connected, connAttrs)
u.recordGauge(ctx, meter, r, "unifi_vpn_tunnel_association_time",
"Site Magic VPN tunnel association Unix timestamp", conn.AssociationTime.Val, connAttrs)
u.recordGauge(ctx, meter, r, "unifi_vpn_tunnel_errors",
"Number of errors on a Site Magic VPN tunnel connection", float64(len(conn.Errors)), connAttrs)
}
}
}
}

View File

@@ -105,6 +105,7 @@ type Metrics struct {
FirewallPolicies []any FirewallPolicies []any
Topologies []any Topologies []any
PortAnomalies []any PortAnomalies []any
VPNMeshes []any
ControllerStatuses []ControllerStatus ControllerStatuses []ControllerStatus
} }
@@ -120,10 +121,10 @@ type Config struct {
// Poller is the global config values. // Poller is the global config values.
type Poller struct { type Poller struct {
Plugins []string `json:"plugins" toml:"plugins" xml:"plugin" yaml:"plugins"` Plugins []string `json:"plugins" toml:"plugins" xml:"plugin" yaml:"plugins"`
Debug bool `json:"debug" toml:"debug" xml:"debug,attr" yaml:"debug"` Debug bool `json:"debug" toml:"debug" xml:"debug,attr" yaml:"debug"`
Quiet bool `json:"quiet" toml:"quiet" xml:"quiet,attr" yaml:"quiet"` Quiet bool `json:"quiet" toml:"quiet" xml:"quiet,attr" yaml:"quiet"`
LogUnknownTypes bool `json:"log_unknown_types" toml:"log_unknown_types" xml:"log_unknown_types" yaml:"log_unknown_types"` LogUnknownTypes bool `json:"log_unknown_types" toml:"log_unknown_types" xml:"log_unknown_types" yaml:"log_unknown_types"`
} }
// LoadPlugins reads-in dynamic shared libraries. // LoadPlugins reads-in dynamic shared libraries.

View File

@@ -280,6 +280,7 @@ func AppendMetrics(existing *Metrics, m *Metrics) *Metrics {
existing.FirewallPolicies = append(existing.FirewallPolicies, m.FirewallPolicies...) existing.FirewallPolicies = append(existing.FirewallPolicies, m.FirewallPolicies...)
existing.Topologies = append(existing.Topologies, m.Topologies...) existing.Topologies = append(existing.Topologies, m.Topologies...)
existing.PortAnomalies = append(existing.PortAnomalies, m.PortAnomalies...) existing.PortAnomalies = append(existing.PortAnomalies, m.PortAnomalies...)
existing.VPNMeshes = append(existing.VPNMeshes, m.VPNMeshes...)
existing.ControllerStatuses = append(existing.ControllerStatuses, m.ControllerStatuses...) existing.ControllerStatuses = append(existing.ControllerStatuses, m.ControllerStatuses...)
return existing return existing

View File

@@ -53,6 +53,7 @@ type promUnifi struct {
FirewallPolicy *firewallpolicy FirewallPolicy *firewallpolicy
Topology *topology Topology *topology
PortAnomaly *portanomaly PortAnomaly *portanomaly
VPNMesh *vpnmesh
// controllerUp tracks per-controller poll success (1) or failure (0). // controllerUp tracks per-controller poll success (1) or failure (0).
controllerUp *prometheus.GaugeVec controllerUp *prometheus.GaugeVec
// This interface is passed to the Collect() method. The Collect method uses // This interface is passed to the Collect() method. The Collect method uses
@@ -221,6 +222,7 @@ func (u *promUnifi) Run(c poller.Collect) error {
u.FirewallPolicy = descFirewallPolicy(u.Namespace + "_") u.FirewallPolicy = descFirewallPolicy(u.Namespace + "_")
u.Topology = descTopology(u.Namespace + "_") u.Topology = descTopology(u.Namespace + "_")
u.PortAnomaly = descPortAnomaly(u.Namespace + "_") u.PortAnomaly = descPortAnomaly(u.Namespace + "_")
u.VPNMesh = descVPNMesh(u.Namespace + "_")
u.controllerUp = prometheus.NewGaugeVec(prometheus.GaugeOpts{ u.controllerUp = prometheus.NewGaugeVec(prometheus.GaugeOpts{
Name: u.Namespace + "_controller_up", Name: u.Namespace + "_controller_up",
Help: "Whether the last poll of the UniFi controller succeeded (1) or failed (0).", Help: "Whether the last poll of the UniFi controller succeeded (1) or failed (0).",
@@ -309,7 +311,7 @@ func (t *target) Describe(ch chan<- *prometheus.Desc) {
// Describe satisfies the prometheus Collector. This returns all of the // Describe satisfies the prometheus Collector. This returns all of the
// metric descriptions that this packages produces. // metric descriptions that this packages produces.
func (u *promUnifi) Describe(ch chan<- *prometheus.Desc) { 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} { 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} {
v := reflect.Indirect(reflect.ValueOf(f)) v := reflect.Indirect(reflect.ValueOf(f))
// Loop each struct member and send it to the provided channel. // Loop each struct member and send it to the provided channel.
@@ -451,6 +453,7 @@ func (u *promUnifi) loopExports(r report) {
dhcpLeases = append(dhcpLeases, l) dhcpLeases = append(dhcpLeases, l)
} }
} }
if len(dhcpLeases) > 0 { if len(dhcpLeases) > 0 {
u.exportDHCPNetworkPool(r, dhcpLeases) u.exportDHCPNetworkPool(r, dhcpLeases)
} }
@@ -501,6 +504,12 @@ func (u *promUnifi) loopExports(r report) {
u.exportPortAnomalies(r, portAnomalies) u.exportPortAnomalies(r, portAnomalies)
for _, v := range m.VPNMeshes {
if mesh, ok := v.(*unifi.MagicSiteToSiteVPN); ok {
u.exportVPNMesh(r, mesh)
}
}
u.exportClientDPItotals(r, appTotal, catTotal) u.exportClientDPItotals(r, appTotal, catTotal)
} }

83
pkg/promunifi/vpn.go Normal file
View File

@@ -0,0 +1,83 @@
package promunifi
import (
"github.com/prometheus/client_golang/prometheus"
"github.com/unpoller/unifi/v5"
)
type vpnmesh struct {
MeshPaused *prometheus.Desc
MeshConnectionsTotal *prometheus.Desc
MeshDevicesTotal *prometheus.Desc
TunnelConnected *prometheus.Desc
TunnelAssociationTime *prometheus.Desc
TunnelErrors *prometheus.Desc
StatusErrors *prometheus.Desc
StatusWarnings *prometheus.Desc
}
func descVPNMesh(ns string) *vpnmesh {
meshLabels := []string{"site_name", "source", "mesh_name"}
connLabels := []string{"site_name", "source", "mesh_name", "connection_id", "status_site"}
statusLabels := []string{"site_name", "source", "mesh_name", "status_site"}
nd := prometheus.NewDesc
return &vpnmesh{
MeshPaused: nd(ns+"vpn_mesh_paused", "Site Magic VPN mesh paused (1/0)", meshLabels, nil),
MeshConnectionsTotal: nd(ns+"vpn_mesh_connections_total", "Total connections in Site Magic VPN mesh", meshLabels, nil),
MeshDevicesTotal: nd(ns+"vpn_mesh_devices_total", "Total devices in Site Magic VPN mesh", meshLabels, nil),
TunnelConnected: nd(ns+"vpn_tunnel_connected", "Site Magic VPN tunnel connection status (1=connected, 0=disconnected)", connLabels, nil),
TunnelAssociationTime: nd(ns+"vpn_tunnel_association_time", "Site Magic VPN tunnel association Unix timestamp", connLabels, nil),
TunnelErrors: nd(ns+"vpn_tunnel_errors", "Number of errors on a Site Magic VPN tunnel connection", connLabels, nil),
StatusErrors: nd(ns+"vpn_mesh_status_errors", "Number of errors for a site in a Site Magic VPN mesh", statusLabels, nil),
StatusWarnings: nd(ns+"vpn_mesh_status_warnings", "Number of warnings for a site in a Site Magic VPN mesh", statusLabels, nil),
}
}
func (u *promUnifi) exportVPNMesh(r report, m *unifi.MagicSiteToSiteVPN) {
if m == nil {
return
}
meshLabels := []string{m.SiteName, m.SourceName, m.Name}
paused := 0.0
if m.Pause.Val {
paused = 1.0
}
r.send([]*metric{
{u.VPNMesh.MeshPaused, gauge, paused, meshLabels},
{u.VPNMesh.MeshConnectionsTotal, gauge, float64(len(m.Connections)), meshLabels},
{u.VPNMesh.MeshDevicesTotal, gauge, float64(len(m.Devices)), meshLabels},
})
for i := range m.Status {
s := &m.Status[i]
statusLabels := []string{m.SiteName, m.SourceName, m.Name, s.SiteID}
r.send([]*metric{
{u.VPNMesh.StatusErrors, gauge, float64(len(s.Errors)), statusLabels},
{u.VPNMesh.StatusWarnings, gauge, float64(len(s.Warnings)), statusLabels},
})
for j := range s.Connections {
conn := &s.Connections[j]
connected := 0.0
if conn.Connected.Val {
connected = 1.0
}
connLabels := []string{m.SiteName, m.SourceName, m.Name, conn.ConnectionID, s.SiteID}
r.send([]*metric{
{u.VPNMesh.TunnelConnected, gauge, connected, connLabels},
{u.VPNMesh.TunnelAssociationTime, gauge, conn.AssociationTime.Val, connLabels},
{u.VPNMesh.TunnelErrors, gauge, float64(len(conn.Errors)), connLabels},
})
}
}
}