Files
Cody Lee 65ad210f20 feat: add UniFi Protect device metrics (closes #1015)
Collects Protect device data (sensors, cameras, lights, bridges, link
stations, NVR) via the official Integration API and exports it through
Prometheus, InfluxDB, and DataDog. Opt-in via save_protect_devices,
gated by protect_api_key.

Bumps github.com/unpoller/unifi to v6, which added the Protect API
client (breaking change: FlexInt/FlexBool/FlexFloat replace nullable
pointers).
2026-08-19 15:14:43 -05:00

164 lines
4.7 KiB
Go

// Package inputunas implements the poller.Input interface for UniFi UNAS Pro storage
// consoles. A UNAS Pro is a standalone UniFi OS console with no Network application: no
// sites, no /status endpoint, its own credentials and its own host. That is why it lives
// here rather than inside inputunifi, whose sites-then-devices flow has nothing to offer it.
//
// Endpoint discovery and the JSON shapes are derived from alexgreenbank's unaspoller
// (https://github.com/alexgreenbank/unaspoller, MIT).
package inputunas
import (
"fmt"
"os"
"sync"
"time"
"github.com/unpoller/unifi/v6"
"github.com/unpoller/unpoller/pkg/poller"
"golift.io/cnfg"
)
// PluginName is the name of this input plugin.
const PluginName = "unas"
const (
defaultUser = "unpoller"
defaultTimeout = 60 * time.Second
)
// InputUNAS contains the running data.
type InputUNAS struct {
*Config `json:"unas" toml:"unas" xml:"unas" yaml:"unas"`
Logger poller.Logger
}
// Device represents one UNAS Pro console to poll. Each may have its own credentials.
type Device struct {
URL string `json:"url" toml:"url" xml:"url" yaml:"url"`
User string `json:"user" toml:"user" xml:"user" yaml:"user"`
Pass string `json:"pass" toml:"pass" xml:"pass" yaml:"pass"`
VerifySSL *bool `json:"verify_ssl" toml:"verify_ssl" xml:"verify_ssl" yaml:"verify_ssl"`
CertPaths []string `json:"ssl_cert_paths" toml:"ssl_cert_paths" xml:"ssl_cert_path" yaml:"ssl_cert_paths"`
Timeout cnfg.Duration `json:"timeout" toml:"timeout" xml:"timeout" yaml:"timeout"`
unas *unifi.UNASClient `json:"-" toml:"-" xml:"-" yaml:"-"`
}
// Config contains our configuration data.
type Config struct {
sync.RWMutex // locks a Device's client while it re-authenticates.
Default Device `json:"defaults" toml:"defaults" xml:"default" yaml:"defaults"`
Enable bool `json:"enable" toml:"enable" xml:"enable,attr" yaml:"enable"`
Devices []*Device `json:"devices" toml:"device" xml:"device" yaml:"devices"`
}
func init() { // nolint: gochecknoinits
u := &InputUNAS{}
poller.NewInput(&poller.InputPlugin{
Name: PluginName,
Input: u, // this package implements poller.Input for Metrics().
Config: u, // defines our config data interface.
})
}
// setDefaults fills a device's unset fields from the defaults block, then from package
// defaults. It never invents a URL: an empty URL is how "not configured" is expressed, and
// synthesizing one (as inputunifi does with localhost:8443) would make the plugin poll a
// host the operator never named. Opt-in rests on Config.Enable; this is the second guard.
func (u *InputUNAS) setDefaults(d *Device) *Device {
if d.User == "" {
d.User = u.Default.User
}
if d.User == "" {
d.User = defaultUser
}
if d.Pass == "" {
d.Pass = u.Default.Pass
}
if len(d.CertPaths) == 0 {
d.CertPaths = u.Default.CertPaths
}
if d.Timeout.Duration == 0 {
d.Timeout = u.Default.Timeout
}
if d.Timeout.Duration == 0 {
d.Timeout.Duration = defaultTimeout
}
if d.VerifySSL == nil {
if u.Default.VerifySSL != nil {
d.VerifySSL = u.Default.VerifySSL
} else {
f := false
d.VerifySSL = &f
}
}
return d
}
// getCerts reads in cert files from disk and stores them as a slice of byte slices.
func (d *Device) getCerts() ([][]byte, error) {
if len(d.CertPaths) == 0 {
return nil, nil
}
b := make([][]byte, len(d.CertPaths))
for i, f := range d.CertPaths {
c, err := os.ReadFile(f)
if err != nil {
return nil, fmt.Errorf("reading SSL cert file: %w", err)
}
b[i] = c
}
return b, nil
}
// login (re-)authenticates to a UNAS console, replacing any existing client.
//
// NewUNASClient stops after Login: unlike NewUnifi it does not finish with GetServerData,
// because that requests /status, which path() rewrites to /proxy/network/status -- and a
// storage-only console has no Network application to answer it.
func (u *InputUNAS) login(d *Device) error {
u.Lock()
defer u.Unlock()
if d.unas != nil && d.unas.Unifi != nil {
d.unas.CloseIdleConnections()
}
certs, err := d.getCerts()
if err != nil {
return err
}
client, err := unifi.NewUNASClient(&unifi.Config{
User: d.User,
Pass: d.Pass,
URL: d.URL,
SSLCert: certs,
VerifySSL: *d.VerifySSL,
Timeout: d.Timeout.Duration,
ErrorLog: u.LogErrorf,
DebugLog: u.LogDebugf,
})
if err != nil {
d.unas = nil
return fmt.Errorf("unas console %s: %w", d.URL, err)
}
d.unas = client
u.LogDebugf("Authenticated with UNAS console successfully, %s", d.URL)
return nil
}