From 18c6e66a8eec30c668b023b96354300bb501c1d9 Mon Sep 17 00:00:00 2001 From: Cody Lee Date: Mon, 23 Mar 2026 21:08:09 -0500 Subject: [PATCH] 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) * 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) --------- Co-authored-by: Claude Sonnet 4.6 (1M context) --- go.mod | 2 +- go.sum | 8 +--- pkg/datadogunifi/datadog.go | 6 +++ pkg/datadogunifi/vpn.go | 67 ++++++++++++++++++++++++++++++ pkg/influxunifi/influxdb.go | 6 +++ pkg/influxunifi/vpn.go | 75 +++++++++++++++++++++++++++++++++ pkg/inputunifi/collector.go | 35 ++++++++++++++++ pkg/inputunifi/input.go | 37 ++++++++++------- pkg/otelunifi/report.go | 3 ++ pkg/otelunifi/vpn.go | 79 +++++++++++++++++++++++++++++++++++ pkg/poller/config.go | 9 ++-- pkg/poller/inputs.go | 1 + pkg/promunifi/collector.go | 11 ++++- pkg/promunifi/vpn.go | 83 +++++++++++++++++++++++++++++++++++++ 14 files changed, 395 insertions(+), 27 deletions(-) create mode 100644 pkg/datadogunifi/vpn.go create mode 100644 pkg/influxunifi/vpn.go create mode 100644 pkg/otelunifi/vpn.go create mode 100644 pkg/promunifi/vpn.go diff --git a/go.mod b/go.mod index e2346e84..d8823ba9 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.24.0 + github.com/unpoller/unifi/v5 v5.25.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/otlpmetrichttp v1.42.0 diff --git a/go.sum b/go.sum index 14d797ee..c6e43980 100644 --- a/go.sum +++ b/go.sum @@ -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.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= 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.22.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/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/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= diff --git a/pkg/datadogunifi/datadog.go b/pkg/datadogunifi/datadog.go index c019f908..72cb1d98 100644 --- a/pkg/datadogunifi/datadog.go +++ b/pkg/datadogunifi/datadog.go @@ -333,6 +333,10 @@ func (u *DatadogUnifi) loopPoints(r report) { for _, a := range m.PortAnomalies { u.switchExport(r, a) } + + for _, v := range m.VPNMeshes { + u.switchExport(r, v) + } } 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) case *unifi.PortAnomaly: u.batchPortAnomaly(r, v) + case *unifi.MagicSiteToSiteVPN: + u.batchMagicSiteToSiteVPN(r, v) default: if u.Collector != nil && u.Collector.Poller().LogUnknownTypes { u.LogDebugf("unknown export type: %T", v) diff --git a/pkg/datadogunifi/vpn.go b/pkg/datadogunifi/vpn.go new file mode 100644 index 00000000..2958f844 --- /dev/null +++ b/pkg/datadogunifi/vpn.go @@ -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) + } + } +} diff --git a/pkg/influxunifi/influxdb.go b/pkg/influxunifi/influxdb.go index b2ef1fca..6678e704 100644 --- a/pkg/influxunifi/influxdb.go +++ b/pkg/influxunifi/influxdb.go @@ -446,6 +446,10 @@ func (u *InfluxUnifi) loopPoints(r report) { for _, a := range m.PortAnomalies { u.switchExport(r, a) } + + for _, v := range m.VPNMeshes { + u.switchExport(r, v) + } } 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) case *unifi.PortAnomaly: u.batchPortAnomaly(r, v) + case *unifi.MagicSiteToSiteVPN: + u.batchMagicSiteToSiteVPN(r, v) default: if u.Collector.Poller().LogUnknownTypes { u.LogDebugf("unknown export type: %T", v) diff --git a/pkg/influxunifi/vpn.go b/pkg/influxunifi/vpn.go new file mode 100644 index 00000000..d421f26e --- /dev/null +++ b/pkg/influxunifi/vpn.go @@ -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}) + } +} diff --git a/pkg/inputunifi/collector.go b/pkg/inputunifi/collector.go index e9734a93..ef99c6e9 100644 --- a/pkg/inputunifi/collector.go +++ b/pkg/inputunifi/collector.go @@ -103,6 +103,7 @@ func (u *InputUnifi) pollController(c *Controller) (*poller.Metrics, error) { if c == nil { return nil, fmt.Errorf("controller is nil") } + if c.Unifi == nil { return nil, fmt.Errorf("controller client is nil (e.g. after 429 or auth failure): %s", c.URL) } @@ -246,6 +247,14 @@ func (u *InputUnifi) pollController(c *Controller) (*poller.Metrics, error) { 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). // Recover so a panic in updateWeb (e.g. old image, race) never kills the poller. 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) } }() + updateWeb(c, m) }() } + return u.augmentMetrics(c, m), nil } @@ -410,10 +421,12 @@ func (u *InputUnifi) augmentMetrics(c *Controller, metrics *Metrics) *poller.Met if isDefaultSiteName(site.Name) { site.Name = c.DefaultSiteNameOverride } + if isDefaultSiteName(site.SiteName) { site.SiteName = c.DefaultSiteNameOverride } } + 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) { site.SiteName = c.DefaultSiteNameOverride } + 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) { speedTest.SiteName = c.DefaultSiteNameOverride } + 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) { traffic.TrafficSite.SiteName = c.DefaultSiteNameOverride } + 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) { lease.SiteName = c.DefaultSiteNameOverride } + 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) } + 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. // 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 @@ -504,6 +529,7 @@ func isDefaultSiteName(siteName string) bool { if siteName == "" { return false } + lower := strings.ToLower(siteName) // Check for exact match or if it contains "default" as a word return lower == "default" || strings.Contains(lower, "default") @@ -572,6 +598,7 @@ func applySiteNameOverride(m *poller.Metrics, overrideName string) { if isDefaultSiteName(site.Name) { site.Name = overrideName } + if isDefaultSiteName(site.SiteName) { 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. diff --git a/pkg/inputunifi/input.go b/pkg/inputunifi/input.go index 5fe3f91f..b5a8f1ad 100644 --- a/pkg/inputunifi/input.go +++ b/pkg/inputunifi/input.go @@ -60,7 +60,7 @@ type Controller struct { 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"` 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:"-"` ID string `json:"id,omitempty"` // this is an output, not an input. } @@ -68,31 +68,32 @@ type Controller struct { // Config contains our configuration data. type Config struct { sync.RWMutex // locks the Unifi struct member when re-authing to unifi. - Default Controller `json:"defaults" toml:"defaults" xml:"default" yaml:"defaults"` - Disable bool `json:"disable" toml:"disable" xml:"disable,attr" yaml:"disable"` - Dynamic bool `json:"dynamic" toml:"dynamic" xml:"dynamic,attr" yaml:"dynamic"` - Remote bool `json:"remote" toml:"remote" xml:"remote,attr" yaml:"remote"` + Default Controller `json:"defaults" toml:"defaults" xml:"default" yaml:"defaults"` + Disable bool `json:"disable" toml:"disable" xml:"disable,attr" yaml:"disable"` + Dynamic bool `json:"dynamic" toml:"dynamic" xml:"dynamic,attr" yaml:"dynamic"` + 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"` - 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. type Metrics struct { - TS time.Time - Sites []*unifi.Site - Clients []*unifi.Client - SitesDPI []*unifi.DPITable - ClientsDPI []*unifi.DPITable - CountryTraffic []*unifi.UsageByCountry - RogueAPs []*unifi.RogueAP - SpeedTests []*unifi.SpeedTestResult - Devices *unifi.Devices + TS time.Time + Sites []*unifi.Site + Clients []*unifi.Client + SitesDPI []*unifi.DPITable + ClientsDPI []*unifi.DPITable + CountryTraffic []*unifi.UsageByCountry + RogueAPs []*unifi.RogueAP + SpeedTests []*unifi.SpeedTestResult + Devices *unifi.Devices DHCPLeases []*unifi.DHCPLease WANConfigs []*unifi.WANEnrichedConfiguration Sysinfos []*unifi.Sysinfo FirewallPolicies []*unifi.FirewallPolicy Topologies []*unifi.Topology PortAnomalies []*unifi.PortAnomaly + VPNMeshes []*unifi.MagicSiteToSiteVPN } func init() { // nolint: gochecknoinits @@ -158,17 +159,20 @@ func (u *InputUnifi) getUnifi(c *Controller) error { } var lastErr error + backoff := 30 * time.Second for attempt := 0; attempt < maxAuthRetries; attempt++ { c.Unifi, lastErr = unifi.NewUnifi(cfg) if lastErr == nil { u.LogDebugf("Authenticated with controller successfully, %s", c.URL) + return nil } if !errors.Is(lastErr, unifi.ErrTooManyRequests) { c.Unifi = nil + 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)", c.URL, backoff, attempt+1, maxAuthRetries) time.Sleep(backoff) + if backoff < 5*time.Minute { backoff = backoff * 2 } @@ -188,6 +193,7 @@ func (u *InputUnifi) getUnifi(c *Controller) error { } c.Unifi = nil + 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 == "" { c.APIKey = u.Default.APIKey } + c.User = "" c.Pass = "" } else { diff --git a/pkg/otelunifi/report.go b/pkg/otelunifi/report.go index a4cc8883..dfaeecd4 100644 --- a/pkg/otelunifi/report.go +++ b/pkg/otelunifi/report.go @@ -50,6 +50,7 @@ func (u *OtelOutput) reportMetrics(m *poller.Metrics, _ *poller.Events) (*Report u.exportFirewallPolicies(ctx, meter, m, r) u.exportTopology(ctx, meter, m, r) u.exportPortAnomalies(ctx, meter, m, r) + u.exportVPNMeshes(ctx, meter, m, r) r.Elapsed = time.Since(start) @@ -215,6 +216,7 @@ func (u *OtelOutput) recordGauge( g, err := meter.Float64ObservableGauge(name, metric.WithDescription(description)) if err != nil { r.Errors++ + u.LogDebugf("otel: creating gauge %s: %v", name, err) return @@ -227,6 +229,7 @@ func (u *OtelOutput) recordGauge( }, g) if err != nil { r.Errors++ + u.LogDebugf("otel: registering callback for %s: %v", name, err) return diff --git a/pkg/otelunifi/vpn.go b/pkg/otelunifi/vpn.go new file mode 100644 index 00000000..efb8d9f0 --- /dev/null +++ b/pkg/otelunifi/vpn.go @@ -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) + } + } + } +} diff --git a/pkg/poller/config.go b/pkg/poller/config.go index 3a032dcd..d211e063 100644 --- a/pkg/poller/config.go +++ b/pkg/poller/config.go @@ -105,6 +105,7 @@ type Metrics struct { FirewallPolicies []any Topologies []any PortAnomalies []any + VPNMeshes []any ControllerStatuses []ControllerStatus } @@ -120,10 +121,10 @@ type Config struct { // Poller is the global config values. type Poller struct { - Plugins []string `json:"plugins" toml:"plugins" xml:"plugin" yaml:"plugins"` - Debug bool `json:"debug" toml:"debug" xml:"debug,attr" yaml:"debug"` - 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"` + Plugins []string `json:"plugins" toml:"plugins" xml:"plugin" yaml:"plugins"` + Debug bool `json:"debug" toml:"debug" xml:"debug,attr" yaml:"debug"` + 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"` } // LoadPlugins reads-in dynamic shared libraries. diff --git a/pkg/poller/inputs.go b/pkg/poller/inputs.go index e319ec7a..ee1dc6e0 100644 --- a/pkg/poller/inputs.go +++ b/pkg/poller/inputs.go @@ -280,6 +280,7 @@ func AppendMetrics(existing *Metrics, m *Metrics) *Metrics { existing.FirewallPolicies = append(existing.FirewallPolicies, m.FirewallPolicies...) existing.Topologies = append(existing.Topologies, m.Topologies...) existing.PortAnomalies = append(existing.PortAnomalies, m.PortAnomalies...) + existing.VPNMeshes = append(existing.VPNMeshes, m.VPNMeshes...) existing.ControllerStatuses = append(existing.ControllerStatuses, m.ControllerStatuses...) return existing diff --git a/pkg/promunifi/collector.go b/pkg/promunifi/collector.go index dde09477..91c2df78 100644 --- a/pkg/promunifi/collector.go +++ b/pkg/promunifi/collector.go @@ -53,6 +53,7 @@ type promUnifi struct { FirewallPolicy *firewallpolicy Topology *topology PortAnomaly *portanomaly + VPNMesh *vpnmesh // 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 @@ -221,6 +222,7 @@ func (u *promUnifi) Run(c poller.Collect) error { u.FirewallPolicy = descFirewallPolicy(u.Namespace + "_") u.Topology = descTopology(u.Namespace + "_") u.PortAnomaly = descPortAnomaly(u.Namespace + "_") + u.VPNMesh = descVPNMesh(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).", @@ -309,7 +311,7 @@ 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} { + 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)) // 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) } } + if len(dhcpLeases) > 0 { u.exportDHCPNetworkPool(r, dhcpLeases) } @@ -501,6 +504,12 @@ func (u *promUnifi) loopExports(r report) { 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) } diff --git a/pkg/promunifi/vpn.go b/pkg/promunifi/vpn.go new file mode 100644 index 00000000..7bf35fe4 --- /dev/null +++ b/pkg/promunifi/vpn.go @@ -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}, + }) + } + } +}