feat: firewall policy metrics across all output plugins (closes #928) (#979)

* feat(promunifi): add firewall policy metrics (closes #928)

Bump unifi client to v5.22.0 and wire up firewall policy data end-to-end:

- poller.Metrics: add FirewallPolicies []any slice
- inputunifi: collect GetFirewallPolicies() per poll cycle; apply
  DefaultSiteNameOverride; augment into poller.Metrics
- promunifi: export per-rule (rule_enabled, rule_index) and per-site
  aggregate metrics (rules_total, rules_enabled, rules_disabled,
  rules_by_action, rules_predefined, rules_custom, rules_logging_enabled)

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

* feat: export firewall policies to influx, datadog, and otel outputs

Extends firewall policy support (PR #979) to all remaining output plugins:

- influxunifi: batchFirewallPolicy() writes measurement "firewall_policy"
  with tags (rule_name, action, protocol, ip_version, source/dest zone,
  site_name, source) and fields (enabled, index, predefined, logging)
- datadogunifi: batchFirewallPolicy() emits the same data as Datadog gauges
  under the "firewall_policy.*" namespace
- otelunifi: exportFirewallPolicies() emits per-rule gauges
  (unifi_firewall_rule_enabled, unifi_firewall_rule_index) and per-site
  aggregates (rules_total, rules_enabled, rules_disabled, rules_by_action,
  rules_predefined, rules_custom, rules_logging_enabled)

Also rebases onto master to pick up the otelunifi plugin (PR #978).

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 18:26:27 -05:00
committed by GitHub
parent 521c2f88bc
commit 6b33b6b97b
14 changed files with 430 additions and 6 deletions

4
go.mod
View File

@@ -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.20.1-0.20260323223726-f363f61cdbe3
github.com/unpoller/unifi/v5 v5.22.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
@@ -36,7 +36,7 @@ require (
go.opentelemetry.io/otel/trace v1.42.0 // indirect
go.opentelemetry.io/proto/otlp v1.9.0 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
golang.org/x/net v0.51.0 // indirect
golang.org/x/net v0.52.0 // indirect
golang.org/x/text v0.35.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 // indirect

6
go.sum
View File

@@ -89,6 +89,10 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/unpoller/unifi/v5 v5.20.1-0.20260323223726-f363f61cdbe3 h1:gBBSmwjzzTAVN56mthkm7J7sN/M6bgjR0RUMTtBwPO0=
github.com/unpoller/unifi/v5 v5.20.1-0.20260323223726-f363f61cdbe3/go.mod h1:vSIXIclPG9dpKxUp+pavfgENHWaTZXvDg7F036R1YCo=
github.com/unpoller/unifi/v5 v5.21.0 h1:rVmZjiKDwu35JYuFhhJTfCU2itcFy9uEfySmjOf5JFU=
github.com/unpoller/unifi/v5 v5.21.0/go.mod h1:0R6t/SKaS8eoOrTkSYwzVb292KG5eQfbKEuevuES0So=
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/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=
@@ -122,6 +126,8 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
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/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=

View File

@@ -321,6 +321,10 @@ func (u *DatadogUnifi) loopPoints(r report) {
for _, w := range m.WANConfigs {
u.switchExport(r, w)
}
for _, p := range m.FirewallPolicies {
u.switchExport(r, p)
}
}
func (u *DatadogUnifi) switchExport(r report, v any) { //nolint:cyclop
@@ -361,6 +365,8 @@ func (u *DatadogUnifi) switchExport(r report, v any) { //nolint:cyclop
u.batchSpeedTest(r, v)
case *unifi.WANEnrichedConfiguration:
u.batchWAN(r, v)
case *unifi.FirewallPolicy:
u.batchFirewallPolicy(r, v)
default:
if u.Collector != nil && u.Collector.Poller().LogUnknownTypes {
u.LogDebugf("unknown export type: %T", v)

View File

@@ -0,0 +1,51 @@
package datadogunifi
import (
"github.com/unpoller/unifi/v5"
)
// batchFirewallPolicy generates firewall policy datapoints for Datadog.
func (u *DatadogUnifi) batchFirewallPolicy(r report, p *unifi.FirewallPolicy) {
if p == nil {
return
}
metricName := metricNamespace("firewall_policy")
tags := []string{
tag("rule_name", p.Name),
tag("action", p.Action),
tag("protocol", p.Protocol),
tag("ip_version", p.IPVersion),
tag("source_zone", p.Source.ZoneID),
tag("dest_zone", p.Destination.ZoneID),
tag("site_name", p.SiteName),
tag("source", p.SourceName),
}
enabled := 0.0
if p.Enabled.Val {
enabled = 1.0
}
predefined := 0.0
if p.Predefined.Val {
predefined = 1.0
}
logging := 0.0
if p.Logging.Val {
logging = 1.0
}
data := map[string]float64{
"enabled": enabled,
"index": p.Index.Val,
"predefined": predefined,
"logging": logging,
}
for name, value := range data {
_ = r.reportGauge(metricName(name), value, tags)
}
}

View File

@@ -0,0 +1,47 @@
package influxunifi
import (
"github.com/unpoller/unifi/v5"
)
// batchFirewallPolicy generates a firewall policy datapoint for InfluxDB.
func (u *InfluxUnifi) batchFirewallPolicy(r report, p *unifi.FirewallPolicy) {
if p == nil {
return
}
tags := map[string]string{
"rule_name": p.Name,
"action": p.Action,
"protocol": p.Protocol,
"ip_version": p.IPVersion,
"source_zone": p.Source.ZoneID,
"dest_zone": p.Destination.ZoneID,
"site_name": p.SiteName,
"source": p.SourceName,
}
enabled := 0
if p.Enabled.Val {
enabled = 1
}
predefined := 0
if p.Predefined.Val {
predefined = 1
}
logging := 0
if p.Logging.Val {
logging = 1
}
fields := map[string]any{
"enabled": enabled,
"index": p.Index.Val,
"predefined": predefined,
"logging": logging,
}
r.send(&metric{Table: "firewall_policy", Tags: tags, Fields: fields})
}

View File

@@ -434,6 +434,10 @@ func (u *InfluxUnifi) loopPoints(r report) {
for _, w := range m.WANConfigs {
u.switchExport(r, w)
}
for _, p := range m.FirewallPolicies {
u.switchExport(r, p)
}
}
func (u *InfluxUnifi) switchExport(r report, v any) { //nolint:cyclop
@@ -474,6 +478,8 @@ func (u *InfluxUnifi) switchExport(r report, v any) { //nolint:cyclop
u.batchSpeedTest(r, v)
case *unifi.WANEnrichedConfiguration:
u.batchWAN(r, v)
case *unifi.FirewallPolicy:
u.batchFirewallPolicy(r, v)
default:
if u.Collector.Poller().LogUnknownTypes {
u.LogDebugf("unknown export type: %T", v)

View File

@@ -214,6 +214,14 @@ func (u *InputUnifi) pollController(c *Controller) (*poller.Metrics, error) {
u.LogDebugf("Found %d WAN configuration entries", len(m.WANConfigs))
}
// Get firewall policies
if m.FirewallPolicies, err = c.Unifi.GetFirewallPolicies(sites); err != nil {
// Don't fail collection if firewall policies fail - older controllers may not have this endpoint
u.LogDebugf("unifi.GetFirewallPolicies(%s): %v (continuing)", c.URL, err)
} else {
u.LogDebugf("Found %d FirewallPolicies entries", len(m.FirewallPolicies))
}
// Get controller system info (UniFi OS only)
if m.Sysinfos, err = c.Unifi.GetSysinfo(sites); err != nil {
// Don't fail collection if sysinfo fails - older controllers may not have this endpoint
@@ -437,6 +445,15 @@ func (u *InputUnifi) augmentMetrics(c *Controller, metrics *Metrics) *poller.Met
m.Sysinfos = append(m.Sysinfos, sysinfo)
}
for _, policy := range metrics.FirewallPolicies {
// Apply site name override for firewall policies if configured
if c.DefaultSiteNameOverride != "" && isDefaultSiteName(policy.SiteName) {
policy.SiteName = c.DefaultSiteNameOverride
}
m.FirewallPolicies = append(m.FirewallPolicies, policy)
}
// 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
@@ -562,6 +579,15 @@ func applySiteNameOverride(m *poller.Metrics, overrideName string) {
_ = wanConfig
}
}
// Apply to firewall policies
for i := range m.FirewallPolicies {
if policy, ok := m.FirewallPolicies[i].(*unifi.FirewallPolicy); ok {
if isDefaultSiteName(policy.SiteName) {
policy.SiteName = overrideName
}
}
}
}
// this is a helper function for augmentMetrics.

View File

@@ -87,9 +87,10 @@ type Metrics struct {
RogueAPs []*unifi.RogueAP
SpeedTests []*unifi.SpeedTestResult
Devices *unifi.Devices
DHCPLeases []*unifi.DHCPLease
WANConfigs []*unifi.WANEnrichedConfiguration
Sysinfos []*unifi.Sysinfo
DHCPLeases []*unifi.DHCPLease
WANConfigs []*unifi.WANEnrichedConfiguration
Sysinfos []*unifi.Sysinfo
FirewallPolicies []*unifi.FirewallPolicy
}
func init() { // nolint: gochecknoinits

View File

@@ -0,0 +1,116 @@
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"
)
// exportFirewallPolicies emits per-rule and per-site aggregate firewall policy metrics.
func (u *OtelOutput) exportFirewallPolicies(ctx context.Context, meter metric.Meter, m *poller.Metrics, r *Report) {
type siteKey struct{ site, source string }
type siteStats struct {
total int
enabled int
disabled int
predef int
custom int
logging int
byAction map[string]int
}
sites := make(map[siteKey]*siteStats)
for _, item := range m.FirewallPolicies {
p, ok := item.(*unifi.FirewallPolicy)
if !ok {
continue
}
attrs := attribute.NewSet(
attribute.String("rule_name", p.Name),
attribute.String("action", p.Action),
attribute.String("protocol", p.Protocol),
attribute.String("ip_version", p.IPVersion),
attribute.String("source_zone", p.Source.ZoneID),
attribute.String("dest_zone", p.Destination.ZoneID),
attribute.String("site_name", p.SiteName),
attribute.String("source", p.SourceName),
)
enabled := 0.0
if p.Enabled.Val {
enabled = 1.0
}
u.recordGauge(ctx, meter, r, "unifi_firewall_rule_enabled",
"Firewall rule enabled status (1=enabled, 0=disabled)", enabled, attrs)
u.recordGauge(ctx, meter, r, "unifi_firewall_rule_index",
"Firewall rule priority index", p.Index.Val, attrs)
// Accumulate site-level stats
key := siteKey{p.SiteName, p.SourceName}
if _, ok := sites[key]; !ok {
sites[key] = &siteStats{byAction: make(map[string]int)}
}
s := sites[key]
s.total++
if p.Enabled.Val {
s.enabled++
} else {
s.disabled++
}
if p.Predefined.Val {
s.predef++
} else {
s.custom++
}
if p.Logging.Val {
s.logging++
}
if p.Action != "" {
s.byAction[p.Action]++
}
}
// Emit per-site aggregate metrics
for key, s := range sites {
siteAttrs := attribute.NewSet(
attribute.String("site_name", key.site),
attribute.String("source", key.source),
)
u.recordGauge(ctx, meter, r, "unifi_firewall_rules_total",
"Total number of firewall rules", float64(s.total), siteAttrs)
u.recordGauge(ctx, meter, r, "unifi_firewall_rules_enabled",
"Number of enabled firewall rules", float64(s.enabled), siteAttrs)
u.recordGauge(ctx, meter, r, "unifi_firewall_rules_disabled",
"Number of disabled firewall rules", float64(s.disabled), siteAttrs)
u.recordGauge(ctx, meter, r, "unifi_firewall_rules_predefined",
"Number of predefined firewall rules", float64(s.predef), siteAttrs)
u.recordGauge(ctx, meter, r, "unifi_firewall_rules_custom",
"Number of custom firewall rules", float64(s.custom), siteAttrs)
u.recordGauge(ctx, meter, r, "unifi_firewall_rules_logging_enabled",
"Number of firewall rules with logging enabled", float64(s.logging), siteAttrs)
for action, count := range s.byAction {
actionAttrs := attribute.NewSet(
attribute.String("action", action),
attribute.String("site_name", key.site),
attribute.String("source", key.source),
)
u.recordGauge(ctx, meter, r, "unifi_firewall_rules_by_action",
"Number of firewall rules grouped by action", float64(count), actionAttrs)
}
}
}

View File

@@ -47,6 +47,7 @@ func (u *OtelOutput) reportMetrics(m *poller.Metrics, _ *poller.Events) (*Report
u.exportSites(ctx, meter, m, r)
u.exportClients(ctx, meter, m, r)
u.exportDevices(ctx, meter, m, r)
u.exportFirewallPolicies(ctx, meter, m, r)
r.Elapsed = time.Since(start)

View File

@@ -102,6 +102,7 @@ type Metrics struct {
DHCPLeases []any
WANConfigs []any
Sysinfos []any
FirewallPolicies []any
ControllerStatuses []ControllerStatus
}

View File

@@ -277,6 +277,7 @@ func AppendMetrics(existing *Metrics, m *Metrics) *Metrics {
existing.DHCPLeases = append(existing.DHCPLeases, m.DHCPLeases...)
existing.WANConfigs = append(existing.WANConfigs, m.WANConfigs...)
existing.Sysinfos = append(existing.Sysinfos, m.Sysinfos...)
existing.FirewallPolicies = append(existing.FirewallPolicies, m.FirewallPolicies...)
existing.ControllerStatuses = append(existing.ControllerStatuses, m.ControllerStatuses...)
return existing

View File

@@ -50,6 +50,7 @@ type promUnifi struct {
DHCPLease *dhcplease
WAN *wan
Controller *controller
FirewallPolicy *firewallpolicy
// 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
@@ -215,6 +216,7 @@ func (u *promUnifi) Run(c poller.Collect) error {
u.DHCPLease = descDHCPLease(u.Namespace + "_")
u.WAN = descWAN(u.Namespace + "_")
u.Controller = descController(u.Namespace + "_")
u.FirewallPolicy = descFirewallPolicy(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).",
@@ -303,7 +305,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} {
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} {
v := reflect.Indirect(reflect.ValueOf(f))
// Loop each struct member and send it to the provided channel.
@@ -470,6 +472,16 @@ func (u *promUnifi) loopExports(r report) {
}
}
// Export firewall policy metrics
firewallPolicies := make([]*unifi.FirewallPolicy, 0, len(m.FirewallPolicies))
for _, p := range m.FirewallPolicies {
if policy, ok := p.(*unifi.FirewallPolicy); ok {
firewallPolicies = append(firewallPolicies, policy)
}
}
u.exportFirewallPolicies(r, firewallPolicies)
u.exportClientDPItotals(r, appTotal, catTotal)
}

View File

@@ -0,0 +1,150 @@
package promunifi
import (
"github.com/prometheus/client_golang/prometheus"
"github.com/unpoller/unifi/v5"
)
type firewallpolicy struct {
// Per-rule metrics
RuleEnabled *prometheus.Desc
RuleIndex *prometheus.Desc
// Aggregate metrics
RulesTotal *prometheus.Desc
RulesEnabled *prometheus.Desc
RulesDisabled *prometheus.Desc
RulesByAction *prometheus.Desc
RulesPredefined *prometheus.Desc
RulesCustom *prometheus.Desc
RulesLoggingEnabled *prometheus.Desc
}
func descFirewallPolicy(ns string) *firewallpolicy {
// Per-rule labels
ruleLabels := []string{
"rule_name",
"action",
"protocol",
"ip_version",
"source_zone",
"dest_zone",
"site_name",
"source",
}
// Site-level labels
siteLabels := []string{"site_name", "source"}
// Action-level labels
actionLabels := []string{"action", "site_name", "source"}
nd := prometheus.NewDesc
return &firewallpolicy{
RuleEnabled: nd(ns+"firewall_rule_enabled", "Firewall rule enabled status (1=enabled, 0=disabled)", ruleLabels, nil),
RuleIndex: nd(ns+"firewall_rule_index", "Firewall rule priority index", ruleLabels, nil),
RulesTotal: nd(ns+"firewall_rules_total", "Total number of firewall rules", siteLabels, nil),
RulesEnabled: nd(ns+"firewall_rules_enabled", "Number of enabled firewall rules", siteLabels, nil),
RulesDisabled: nd(ns+"firewall_rules_disabled", "Number of disabled firewall rules", siteLabels, nil),
RulesByAction: nd(ns+"firewall_rules_by_action", "Number of firewall rules grouped by action", actionLabels, nil),
RulesPredefined: nd(ns+"firewall_rules_predefined", "Number of predefined firewall rules", siteLabels, nil),
RulesCustom: nd(ns+"firewall_rules_custom", "Number of custom firewall rules", siteLabels, nil),
RulesLoggingEnabled: nd(ns+"firewall_rules_logging_enabled", "Number of firewall rules with logging enabled", siteLabels, nil),
}
}
func (u *promUnifi) exportFirewallPolicies(r report, policies []*unifi.FirewallPolicy) {
if len(policies) == 0 {
return
}
// Per-site aggregate counters, keyed by "siteName|source"
type siteKey struct{ site, source string }
type siteStats struct {
total int
enabled int
disabled int
predef int
custom int
logging int
site string
source string
byAction map[string]int
}
sites := make(map[siteKey]*siteStats)
for _, p := range policies {
key := siteKey{p.SiteName, p.SourceName}
if _, ok := sites[key]; !ok {
sites[key] = &siteStats{
site: p.SiteName,
source: p.SourceName,
byAction: make(map[string]int),
}
}
s := sites[key]
s.total++
if p.Enabled.Val {
s.enabled++
} else {
s.disabled++
}
if p.Predefined.Val {
s.predef++
} else {
s.custom++
}
if p.Logging.Val {
s.logging++
}
if p.Action != "" {
s.byAction[p.Action]++
}
// Per-rule metrics
ruleLabels := []string{
p.Name,
p.Action,
p.Protocol,
p.IPVersion,
p.Source.ZoneID,
p.Destination.ZoneID,
p.SiteName,
p.SourceName,
}
enabledVal := 0.0
if p.Enabled.Val {
enabledVal = 1.0
}
r.send([]*metric{
{u.FirewallPolicy.RuleEnabled, gauge, enabledVal, ruleLabels},
{u.FirewallPolicy.RuleIndex, gauge, p.Index.Val, ruleLabels},
})
}
// Site-level aggregate metrics
for _, s := range sites {
siteLabels := []string{s.site, s.source}
r.send([]*metric{
{u.FirewallPolicy.RulesTotal, gauge, float64(s.total), siteLabels},
{u.FirewallPolicy.RulesEnabled, gauge, float64(s.enabled), siteLabels},
{u.FirewallPolicy.RulesDisabled, gauge, float64(s.disabled), siteLabels},
{u.FirewallPolicy.RulesPredefined, gauge, float64(s.predef), siteLabels},
{u.FirewallPolicy.RulesCustom, gauge, float64(s.custom), siteLabels},
{u.FirewallPolicy.RulesLoggingEnabled, gauge, float64(s.logging), siteLabels},
})
for action, count := range s.byAction {
r.send([]*metric{
{u.FirewallPolicy.RulesByAction, gauge, float64(count), []string{action, s.site, s.source}},
})
}
}
}