diff --git a/integrations/inputunifi/.travis.yml b/integrations/inputunifi/.travis.yml new file mode 100644 index 00000000..006b6d64 --- /dev/null +++ b/integrations/inputunifi/.travis.yml @@ -0,0 +1,9 @@ +language: go +go: +- 1.16.x +before_install: + # download super-linter: golangci-lint +- curl -sL https://install.goreleaser.com/github.com/golangci/golangci-lint.sh | sh -s -- -b $(go env GOPATH)/bin latest +script: +- golangci-lint run --enable-all -D nlreturn,exhaustivestruct +- go test ./... diff --git a/integrations/inputunifi/LICENSE b/integrations/inputunifi/LICENSE new file mode 100644 index 00000000..870eeecd --- /dev/null +++ b/integrations/inputunifi/LICENSE @@ -0,0 +1,21 @@ +MIT LICENSE. +Copyright (c) 2018-2021 David Newhall II + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/integrations/inputunifi/README.md b/integrations/inputunifi/README.md new file mode 100644 index 00000000..10a6daae --- /dev/null +++ b/integrations/inputunifi/README.md @@ -0,0 +1,3 @@ +# inputunifi + +## UnPoller Input Plugin diff --git a/integrations/inputunifi/collectevents.go b/integrations/inputunifi/collectevents.go new file mode 100644 index 00000000..21decd56 --- /dev/null +++ b/integrations/inputunifi/collectevents.go @@ -0,0 +1,158 @@ +package inputunifi + +import ( + "fmt" + "time" + + "github.com/unpoller/unifi" + "github.com/unpoller/webserver" +) + +/* Event collection. Events are also sent to the webserver for display. */ + +func (u *InputUnifi) collectControllerEvents(c *Controller) ([]interface{}, error) { + if u.isNill(c) { + u.Logf("Re-authenticating to UniFi Controller: %s", c.URL) + + if err := u.getUnifi(c); err != nil { + return nil, fmt.Errorf("re-authenticating to %s: %w", c.URL, err) + } + } + + var ( + logs = []interface{}{} + newLogs []interface{} + ) + + // Get the sites we care about. + sites, err := u.getFilteredSites(c) + if err != nil { + return nil, fmt.Errorf("unifi.GetSites(): %w", err) + } + + type caller func([]interface{}, []*unifi.Site, *Controller) ([]interface{}, error) + + for _, call := range []caller{u.collectIDS, u.collectAnomalies, u.collectAlarms, u.collectEvents} { + if newLogs, err = call(logs, sites, c); err != nil { + return logs, err + } + + logs = append(logs, newLogs...) + } + + return logs, nil +} + +func (u *InputUnifi) collectAlarms(logs []interface{}, sites []*unifi.Site, c *Controller) ([]interface{}, error) { + if *c.SaveAlarms { + for _, s := range sites { + events, err := c.Unifi.GetAlarmsSite(s) + if err != nil { + return logs, fmt.Errorf("unifi.GetAlarms(): %w", err) + } + + for _, e := range events { + logs = append(logs, e) + + webserver.NewInputEvent(PluginName, s.ID+"_alarms", &webserver.Event{ + Ts: e.Datetime, Msg: e.Msg, Tags: map[string]string{ + "type": "alarm", "key": e.Key, "site_id": e.SiteID, + "site_name": e.SiteName, "source": e.SourceName, + }, + }) + } + } + } + + return logs, nil +} + +func (u *InputUnifi) collectAnomalies(logs []interface{}, sites []*unifi.Site, c *Controller) ([]interface{}, error) { + if *c.SaveAnomal { + for _, s := range sites { + events, err := c.Unifi.GetAnomaliesSite(s) + if err != nil { + return logs, fmt.Errorf("unifi.GetAnomalies(): %w", err) + } + + for _, e := range events { + logs = append(logs, e) + + webserver.NewInputEvent(PluginName, s.ID+"_anomalies", &webserver.Event{ + Ts: e.Datetime, Msg: e.Anomaly, Tags: map[string]string{ + "type": "anomaly", "site_name": e.SiteName, "source": e.SourceName, + }, + }) + } + } + } + + return logs, nil +} + +func (u *InputUnifi) collectEvents(logs []interface{}, sites []*unifi.Site, c *Controller) ([]interface{}, error) { + if *c.SaveEvents { + for _, s := range sites { + events, err := c.Unifi.GetSiteEvents(s, time.Hour) + if err != nil { + return logs, fmt.Errorf("unifi.GetEvents(): %w", err) + } + + for _, e := range events { + e := redactEvent(e, c.HashPII) + logs = append(logs, e) + + webserver.NewInputEvent(PluginName, s.ID+"_events", &webserver.Event{ + Msg: e.Msg, Ts: e.Datetime, Tags: map[string]string{ + "type": "event", "key": e.Key, "site_id": e.SiteID, + "site_name": e.SiteName, "source": e.SourceName, + }, + }) + } + } + } + + return logs, nil +} + +func (u *InputUnifi) collectIDS(logs []interface{}, sites []*unifi.Site, c *Controller) ([]interface{}, error) { + if *c.SaveIDS { + for _, s := range sites { + events, err := c.Unifi.GetIDSSite(s) + if err != nil { + return logs, fmt.Errorf("unifi.GetIDS(): %w", err) + } + + for _, e := range events { + logs = append(logs, e) + + webserver.NewInputEvent(PluginName, s.ID+"_ids", &webserver.Event{ + Ts: e.Datetime, Msg: e.Msg, Tags: map[string]string{ + "type": "ids", "key": e.Key, "site_id": e.SiteID, + "site_name": e.SiteName, "source": e.SourceName, + }, + }) + } + } + } + + return logs, nil +} + +// redactEvent attempts to mask personally identying information from log messages. +// This currently misses the "msg" value entirely and leaks PII information. +func redactEvent(e *unifi.Event, hash *bool) *unifi.Event { + if !*hash { + return e + } + + // metrics.Events[i].Msg <-- not sure what to do here. + e.DestIPGeo = unifi.IPGeo{} + e.SourceIPGeo = unifi.IPGeo{} + e.Host = RedactNamePII(e.Host, hash) + e.Hostname = RedactNamePII(e.Hostname, hash) + e.DstMAC = RedactMacPII(e.DstMAC, hash) + e.SrcMAC = RedactMacPII(e.SrcMAC, hash) + + return e +} diff --git a/integrations/inputunifi/collector.go b/integrations/inputunifi/collector.go new file mode 100644 index 00000000..6c46959f --- /dev/null +++ b/integrations/inputunifi/collector.go @@ -0,0 +1,269 @@ +package inputunifi + +// nolint: gosec +import ( + "crypto/md5" + "fmt" + "strings" + "time" + + "github.com/unpoller/poller" + "github.com/unpoller/unifi" +) + +var ErrScrapeFilterMatchFailed = fmt.Errorf("scrape filter match failed, and filter is not http URL") + +func (u *InputUnifi) isNill(c *Controller) bool { + u.RLock() + defer u.RUnlock() + + return c.Unifi == nil +} + +// newDynamicCntrlr creates and saves a controller definition for further use. +// This is called when an unconfigured controller is requested. +func (u *InputUnifi) newDynamicCntrlr(url string) (bool, *Controller) { + u.Lock() + defer u.Unlock() + + if c := u.dynamic[url]; c != nil { + // it already exists. + return false, c + } + + ccopy := u.Default // copy defaults into new controller + u.dynamic[url] = &ccopy + u.dynamic[url].URL = url + + return true, u.dynamic[url] +} + +func (u *InputUnifi) dynamicController(filter *poller.Filter) (*poller.Metrics, error) { + if !strings.HasPrefix(filter.Path, "http") { + return nil, ErrScrapeFilterMatchFailed + } + + newCntrlr, c := u.newDynamicCntrlr(filter.Path) + + if newCntrlr { + u.Logf("Authenticating to Dynamic UniFi Controller: %s", filter.Path) + + if err := u.getUnifi(c); err != nil { + u.logController(c) + return nil, fmt.Errorf("authenticating to %s: %w", filter.Path, err) + } + + u.logController(c) + } + + return u.collectController(c) +} + +func (u *InputUnifi) collectController(c *Controller) (*poller.Metrics, error) { + if u.isNill(c) { + u.Logf("Re-authenticating to UniFi Controller: %s", c.URL) + + if err := u.getUnifi(c); err != nil { + return nil, fmt.Errorf("re-authenticating to %s: %w", c.URL, err) + } + } + + metrics, err := u.pollController(c) + if err != nil { + u.Logf("Re-authenticating to UniFi Controller: %s", c.URL) + + if err := u.getUnifi(c); err != nil { + return metrics, fmt.Errorf("re-authenticating to %s: %w", c.URL, err) + } + } + + return metrics, err +} + +//nolint:cyclop +func (u *InputUnifi) pollController(c *Controller) (*poller.Metrics, error) { + u.RLock() + defer u.RUnlock() + + // Get the sites we care about. + sites, err := u.getFilteredSites(c) + if err != nil { + return nil, fmt.Errorf("unifi.GetSites(): %w", err) + } + + m := &Metrics{TS: time.Now(), Sites: sites} + defer updateWeb(c, m) + + if c.SaveRogue != nil && *c.SaveRogue { + if m.RogueAPs, err = c.Unifi.GetRogueAPs(sites); err != nil { + return nil, fmt.Errorf("unifi.GetRogueAPs(%s): %w", c.URL, err) + } + } + + if c.SaveDPI != nil && *c.SaveDPI { + if m.SitesDPI, err = c.Unifi.GetSiteDPI(sites); err != nil { + return nil, fmt.Errorf("unifi.GetSiteDPI(%s): %w", c.URL, err) + } + + if m.ClientsDPI, err = c.Unifi.GetClientsDPI(sites); err != nil { + return nil, fmt.Errorf("unifi.GetClientsDPI(%s): %w", c.URL, err) + } + } + + // Get all the points. + if m.Clients, err = c.Unifi.GetClients(sites); err != nil { + return nil, fmt.Errorf("unifi.GetClients(%s): %w", c.URL, err) + } + + if m.Devices, err = c.Unifi.GetDevices(sites); err != nil { + return nil, fmt.Errorf("unifi.GetDevices(%s): %w", c.URL, err) + } + + return u.augmentMetrics(c, m), nil +} + +// augmentMetrics is our middleware layer between collecting metrics and writing them. +// This is where we can manipuate the returned data or make arbitrary decisions. +// This method currently adds parent device names to client metrics and hashes PII. +// This method also converts our local *Metrics type into a slice of interfaces for poller. +func (u *InputUnifi) augmentMetrics(c *Controller, metrics *Metrics) *poller.Metrics { + if metrics == nil { + return nil + } + + m, devices, bssdIDs := extractDevices(metrics) + + // These come blank, so set them here. + for _, client := range metrics.Clients { + if devices[client.Mac] = client.Name; client.Name == "" { + devices[client.Mac] = client.Hostname + } + + client.Mac = RedactMacPII(client.Mac, c.HashPII) + client.Name = RedactNamePII(client.Name, c.HashPII) + client.Hostname = RedactNamePII(client.Hostname, c.HashPII) + client.SwName = devices[client.SwMac] + client.ApName = devices[client.ApMac] + client.GwName = devices[client.GwMac] + client.RadioDescription = bssdIDs[client.Bssid] + client.RadioProto + m.Clients = append(m.Clients, client) + } + + for _, client := range metrics.ClientsDPI { + // Name on Client DPI data also comes blank, find it based on MAC address. + client.Name = devices[client.MAC] + if client.Name == "" { + client.Name = client.MAC + } + + client.Name = RedactNamePII(client.Name, c.HashPII) + client.MAC = RedactMacPII(client.MAC, c.HashPII) + m.ClientsDPI = append(m.ClientsDPI, client) + } + + for _, ap := range metrics.RogueAPs { + // XXX: do we need augment this data? + m.RogueAPs = append(m.RogueAPs, ap) + } + + if *c.SaveSites { + for _, site := range metrics.Sites { + m.Sites = append(m.Sites, site) + } + + for _, site := range metrics.SitesDPI { + m.SitesDPI = append(m.SitesDPI, site) + } + } + + return m +} + +// this is a helper function for augmentMetrics. +func extractDevices(metrics *Metrics) (*poller.Metrics, map[string]string, map[string]string) { + m := &poller.Metrics{TS: metrics.TS} + devices := make(map[string]string) + bssdIDs := make(map[string]string) + + for _, r := range metrics.Devices.UAPs { + devices[r.Mac] = r.Name + m.Devices = append(m.Devices, r) + + for _, v := range r.VapTable { + bssdIDs[v.Bssid] = fmt.Sprintf("%s %s %s:", r.Name, v.Radio, v.RadioName) + } + } + + for _, r := range metrics.Devices.USGs { + devices[r.Mac] = r.Name + m.Devices = append(m.Devices, r) + } + + for _, r := range metrics.Devices.USWs { + devices[r.Mac] = r.Name + m.Devices = append(m.Devices, r) + } + + for _, r := range metrics.Devices.UDMs { + devices[r.Mac] = r.Name + m.Devices = append(m.Devices, r) + } + + for _, r := range metrics.Devices.UXGs { + devices[r.Mac] = r.Name + m.Devices = append(m.Devices, r) + } + + return m, devices, bssdIDs +} + +// RedactNamePII converts a name string to an md5 hash (first 24 chars only). +// Useful for maskiing out personally identifying information. +func RedactNamePII(pii string, hash *bool) string { + if hash == nil || !*hash || pii == "" { + return pii + } + + s := fmt.Sprintf("%x", md5.Sum([]byte(pii))) // nolint: gosec + // instead of 32 characters, only use 24. + return s[:24] +} + +// RedactMacPII converts a MAC address to an md5 hashed version (first 14 chars only). +// Useful for maskiing out personally identifying information. +func RedactMacPII(pii string, hash *bool) (output string) { + if hash == nil || !*hash || pii == "" { + return pii + } + + s := fmt.Sprintf("%x", md5.Sum([]byte(pii))) // nolint: gosec + // This formats a "fake" mac address looking string. + return fmt.Sprintf("%s:%s:%s:%s:%s:%s:%s", s[:2], s[2:4], s[4:6], s[6:8], s[8:10], s[10:12], s[12:14]) +} + +// getFilteredSites returns a list of sites to fetch data for. +// Omits requested but unconfigured sites. Grabs the full list from the +// controller and returns the sites provided in the config file. +func (u *InputUnifi) getFilteredSites(c *Controller) ([]*unifi.Site, error) { + u.RLock() + defer u.RUnlock() + + sites, err := c.Unifi.GetSites() + if err != nil { + return nil, fmt.Errorf("controller: %w", err) + } else if len(c.Sites) == 0 || StringInSlice("all", c.Sites) { + return sites, nil + } + + i := 0 + + for _, s := range sites { + // Only include valid sites in the request filter. + if StringInSlice(s.Name, c.Sites) { + sites[i] = s + i++ + } + } + + return sites[:i], nil +} diff --git a/integrations/inputunifi/go.mod b/integrations/inputunifi/go.mod new file mode 100644 index 00000000..5fab1e28 --- /dev/null +++ b/integrations/inputunifi/go.mod @@ -0,0 +1,10 @@ +module github.com/unpoller/inputunifi + +go 1.16 + +require ( + github.com/unpoller/poller v0.0.0-20210623101401-f12841d79a28 + github.com/unpoller/unifi v0.0.9-0.20210623100314-3dccfdbc4c80 + github.com/unpoller/webserver v0.0.0-20210623101543-90d89bb0acdf + golang.org/x/net v0.0.0-20210614182718-04defd469f4e // indirect +) diff --git a/integrations/inputunifi/go.sum b/integrations/inputunifi/go.sum new file mode 100644 index 00000000..3d616959 --- /dev/null +++ b/integrations/inputunifi/go.sum @@ -0,0 +1,58 @@ +github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= +github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/spf13/pflag v1.0.6-0.20201009195203-85dd5c8bc61c h1:zqmyTlQyufRC65JnImJ6H1Sf7BDj8bG31EV919NVEQc= +github.com/spf13/pflag v1.0.6-0.20201009195203-85dd5c8bc61c/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/unpoller/poller v0.0.0-20210623101401-f12841d79a28 h1:YAv5naMdpOFahnxteFFRidZlrSEwLv8V2nBKJKmLmHg= +github.com/unpoller/poller v0.0.0-20210623101401-f12841d79a28/go.mod h1:AbDp60t5WlLSRELAliMJ0RFQpm/0yXpyolVSZqNtero= +github.com/unpoller/unifi v0.0.9-0.20210623100314-3dccfdbc4c80 h1:XjHGfJhMwnB63DYHgtWGJgDxLhxVcAOtf+cfuvpGoyo= +github.com/unpoller/unifi v0.0.9-0.20210623100314-3dccfdbc4c80/go.mod h1:K9QFFGfZws4gzB+Popix19S/rBKqrtqI+tyPORyg3F0= +github.com/unpoller/webserver v0.0.0-20210623101543-90d89bb0acdf h1:HhXi3qca3kyFEFPh0mqdr0bpQs94hJvMbUJztwPtf2A= +github.com/unpoller/webserver v0.0.0-20210623101543-90d89bb0acdf/go.mod h1:77PywuUvspdtoRuH1htFhR3Tp0pLyWj6kJlYR4tBYho= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210415154028-4f45737414dc/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= +golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e h1:gsTQYXdTw2Gq7RBsWvlQ91b+aEQ6bXFUngBGuR8sPpI= +golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210415231046-e915ea6b2b7d/go.mod h1:9tjilg8BloeKEkVJvy7fQ90B1CfIiPueXVOjqfkSzI8= +golang.org/x/net v0.0.0-20210614182718-04defd469f4e h1:XpT3nA5TvE525Ne3hInMh6+GETgn27Zfm9dxsThnX2Q= +golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210616094352-59db8d763f22 h1:RqytpXGR1iVNX7psjB3ff8y7sNFinVFvkx1c8SjBkio= +golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210406210042-72f3dc4e9b72/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b h1:9zKuko04nR4gjZ4+DNjHqRlAJqbJETHwiNKDqTfOjfE= +golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golift.io/cnfg v0.0.7 h1:qkNpP5Bq+5Gtoc6HcI8kapMD5zFOVan6qguxqBQF3OY= +golift.io/cnfg v0.0.7/go.mod h1:AsB0DJe7nv0bizKaoy3e3MjjOF7upTpMOMvsfv4CNNk= +golift.io/version v0.0.2 h1:i0gXRuSDHKs4O0sVDUg4+vNIuOxYoXhaxspftu2FRTE= +golift.io/version v0.0.2/go.mod h1:76aHNz8/Pm7CbuxIsDi97jABL5Zui3f2uZxDm4vB6hU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/integrations/inputunifi/input.go b/integrations/inputunifi/input.go new file mode 100644 index 00000000..7651c832 --- /dev/null +++ b/integrations/inputunifi/input.go @@ -0,0 +1,339 @@ +// Package inputunifi implements the poller.Input interface and bridges the gap between +// metrics from the unifi library, and the augments required to pump them into unifi-poller. +package inputunifi + +import ( + "fmt" + "io/ioutil" + "strings" + "sync" + "time" + + "github.com/unpoller/poller" + "github.com/unpoller/unifi" +) + +// PluginName is the name of this input plugin. +const PluginName = "unifi" + +const ( + defaultURL = "https://127.0.0.1:8443" + defaultUser = "unifipoller" + defaultPass = "unifipoller" + defaultSite = "all" +) + +// InputUnifi contains the running data. +type InputUnifi struct { + *Config `json:"unifi" toml:"unifi" xml:"unifi" yaml:"unifi"` + dynamic map[string]*Controller + sync.Mutex // to lock the map above. + Logger poller.Logger +} + +// Controller represents the configuration for a UniFi Controller. +// Each polled controller may have its own configuration. +type Controller struct { + VerifySSL *bool `json:"verify_ssl" toml:"verify_ssl" xml:"verify_ssl" yaml:"verify_ssl"` + SaveAnomal *bool `json:"save_anomalies" toml:"save_anomalies" xml:"save_anomalies" yaml:"save_anomalies"` + SaveAlarms *bool `json:"save_alarms" toml:"save_alarms" xml:"save_alarms" yaml:"save_alarms"` + SaveEvents *bool `json:"save_events" toml:"save_events" xml:"save_events" yaml:"save_events"` + SaveIDS *bool `json:"save_ids" toml:"save_ids" xml:"save_ids" yaml:"save_ids"` + SaveDPI *bool `json:"save_dpi" toml:"save_dpi" xml:"save_dpi" yaml:"save_dpi"` + SaveRogue *bool `json:"save_rogue" toml:"save_rogue" xml:"save_rogue" yaml:"save_rogue"` + HashPII *bool `json:"hash_pii" toml:"hash_pii" xml:"hash_pii" yaml:"hash_pii"` + SaveSites *bool `json:"save_sites" toml:"save_sites" xml:"save_sites" yaml:"save_sites"` + CertPaths []string `json:"ssl_cert_paths" toml:"ssl_cert_paths" xml:"ssl_cert_path" yaml:"ssl_cert_paths"` + User string `json:"user" toml:"user" xml:"user" yaml:"user"` + Pass string `json:"pass" toml:"pass" xml:"pass" yaml:"pass"` + URL string `json:"url" toml:"url" xml:"url" yaml:"url"` + Sites []string `json:"sites" toml:"sites" xml:"site" yaml:"sites"` + Unifi *unifi.Unifi `json:"-" toml:"-" xml:"-" yaml:"-"` + ID string `json:"id,omitempty"` // this is an output, not an input. +} + +// 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"` + 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 + RogueAPs []*unifi.RogueAP + Devices *unifi.Devices +} + +func init() { // nolint: gochecknoinits + u := &InputUnifi{ + dynamic: make(map[string]*Controller), + } + + poller.NewInput(&poller.InputPlugin{ + Name: PluginName, + Input: u, // this library implements poller.Input interface for Metrics(). + Config: u, // Defines our config data interface. + }) +} + +// getCerts reads in cert files from disk and stores them as a slice of of byte slices. +func (c *Controller) getCerts() ([][]byte, error) { + if len(c.CertPaths) == 0 { + return nil, nil + } + + b := make([][]byte, len(c.CertPaths)) + + for i, f := range c.CertPaths { + d, err := ioutil.ReadFile(f) + if err != nil { + return nil, fmt.Errorf("reading SSL cert file: %w", err) + } + + b[i] = d + } + + return b, nil +} + +// getUnifi (re-)authenticates to a unifi controller. +// If certificate files are provided, they are re-read. +func (u *InputUnifi) getUnifi(c *Controller) error { + u.Lock() + defer u.Unlock() + + if c.Unifi != nil { + c.Unifi.CloseIdleConnections() + } + + certs, err := c.getCerts() + if err != nil { + return err + } + + // Create an authenticated session to the Unifi Controller. + c.Unifi, err = unifi.NewUnifi(&unifi.Config{ + User: c.User, + Pass: c.Pass, + URL: c.URL, + SSLCert: certs, + VerifySSL: *c.VerifySSL, + ErrorLog: u.LogErrorf, // Log all errors. + DebugLog: u.LogDebugf, // Log debug messages. + }) + if err != nil { + c.Unifi = nil + return fmt.Errorf("unifi controller: %w", err) + } + + u.LogDebugf("Authenticated with controller successfully, %s", c.URL) + + return nil +} + +// checkSites makes sure the list of provided sites exists on the controller. +// This only runs once during initialization. +func (u *InputUnifi) checkSites(c *Controller) error { + u.RLock() + defer u.RUnlock() + + if len(c.Sites) == 0 || c.Sites[0] == "" { + c.Sites = []string{"all"} + } + + u.LogDebugf("Checking Controller Sites List") + + sites, err := c.Unifi.GetSites() + if err != nil { + return fmt.Errorf("controller: %w", err) + } + + msg := []string{} + for _, site := range sites { + msg = append(msg, site.Name+" ("+site.Desc+")") + } + + u.Logf("Found %d site(s) on controller %s: %v", len(msg), c.URL, strings.Join(msg, ", ")) + + if StringInSlice("all", c.Sites) { + c.Sites = []string{"all"} + return nil + } + + keep := []string{} + +FIRST: + for _, s := range c.Sites { + for _, site := range sites { + if s == site.Name { + keep = append(keep, s) + continue FIRST + } + } + u.LogErrorf("Configured site not found on controller %s: %v", c.URL, s) + } + + if c.Sites = keep; len(keep) == 0 { + c.Sites = []string{"all"} + } + + return nil +} + +func (u *InputUnifi) getPassFromFile(filename string) string { + b, err := ioutil.ReadFile(filename) + if err != nil { + u.LogErrorf("Reading UniFi Password File: %v", err) + } + + return strings.TrimSpace(string(b)) +} + +// setDefaults sets the default defaults. +func (u *InputUnifi) setDefaults(c *Controller) { //nolint:cyclop + t := true + f := false + + // Default defaults. + if c.SaveSites == nil { + c.SaveSites = &t + } + + if c.VerifySSL == nil { + c.VerifySSL = &f + } + + if c.HashPII == nil { + c.HashPII = &f + } + + if c.SaveDPI == nil { + c.SaveDPI = &f + } + + if c.SaveRogue == nil { + c.SaveRogue = &f + } + + if c.SaveIDS == nil { + c.SaveIDS = &f + } + + if c.SaveEvents == nil { + c.SaveEvents = &f + } + + if c.SaveAlarms == nil { + c.SaveAlarms = &f + } + + if c.SaveAnomal == nil { + c.SaveAnomal = &f + } + + if c.URL == "" { + c.URL = defaultURL + } + + if strings.HasPrefix(c.Pass, "file://") { + c.Pass = u.getPassFromFile(strings.TrimPrefix(c.Pass, "file://")) + } + + if c.Pass == "" { + c.Pass = defaultPass + } + + if c.User == "" { + c.User = defaultUser + } + + if len(c.Sites) == 0 { + c.Sites = []string{defaultSite} + } +} + +// setControllerDefaults sets defaults for the for controllers. +// Any missing values come from defaults (above). +func (u *InputUnifi) setControllerDefaults(c *Controller) *Controller { //nolint:cyclop,funlen + // Configured controller defaults. + if c.SaveSites == nil { + c.SaveSites = u.Default.SaveSites + } + + if c.VerifySSL == nil { + c.VerifySSL = u.Default.VerifySSL + } + + if c.CertPaths == nil { + c.CertPaths = u.Default.CertPaths + } + + if c.HashPII == nil { + c.HashPII = u.Default.HashPII + } + + if c.SaveDPI == nil { + c.SaveDPI = u.Default.SaveDPI + } + + if c.SaveIDS == nil { + c.SaveIDS = u.Default.SaveIDS + } + + if c.SaveRogue == nil { + c.SaveRogue = u.Default.SaveRogue + } + + if c.SaveEvents == nil { + c.SaveEvents = u.Default.SaveEvents + } + + if c.SaveAlarms == nil { + c.SaveAlarms = u.Default.SaveAlarms + } + + if c.SaveAnomal == nil { + c.SaveAnomal = u.Default.SaveAnomal + } + + if c.URL == "" { + c.URL = u.Default.URL + } + + if strings.HasPrefix(c.Pass, "file://") { + c.Pass = u.getPassFromFile(strings.TrimPrefix(c.Pass, "file://")) + } + + if c.Pass == "" { + c.Pass = u.Default.Pass + } + + if c.User == "" { + c.User = u.Default.User + } + + if len(c.Sites) == 0 { + c.Sites = u.Default.Sites + } + + return c +} + +// StringInSlice returns true if a string is in a slice. +func StringInSlice(str string, slice []string) bool { + for _, s := range slice { + if strings.EqualFold(s, str) { + return true + } + } + + return false +} diff --git a/integrations/inputunifi/interface.go b/integrations/inputunifi/interface.go new file mode 100644 index 00000000..9002204f --- /dev/null +++ b/integrations/inputunifi/interface.go @@ -0,0 +1,202 @@ +package inputunifi + +/* This file contains the three poller.Input interface methods. */ + +import ( + "fmt" + "strings" + + "github.com/unpoller/poller" + "github.com/unpoller/unifi" + "github.com/unpoller/webserver" +) + +var ( + ErrDynamicLookupsDisabled = fmt.Errorf("filter path requested but dynamic lookups disabled") + ErrControllerNumNotFound = fmt.Errorf("controller number not found") + ErrNoFilterKindProvided = fmt.Errorf("must provide filter: devices, clients, other") +) + +// Initialize gets called one time when starting up. +// Satisfies poller.Input interface. +func (u *InputUnifi) Initialize(l poller.Logger) error { + if u.Config == nil { + u.Config = &Config{Disable: true} + } + + if u.Logger = l; u.Disable { + u.Logf("UniFi input plugin disabled or missing configuration!") + return nil + } + + if u.setDefaults(&u.Default); len(u.Controllers) == 0 && !u.Dynamic { + u.Controllers = []*Controller{&u.Default} + } + + if len(u.Controllers) == 0 { + u.Logf("No controllers configured. Polling dynamic controllers only! Defaults:") + u.logController(&u.Default) + } + + for i, c := range u.Controllers { + if err := u.getUnifi(u.setControllerDefaults(c)); err != nil { + u.LogErrorf("Controller %d of %d Auth or Connection Error, retrying: %v", i+1, len(u.Controllers), err) + continue + } + + if err := u.checkSites(c); err != nil { + u.LogErrorf("checking sites on %s: %v", c.URL, err) + } + + u.Logf("Configured UniFi Controller %d of %d:", i+1, len(u.Controllers)) + u.logController(c) + } + + webserver.UpdateInput(&webserver.Input{Name: PluginName, Config: formatConfig(u.Config)}) + + return nil +} + +func (u *InputUnifi) logController(c *Controller) { + u.Logf(" => URL: %s (verify SSL: %v)", c.URL, *c.VerifySSL) + + if len(c.CertPaths) > 0 { + u.Logf(" => Cert Files: %s", strings.Join(c.CertPaths, ", ")) + } + + if c.Unifi != nil { + u.Logf(" => Version: %s (%s)", c.Unifi.ServerVersion, c.Unifi.UUID) + } + + u.Logf(" => Username: %s (has password: %v)", c.User, c.Pass != "") + u.Logf(" => Hash PII / Poll Sites: %v / %s", *c.HashPII, strings.Join(c.Sites, ", ")) + u.Logf(" => Save Sites / Save DPI: %v / %v (metrics)", *c.SaveSites, *c.SaveDPI) + u.Logf(" => Save Events / Save IDS: %v / %v (logs)", *c.SaveEvents, *c.SaveIDS) + u.Logf(" => Save Alarms / Anomalies: %v / %v (logs)", *c.SaveAlarms, *c.SaveAnomal) + u.Logf(" => Save Rogue APs: %v", *c.SaveRogue) +} + +// Events allows you to pull only events (and IDS) from the UniFi Controller. +// This does not fully respect HashPII, but it may in the future! +// Use Filter.Path to pick a specific controller, otherwise poll them all! +func (u *InputUnifi) Events(filter *poller.Filter) (*poller.Events, error) { + if u.Disable { + return nil, nil + } + + logs := []interface{}{} + + if filter == nil { + filter = &poller.Filter{} + } + + for _, c := range u.Controllers { + if filter.Path != "" && !strings.EqualFold(c.URL, filter.Path) { + continue + } + + events, err := u.collectControllerEvents(c) + if err != nil { + return nil, err + } + + logs = append(logs, events...) + } + + return &poller.Events{Logs: logs}, nil +} + +// Metrics grabs all the measurements from a UniFi controller and returns them. +// Set Filter.Path to a controller URL for a specific controller (or get them all). +func (u *InputUnifi) Metrics(filter *poller.Filter) (*poller.Metrics, error) { + if u.Disable { + return nil, nil + } + + metrics := &poller.Metrics{} + + if filter == nil { + filter = &poller.Filter{} + } + + // Check if the request is for an existing, configured controller (or all controllers) + for _, c := range u.Controllers { + if filter.Path != "" && !strings.EqualFold(c.URL, filter.Path) { + // continue only if we have a filter path and it doesn't match. + continue + } + + m, err := u.collectController(c) + if err != nil { + return metrics, err + } + + metrics = poller.AppendMetrics(metrics, m) + } + + if filter.Path == "" || len(metrics.Clients) != 0 { + return metrics, nil + } + + if !u.Dynamic { + return nil, ErrDynamicLookupsDisabled + } + + // Attempt a dynamic metrics fetch from an unconfigured controller. + return u.dynamicController(filter) +} + +// RawMetrics returns API output from the first configured UniFi controller. +// Adjust filter.Unit to pull from a controller other than the first. +func (u *InputUnifi) RawMetrics(filter *poller.Filter) ([]byte, error) { + if l := len(u.Controllers); filter.Unit >= l { + return nil, fmt.Errorf("%d controller(s) configured, '%d': %w", l, filter.Unit, ErrControllerNumNotFound) + } + + c := u.Controllers[filter.Unit] + if u.isNill(c) { + u.Logf("Re-authenticating to UniFi Controller: %s", c.URL) + + if err := u.getUnifi(c); err != nil { + return nil, fmt.Errorf("re-authenticating to %s: %w", c.URL, err) + } + } + + if err := u.checkSites(c); err != nil { + return nil, err + } + + sites, err := u.getFilteredSites(c) + if err != nil { + return nil, err + } + + switch filter.Kind { + case "d", "device", "devices": + return u.getSitesJSON(c, unifi.APIDevicePath, sites) + case "client", "clients", "c": + return u.getSitesJSON(c, unifi.APIClientPath, sites) + case "other", "o": + return c.Unifi.GetJSON(filter.Path) + default: + return []byte{}, ErrNoFilterKindProvided + } +} + +func (u *InputUnifi) getSitesJSON(c *Controller, path string, sites []*unifi.Site) ([]byte, error) { + allJSON := []byte{} + + for _, s := range sites { + apiPath := fmt.Sprintf(path, s.Name) + u.LogDebugf("Returning Path '%s' for site: %s (%s):\n", apiPath, s.Desc, s.Name) + + body, err := c.Unifi.GetJSON(apiPath) + if err != nil { + return allJSON, fmt.Errorf("controller: %w", err) + } + + allJSON = append(allJSON, body...) + } + + return allJSON, nil +} diff --git a/integrations/inputunifi/updateweb.go b/integrations/inputunifi/updateweb.go new file mode 100644 index 00000000..15463fac --- /dev/null +++ b/integrations/inputunifi/updateweb.go @@ -0,0 +1,214 @@ +package inputunifi + +import ( + "fmt" + "strconv" + "time" + + "github.com/unpoller/unifi" + "github.com/unpoller/webserver" +) + +/* This code reformats our data to be displayed on the built-in web interface. */ + +func updateWeb(c *Controller, metrics *Metrics) { + webserver.UpdateInput(&webserver.Input{ + Name: PluginName, // Forgetting this leads to 3 hours of head scratching. + Sites: formatSites(c, metrics.Sites), + Clients: formatClients(c, metrics.Clients), + Devices: formatDevices(c, metrics.Devices), + }) +} + +func formatConfig(config *Config) *Config { + return &Config{ + Default: *formatControllers([]*Controller{&config.Default})[0], + Disable: config.Disable, + Dynamic: config.Dynamic, + Controllers: formatControllers(config.Controllers), + } +} + +func formatControllers(controllers []*Controller) []*Controller { + fixed := []*Controller{} + + for _, c := range controllers { + id := "" + if c.Unifi != nil { + id = c.Unifi.UUID + } + + fixed = append(fixed, &Controller{ + VerifySSL: c.VerifySSL, + SaveAnomal: c.SaveAnomal, + SaveAlarms: c.SaveAlarms, + SaveRogue: c.SaveRogue, + SaveEvents: c.SaveEvents, + SaveIDS: c.SaveIDS, + SaveDPI: c.SaveDPI, + HashPII: c.HashPII, + SaveSites: c.SaveSites, + User: c.User, + Pass: strconv.FormatBool(c.Pass != ""), + URL: c.URL, + Sites: c.Sites, + ID: id, + }) + } + + return fixed +} + +func formatSites(c *Controller, sites []*unifi.Site) (s webserver.Sites) { + for _, site := range sites { + s = append(s, &webserver.Site{ + ID: site.ID, + Name: site.Name, + Desc: site.Desc, + Source: site.SourceName, + Controller: c.Unifi.UUID, + }) + } + + return s +} + +func formatClients(c *Controller, clients []*unifi.Client) (d webserver.Clients) { + for _, client := range clients { + clientType, deviceMAC := "unknown", "unknown" + if client.ApMac != "" { + clientType = "wireless" + deviceMAC = client.ApMac + } else if client.SwMac != "" { + clientType = "wired" + deviceMAC = client.SwMac + } + + if deviceMAC == "" { + deviceMAC = client.GwMac + } + + d = append(d, &webserver.Client{ + Name: client.Name, + SiteID: client.SiteID, + Source: client.SourceName, + Controller: c.Unifi.UUID, + MAC: client.Mac, + IP: client.IP, + Type: clientType, + DeviceMAC: deviceMAC, + Rx: client.RxBytes, + Tx: client.TxBytes, + Since: time.Unix(client.FirstSeen, 0), + Last: time.Unix(client.LastSeen, 0), + }) + } + + return d +} + +func formatDevices(c *Controller, devices *unifi.Devices) (d webserver.Devices) { // nolint: funlen + if devices == nil { + return d + } + + for _, device := range devices.UAPs { + d = append(d, &webserver.Device{ + Name: device.Name, + SiteID: device.SiteID, + Source: device.SourceName, + Controller: c.Unifi.UUID, + MAC: device.Mac, + IP: device.IP, + Type: device.Type, + Model: device.Model, + Version: device.Version, + Uptime: int(device.Uptime.Val), + Clients: int(device.NumSta.Val), + Config: nil, + }) + } + + for _, device := range devices.UDMs { + d = append(d, &webserver.Device{ + Name: device.Name, + SiteID: device.SiteID, + Source: device.SourceName, + Controller: c.Unifi.UUID, + MAC: device.Mac, + IP: device.IP, + Type: device.Type, + Model: device.Model, + Version: device.Version, + Uptime: int(device.Uptime.Val), + Clients: int(device.NumSta.Val), + Config: nil, + }) + } + + for _, device := range devices.USWs { + d = append(d, &webserver.Device{ + Name: device.Name, + SiteID: device.SiteID, + Source: device.SourceName, + Controller: c.Unifi.UUID, + MAC: device.Mac, + IP: device.IP, + Type: device.Type, + Model: device.Model, + Version: device.Version, + Uptime: int(device.Uptime.Val), + Clients: int(device.NumSta.Val), + Config: nil, + }) + } + + for _, device := range devices.USGs { + d = append(d, &webserver.Device{ + Name: device.Name, + SiteID: device.SiteID, + Source: device.SourceName, + Controller: c.Unifi.UUID, + MAC: device.Mac, + IP: device.IP, + Type: device.Type, + Model: device.Model, + Version: device.Version, + Uptime: int(device.Uptime.Val), + Clients: int(device.NumSta.Val), + Config: nil, + }) + } + + return d +} + +// Logf logs a message. +func (u *InputUnifi) Logf(msg string, v ...interface{}) { + webserver.NewInputEvent(PluginName, PluginName, &webserver.Event{ + Ts: time.Now(), + Msg: fmt.Sprintf(msg, v...), + Tags: map[string]string{"type": "info"}, + }) + u.Logger.Logf(msg, v...) +} + +// LogErrorf logs an error message. +func (u *InputUnifi) LogErrorf(msg string, v ...interface{}) { + webserver.NewInputEvent(PluginName, PluginName, &webserver.Event{ + Ts: time.Now(), + Msg: fmt.Sprintf(msg, v...), + Tags: map[string]string{"type": "error"}, + }) + u.Logger.LogErrorf(msg, v...) +} + +// LogDebugf logs a debug message. +func (u *InputUnifi) LogDebugf(msg string, v ...interface{}) { + webserver.NewInputEvent(PluginName, PluginName, &webserver.Event{ + Ts: time.Now(), + Msg: fmt.Sprintf(msg, v...), + Tags: map[string]string{"type": "debug"}, + }) + u.Logger.LogDebugf(msg, v...) +}