Merge branch 'master' of ../inputunifi into merge-them-all

This commit is contained in:
Cody Lee 2022-11-23 21:09:02 -06:00
commit deb94ac251
No known key found for this signature in database
10 changed files with 1283 additions and 0 deletions

View File

@ -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 ./...

View File

@ -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.

View File

@ -0,0 +1,3 @@
# inputunifi
## UnPoller Input Plugin

View File

@ -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
}

View File

@ -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
}

View File

@ -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
)

View File

@ -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=

View File

@ -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
}

View File

@ -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
}

View File

@ -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...)
}