// Package promunifi provides the bridge between unpoller metrics and prometheus. package promunifi import ( "fmt" "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" "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 Site *site RogueAP *rogueap // 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 } // 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 interface{} 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. USG int // Total count of USG devices. USW int // Total count of USW devices. UAP int // Total count of UAP devices. UDM int // Total count of UDM devices. UXG int // Total count of UXG 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, Method: u.Run, }) } // 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 { if u.Collector = c; u.Config == nil || u.Disable { u.Logf("Prometheus config missing (or disabled), Prometheus HTTP listener disabled!") return nil } 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.Site = descSite(u.Namespace + "_site_") u.RogueAP = descRogueAP(u.Namespace + "_rogueap_") 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(promver.NewCollector(u.Namespace)) 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, r *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 []interface{}{u.Client, u.Device, u.UAP, u.USG, u.USW, u.Site} { 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.Config.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)) 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) } appTotal := make(totalsDPImap) catTotal := make(totalsDPImap) for _, c := range m.ClientsDPI { u.exportClientDPI(r, c, appTotal, catTotal) } u.exportClientDPItotals(r, appTotal, catTotal) } func (u *promUnifi) switchExport(r report, v interface{}) { 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.USG: r.addUSG() u.exportUSG(r, v) case *unifi.UXG: r.addUXG() u.exportUXG(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) default: u.LogErrorf("invalid type: %T", v) } }