mirror of
https://github.com/unpoller/unpoller.git
synced 2026-03-31 06:24:21 -04:00
Adds log_unknown_types config option (default: false) to control logging of unknown UniFi device types. When disabled (default), unknown devices are silently ignored to reduce log volume. When enabled, they are logged as DEBUG messages instead of ERROR. Addresses issue #912. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
460 lines
13 KiB
Go
460 lines
13 KiB
Go
// Package promunifi provides the bridge between unpoller metrics and prometheus.
|
|
package promunifi
|
|
|
|
import (
|
|
"fmt"
|
|
"github.com/prometheus/client_golang/prometheus/collectors"
|
|
"net"
|
|
"net/http"
|
|
"reflect"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/prometheus/client_golang/prometheus"
|
|
"github.com/prometheus/client_golang/prometheus/promhttp"
|
|
promver "github.com/prometheus/common/version"
|
|
"github.com/unpoller/unifi/v5"
|
|
"github.com/unpoller/unpoller/pkg/poller"
|
|
"github.com/unpoller/unpoller/pkg/webserver"
|
|
"golift.io/version"
|
|
)
|
|
|
|
// PluginName is the name of this plugin.
|
|
const PluginName = "prometheus"
|
|
|
|
const (
|
|
// channel buffer, fits at least one batch.
|
|
defaultBuffer = 50
|
|
defaultHTTPListen = "0.0.0.0:9130"
|
|
// simply fewer letters.
|
|
counter = prometheus.CounterValue
|
|
gauge = prometheus.GaugeValue
|
|
)
|
|
|
|
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
|
|
// This interface is passed to the Collect() method. The Collect method uses
|
|
// this interface to retrieve the latest UniFi measurements and export them.
|
|
Collector poller.Collect
|
|
}
|
|
|
|
var _ poller.OutputPlugin = &promUnifi{}
|
|
|
|
// Config is the input (config file) data used to initialize this output plugin.
|
|
type Config struct {
|
|
// If non-empty, each of the collected metrics is prefixed by the
|
|
// provided string and an underscore ("_").
|
|
Namespace string `json:"namespace" toml:"namespace" xml:"namespace" yaml:"namespace"`
|
|
HTTPListen string `json:"http_listen" toml:"http_listen" xml:"http_listen" yaml:"http_listen"`
|
|
// If these are provided, the app will attempt to listen with an SSL connection.
|
|
SSLCrtPath string `json:"ssl_cert_path" toml:"ssl_cert_path" xml:"ssl_cert_path" yaml:"ssl_cert_path"`
|
|
SSLKeyPath string `json:"ssl_key_path" toml:"ssl_key_path" xml:"ssl_key_path" yaml:"ssl_key_path"`
|
|
// Buffer is a channel buffer.
|
|
// Default is probably 50. Seems fast there; try 1 to see if CPU usage goes down?
|
|
Buffer int `json:"buffer" toml:"buffer" xml:"buffer" yaml:"buffer"`
|
|
// If true, any error encountered during collection is reported as an
|
|
// invalid metric (see NewInvalidMetric). Otherwise, errors are ignored
|
|
// and the collected metrics will be incomplete. Possibly, no metrics
|
|
// will be collected at all.
|
|
ReportErrors bool `json:"report_errors" toml:"report_errors" xml:"report_errors" yaml:"report_errors"`
|
|
Disable bool `json:"disable" toml:"disable" xml:"disable" yaml:"disable"`
|
|
// Save data for dead ports? ie. ports that are down or disabled.
|
|
DeadPorts bool `json:"dead_ports" toml:"dead_ports" xml:"dead_ports" yaml:"dead_ports"`
|
|
}
|
|
|
|
type metric struct {
|
|
Desc *prometheus.Desc
|
|
ValueType prometheus.ValueType
|
|
Value any
|
|
Labels []string
|
|
}
|
|
|
|
// Report accumulates counters that are printed to a log line.
|
|
type Report struct {
|
|
*Config
|
|
Total int // Total count of metrics recorded.
|
|
Errors int // Total count of errors recording metrics.
|
|
Zeros int // Total count of metrics equal to zero.
|
|
Bytes int // Total count of bytes written.
|
|
USG int // Total count of USG devices.
|
|
USW int // Total count of USW devices.
|
|
PDU int // Total count of PDU devices.
|
|
UAP int // Total count of UAP devices.
|
|
UDM int // Total count of UDM devices.
|
|
UXG int // Total count of UXG devices.
|
|
UBB int // Total count of UBB devices.
|
|
UCI int // Total count of UCI devices.
|
|
Metrics *poller.Metrics // Metrics collected and recorded.
|
|
Elapsed time.Duration // Duration elapsed collecting and exporting.
|
|
Fetch time.Duration // Duration elapsed making controller requests.
|
|
Start time.Time // Time collection began.
|
|
ch chan []*metric
|
|
wg sync.WaitGroup
|
|
}
|
|
|
|
// target is used for targeted (sometimes dynamic) metrics scrapes.
|
|
type target struct {
|
|
*poller.Filter
|
|
u *promUnifi
|
|
}
|
|
|
|
// init is how this modular code is initialized by the main app.
|
|
// This module adds itself as an output module to the poller core.
|
|
func init() { // nolint: gochecknoinits
|
|
u := &promUnifi{Config: &Config{}}
|
|
|
|
poller.NewOutput(&poller.Output{
|
|
Name: PluginName,
|
|
Config: u,
|
|
OutputPlugin: u,
|
|
})
|
|
}
|
|
|
|
func (u *promUnifi) DebugOutput() (bool, error) {
|
|
if u == nil {
|
|
return true, nil
|
|
}
|
|
|
|
if !u.Enabled() {
|
|
return true, nil
|
|
}
|
|
|
|
if u.HTTPListen == "" {
|
|
return false, fmt.Errorf("invalid listen string")
|
|
}
|
|
|
|
// check the port
|
|
parts := strings.Split(u.HTTPListen, ":")
|
|
if len(parts) != 2 {
|
|
return false, fmt.Errorf("invalid listen address: %s (must be of the form \"IP:Port\"", u.HTTPListen)
|
|
}
|
|
|
|
// Skip network binding check during health checks to avoid "address already in use"
|
|
// errors when the main application is already running and bound to the port.
|
|
if poller.IsHealthCheckMode() {
|
|
return true, nil
|
|
}
|
|
|
|
ln, err := net.Listen("tcp", u.HTTPListen)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
_ = ln.Close()
|
|
|
|
return true, nil
|
|
}
|
|
|
|
func (u *promUnifi) Enabled() bool {
|
|
if u == nil {
|
|
return false
|
|
}
|
|
|
|
if u.Config == nil {
|
|
return false
|
|
}
|
|
|
|
return !u.Disable
|
|
}
|
|
|
|
// Run creates the collectors and starts the web server up.
|
|
// Should be run in a Go routine. Returns nil if not configured.
|
|
func (u *promUnifi) Run(c poller.Collect) error {
|
|
u.Collector = c
|
|
if u.Config == nil || !u.Enabled() {
|
|
u.LogDebugf("Prometheus config missing (or disabled), Prometheus HTTP listener disabled!")
|
|
|
|
return nil
|
|
}
|
|
|
|
u.Logf("Prometheus is enabled")
|
|
|
|
u.Namespace = strings.Trim(strings.ReplaceAll(u.Namespace, "-", "_"), "_")
|
|
if u.Namespace == "" {
|
|
u.Namespace = strings.ReplaceAll(poller.AppName, "-", "")
|
|
}
|
|
|
|
if u.HTTPListen == "" {
|
|
u.HTTPListen = defaultHTTPListen
|
|
}
|
|
|
|
if u.Buffer == 0 {
|
|
u.Buffer = defaultBuffer
|
|
}
|
|
|
|
u.Client = descClient(u.Namespace + "_client_")
|
|
u.Device = descDevice(u.Namespace + "_device_") // stats for all device types.
|
|
u.UAP = descUAP(u.Namespace + "_device_")
|
|
u.USG = descUSG(u.Namespace + "_device_")
|
|
u.USW = descUSW(u.Namespace + "_device_")
|
|
u.PDU = descPDU(u.Namespace + "_device_")
|
|
u.Site = descSite(u.Namespace + "_site_")
|
|
u.RogueAP = descRogueAP(u.Namespace + "_rogueap_")
|
|
u.SpeedTest = descSpeedTest(u.Namespace + "_speedtest_")
|
|
u.CountryTraffic = descCountryTraffic(u.Namespace + "_countrytraffic_")
|
|
|
|
mux := http.NewServeMux()
|
|
promver.Version = version.Version
|
|
promver.Revision = version.Revision
|
|
promver.Branch = version.Branch
|
|
|
|
webserver.UpdateOutput(&webserver.Output{Name: PluginName, Config: u.Config})
|
|
prometheus.MustRegister(collectors.NewBuildInfoCollector())
|
|
prometheus.MustRegister(u)
|
|
mux.Handle("/metrics", promhttp.HandlerFor(prometheus.DefaultGatherer,
|
|
promhttp.HandlerOpts{ErrorHandling: promhttp.ContinueOnError},
|
|
))
|
|
mux.HandleFunc("/scrape", u.ScrapeHandler)
|
|
mux.HandleFunc("/", u.DefaultHandler)
|
|
|
|
switch u.SSLKeyPath == "" && u.SSLCrtPath == "" {
|
|
case true:
|
|
u.Logf("Prometheus exported at http://%s/ - namespace: %s", u.HTTPListen, u.Namespace)
|
|
|
|
return http.ListenAndServe(u.HTTPListen, mux)
|
|
default:
|
|
u.Logf("Prometheus exported at https://%s/ - namespace: %s", u.HTTPListen, u.Namespace)
|
|
|
|
return http.ListenAndServeTLS(u.HTTPListen, u.SSLCrtPath, u.SSLKeyPath, mux)
|
|
}
|
|
}
|
|
|
|
// ScrapeHandler allows prometheus to scrape a single source, instead of all sources.
|
|
func (u *promUnifi) ScrapeHandler(w http.ResponseWriter, r *http.Request) {
|
|
t := &target{u: u, Filter: &poller.Filter{
|
|
Name: r.URL.Query().Get("input"), // "unifi"
|
|
Path: r.URL.Query().Get("target"), // url: "https://127.0.0.1:8443"
|
|
}}
|
|
|
|
if t.Name == "" {
|
|
t.Name = "unifi" // the default
|
|
}
|
|
|
|
if pathOld := r.URL.Query().Get("path"); pathOld != "" {
|
|
u.LogErrorf("deprecated 'path' parameter used; update your config to use 'target'")
|
|
|
|
if t.Path == "" {
|
|
t.Path = pathOld
|
|
}
|
|
}
|
|
|
|
if roleOld := r.URL.Query().Get("role"); roleOld != "" {
|
|
u.LogErrorf("deprecated 'role' parameter used; update your config to use 'target'")
|
|
|
|
if t.Path == "" {
|
|
t.Path = roleOld
|
|
}
|
|
}
|
|
|
|
if t.Path == "" {
|
|
u.LogErrorf("'target' parameter missing on scrape from %v", r.RemoteAddr)
|
|
http.Error(w, "'target' parameter must be specified: configured OR unconfigured url", 400)
|
|
|
|
return
|
|
}
|
|
|
|
registry := prometheus.NewRegistry()
|
|
|
|
registry.MustRegister(t)
|
|
promhttp.HandlerFor(
|
|
registry, promhttp.HandlerOpts{ErrorHandling: promhttp.ContinueOnError},
|
|
).ServeHTTP(w, r)
|
|
}
|
|
|
|
func (u *promUnifi) DefaultHandler(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte(poller.AppName + "\n"))
|
|
}
|
|
|
|
// Describe satisfies the prometheus Collector. This returns all of the
|
|
// metric descriptions that this packages produces.
|
|
func (t *target) Describe(ch chan<- *prometheus.Desc) {
|
|
t.u.Describe(ch)
|
|
}
|
|
|
|
// 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} {
|
|
v := reflect.Indirect(reflect.ValueOf(f))
|
|
|
|
// Loop each struct member and send it to the provided channel.
|
|
for i := 0; i < v.NumField(); i++ {
|
|
desc, ok := v.Field(i).Interface().(*prometheus.Desc)
|
|
if ok && desc != nil {
|
|
ch <- desc
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Collect satisfies the prometheus Collector. This runs for a single controller poll.
|
|
func (t *target) Collect(ch chan<- prometheus.Metric) {
|
|
t.u.collect(ch, t.Filter)
|
|
}
|
|
|
|
// Collect satisfies the prometheus Collector. This runs the input method to get
|
|
// the current metrics (from another package) then exports them for prometheus.
|
|
func (u *promUnifi) Collect(ch chan<- prometheus.Metric) {
|
|
u.collect(ch, nil)
|
|
}
|
|
|
|
func (u *promUnifi) collect(ch chan<- prometheus.Metric, filter *poller.Filter) {
|
|
var err error
|
|
|
|
r := &Report{
|
|
Config: u.Config,
|
|
ch: make(chan []*metric, u.Buffer),
|
|
Start: time.Now(),
|
|
}
|
|
defer r.close()
|
|
|
|
r.Metrics, err = u.Collector.Metrics(filter)
|
|
r.Fetch = time.Since(r.Start)
|
|
|
|
if err != nil {
|
|
r.error(ch, prometheus.NewInvalidDesc(err), ErrMetricFetchFailed)
|
|
u.LogErrorf("metric fetch failed: %v", err)
|
|
|
|
return
|
|
}
|
|
|
|
// Pass Report interface into our collecting and reporting methods.
|
|
go u.exportMetrics(r, ch, r.ch)
|
|
|
|
u.loopExports(r)
|
|
}
|
|
|
|
// This is closely tied to the method above with a sync.WaitGroup.
|
|
// This method runs in a go routine and exits when the channel closes.
|
|
// This is where our channels connects to the prometheus channel.
|
|
func (u *promUnifi) exportMetrics(r report, ch chan<- prometheus.Metric, ourChan chan []*metric) {
|
|
descs := make(map[*prometheus.Desc]bool) // used as a counter
|
|
defer r.report(u, descs)
|
|
|
|
for newMetrics := range ourChan {
|
|
for _, m := range newMetrics {
|
|
descs[m.Desc] = true
|
|
|
|
switch v := m.Value.(type) {
|
|
case unifi.FlexInt:
|
|
ch <- r.export(m, v.Val)
|
|
case float64:
|
|
ch <- r.export(m, v)
|
|
case int64:
|
|
ch <- r.export(m, float64(v))
|
|
case int:
|
|
ch <- r.export(m, float64(v))
|
|
case bool:
|
|
if v {
|
|
ch <- r.export(m, 1)
|
|
} else {
|
|
ch <- r.export(m, 0)
|
|
}
|
|
default:
|
|
r.error(ch, m.Desc, fmt.Sprintf("not a number: %v", m.Value))
|
|
}
|
|
}
|
|
|
|
r.done()
|
|
}
|
|
}
|
|
|
|
func (u *promUnifi) loopExports(r report) {
|
|
m := r.metrics()
|
|
|
|
for _, s := range m.RogueAPs {
|
|
u.switchExport(r, s)
|
|
}
|
|
|
|
for _, s := range m.Sites {
|
|
u.switchExport(r, s)
|
|
}
|
|
|
|
for _, s := range m.SitesDPI {
|
|
u.exportSiteDPI(r, s)
|
|
}
|
|
|
|
for _, c := range m.Clients {
|
|
u.switchExport(r, c)
|
|
}
|
|
|
|
for _, d := range m.Devices {
|
|
u.switchExport(r, d)
|
|
}
|
|
|
|
for _, st := range m.SpeedTests {
|
|
u.switchExport(r, st)
|
|
}
|
|
|
|
appTotal := make(totalsDPImap)
|
|
catTotal := make(totalsDPImap)
|
|
|
|
for _, c := range m.ClientsDPI {
|
|
u.exportClientDPI(r, c, appTotal, catTotal)
|
|
}
|
|
|
|
for _, ct := range m.CountryTraffic {
|
|
u.exportCountryTraffic(r, ct)
|
|
}
|
|
|
|
u.exportClientDPItotals(r, appTotal, catTotal)
|
|
}
|
|
|
|
func (u *promUnifi) switchExport(r report, v any) {
|
|
switch v := v.(type) {
|
|
case *unifi.RogueAP:
|
|
// r.addRogueAP()
|
|
u.exportRogueAP(r, v)
|
|
case *unifi.UAP:
|
|
r.addUAP()
|
|
u.exportUAP(r, v)
|
|
case *unifi.USW:
|
|
r.addUSW()
|
|
u.exportUSW(r, v)
|
|
case *unifi.PDU:
|
|
r.addPDU()
|
|
u.exportPDU(r, v)
|
|
case *unifi.USG:
|
|
r.addUSG()
|
|
u.exportUSG(r, v)
|
|
case *unifi.UXG:
|
|
r.addUXG()
|
|
u.exportUXG(r, v)
|
|
case *unifi.UBB:
|
|
r.addUBB()
|
|
u.exportUBB(r, v)
|
|
case *unifi.UCI:
|
|
r.addUCI()
|
|
u.exportUCI(r, v)
|
|
case *unifi.UDM:
|
|
r.addUDM()
|
|
u.exportUDM(r, v)
|
|
case *unifi.Site:
|
|
u.exportSite(r, v)
|
|
case *unifi.Client:
|
|
u.exportClient(r, v)
|
|
case *unifi.SpeedTestResult:
|
|
u.exportSpeedTest(r, v)
|
|
case *unifi.UsageByCountry:
|
|
u.exportCountryTraffic(r, v)
|
|
default:
|
|
if u.Collector.Poller().LogUnknownTypes {
|
|
u.LogDebugf("unknown type: %T", v)
|
|
}
|
|
}
|
|
}
|