From d5dfc771d50958bfa78656a4a4654364faef399e Mon Sep 17 00:00:00 2001 From: Cody Lee Date: Wed, 19 Aug 2026 07:52:37 -0500 Subject: [PATCH] feat: add opt-in UNAS Pro support (closes #785) Adds a new `unas` input plugin that polls UNAS Pro storage consoles and exports console health, storage pools, disks and shares to Prometheus, InfluxDB and DataDog. UNAS is a separate plugin rather than a device type inside inputunifi because a storage-only console has no Network application: it cannot answer /status, has no sites, and shares none of the UniFi device schema. The plugin is opt-in and inert until an operator names a console. Opt-in is expressed as "no devices configured" rather than a `disable` flag, because a bool named `disable` zero-values to false and so cannot make a plugin default-off. Initialize returns silently on an empty device list and, unlike inputunifi, nothing synthesizes a default URL. Two behaviours are worth calling out for reviewers: - Metrics returns (metrics, nil) whenever any console was collected. poller.collectMetrics uses `if err != nil {} else if metric != nil`, so returning both would discard every healthy console because one failed. Only a total failure returns an error. - Re-auth fires on total failure, not on a 401. A mid-session 401 from GetData surfaces as ErrInvalidStatusCode, not ErrAuthenticationFailed, so there is no sentinel to match on. Session expiry fails all four endpoints at once, which is exactly the total-failure case. Prometheus metrics use the `unifi_unas_` prefix, which diverges from the `unas_` prefix used by the reference implementation; dashboards built against that will need query edits. Requires unifi/v5 v5.31.0 for the UNAS client and structs. Credit to alexgreenbank/unaspoller for mapping the endpoints. Co-Authored-By: Claude Opus 5 --- docs/plan-unas-support.md | 238 +++++++++++++++++++++++++ examples/up.conf.example | 32 ++++ examples/up.json.example | 20 +++ examples/up.yaml.example | 18 ++ go.mod | 6 +- go.sum | 7 + main.go | 1 + pkg/datadogunifi/datadog.go | 6 + pkg/datadogunifi/unas.go | 135 ++++++++++++++ pkg/influxunifi/influxdb.go | 6 + pkg/influxunifi/unas.go | 151 ++++++++++++++++ pkg/inputunas/README.md | 120 +++++++++++++ pkg/inputunas/config.go | 163 +++++++++++++++++ pkg/inputunas/input.go | 339 ++++++++++++++++++++++++++++++++++++ pkg/inputunas/input_test.go | 219 +++++++++++++++++++++++ pkg/poller/config.go | 2 + pkg/poller/inputs.go | 1 + pkg/promunifi/collector.go | 45 +++-- pkg/promunifi/unas.go | 209 ++++++++++++++++++++++ pkg/promunifi/unas_test.go | 103 +++++++++++ 20 files changed, 1800 insertions(+), 21 deletions(-) create mode 100644 docs/plan-unas-support.md create mode 100644 pkg/datadogunifi/unas.go create mode 100644 pkg/influxunifi/unas.go create mode 100644 pkg/inputunas/README.md create mode 100644 pkg/inputunas/config.go create mode 100644 pkg/inputunas/input.go create mode 100644 pkg/inputunas/input_test.go create mode 100644 pkg/promunifi/unas.go create mode 100644 pkg/promunifi/unas_test.go diff --git a/docs/plan-unas-support.md b/docs/plan-unas-support.md new file mode 100644 index 00000000..f8235d7b --- /dev/null +++ b/docs/plan-unas-support.md @@ -0,0 +1,238 @@ +# Plan: opt-in UNAS Pro support (issue #785) + +Reference implementation: https://github.com/alexgreenbank/unaspoller (MIT, David Newhall II +copyright header — same lineage as unpoller, so the code is cleanly upstreamable). Credit +alexgreenbank in the package header and README. + +## Design decision: separate input plugin, not a `save_unas` toggle + +UNAS Pro is a standalone UniFi OS console with **no Network application**: no sites, no +`/status`, its own credentials, its own host. `inputunifi`'s sites → devices → per-site flow +has nothing to offer it, and folding it in would mean bolting a second credential set onto +`Controller`. So: a new `pkg/inputunas` plugin with its own `[unas]` config block, disabled +by default and a no-op when unconfigured. + +### The one real blocker + +`unifi.NewUnifi()` is unusable against a UNAS target. It ends in `GetServerData()` → GET +`APIStatusPath` (`/status`), which `path()` rewrites to `/proxy/network/status` when +`u.new` is set. A storage-only console has no Network app to answer that, and unaspoller's +endpoint list never touches `/status`, so we have no evidence it responds. API-key auth is +out for the same reason — `Login()` uses `/status` as its login path when `APIKey != ""`. + +Fix by construction, not by testing against hardware we don't have: a login-only +constructor that stops after `Login()`. + +What already works in our favour: `path()` (types.go:234) deliberately passes anything +starting with `/proxy/` through untouched, so `GetData`/`GetJSON` work on `/proxy/drive/…` +with zero changes. UniFi OS login, the cookie jar, csrf capture +(`x-csrf-token`/`x-updated-csrf-token`), and 429 `Retry-After` handling all already exist. + +## Scope for v1 + +**In** — the four endpoints unaspoller actually polls: + +| Endpoint | Yields | +|---|---| +| `/proxy/drive/api/v2/systems/device-info` | name, model, version, firmware, uptime, cpu load/temp, memory free/total/avail | +| `/proxy/drive/api/v2/storage` | pools (capacity/usage/status/raid groups) and disks (health score, temp, power-on hours, rpm, size, bad/uncorrectable sectors, read/write KBPS) | +| `/proxy/users/drive/api/v2/drives` | per-share id/name/type/status/quota/usage/member count | +| `/proxy/drive/api/v2/systems/network-io` | receive/transmit KBPS | + +**Explicitly cut from v1**, to be revisited once community data exists: + +- `/proxy/drive/api/v2/systems/disk-stats` — needs `?start=&end=&interval=`, returns + time-series arrays that don't map onto gauges, and its per-disk `readKBPS`/`writeKBPS` + are already in `/storage`. +- Cache slots, expansions (unaspoller has them as empty placeholder structs). +- `/proxy/users/drive/api/v1/systems/{identity,info}`, `/proxy/users/drive/api/v2/{groups,storage}`, + `/proxy/drive/api/v1/systems/performance/file-operations`. + +## Part 1 — `../unifi` (github.com/unpoller/unifi/v5) — **done** + +Landed as `unas.go` + `unas_test.go`, four path constants in `types.go`, two sentinels +(`ErrNilConfig`, `ErrAPIKeyUnsupported`) in `unifi.go`, synthetic fixtures under +`endpoints_data/unas-*.json`, and a UNAS section in `endpoints_data/ENDPOINTS.md`. +`go test ./...` and `golangci-lint run ./...` are clean. + +Refinements over the plan below: + +1. `NewUNASClient` sets `u.new = true` directly instead of calling `checkNewStyleAPI()`. A + UNAS Pro is always a UniFi OS console, so probing a GET of `/` only adds a way for login + to resolve to the wrong path. This removes the "does the console answer GET `/`?" open + item entirely. +2. **`GetUNASDevice` does not return an error on partial failure.** Failing endpoints are + logged to `ErrorLog` and their fields left nil; only a total failure returns an error + (and then a nil device). Returning `(device, err)` together was the original design, but + it made the reflexive `if err != nil { return }` in `pkg/inputunas` silently drop every + metric from a console whenever one endpoint 404s — exactly the case the partial path + exists to serve. The footgun is removed at the source rather than documented around. +3. `NewUnifi` now returns `ErrNilConfig` too, instead of its own `fmt.Errorf("config is + nil")` for the identical condition. Callers gain `errors.Is`; the lib stops having two + errors for one condition in one file. + +### 1a. `unas.go` + +Path constants alongside the existing `/proxy/protect/…` block in `types.go` (or local to +`unas.go`, matching however `protect_log.go` does it): + +```go +APIUNASDeviceInfoPath = "/proxy/drive/api/v2/systems/device-info" +APIUNASStoragePath = "/proxy/drive/api/v2/storage" +APIUNASDrivesPath = "/proxy/users/drive/api/v2/drives" +APIUNASNetworkIOPath = "/proxy/drive/api/v2/systems/network-io" +``` + +Login-only client: + +```go +type UNASClient struct{ *Unifi } + +func NewUNASClient(config *Config) (*UNASClient, error) // newUnifi → checkNewStyleAPI → Login, stop +``` + +`newUnifi` is unexported but we're in-package; this is ~10 lines lifted from `NewUnifi` +plus the SSL-fingerprint loop, minus `GetServerData()`. Reject a non-empty `APIKey` with a +clear error (`ErrAPIKeyUnsupported` or reuse an existing sentinel) since key auth routes +through `/status`. + +Getters on `*UNASClient` — `GetUNASDeviceInfo`, `GetUNASStorage`, `GetUNASDrives`, +`GetUNASNetworkIO`, plus one aggregate `GetUNASDevice()` returning the composed +`*UNASDevice`. **Do not add these to the `UnifiClient` interface**: CONVENTIONS.md requires +`mocks.MockUnifi` implement every method, so widening it breaks the mocks build for no +gain — `inputunifi` holds `*unifi.Unifi` directly and nothing consumes the interface here. + +### 1b. Structs — port with two bug fixes + +Port unaspoller's `drivetypes.go`, renaming `DriveApiV2*` → `UNAS*` to match lib style. +Two dead fields to fix rather than inherit: + +1. `DriveApiV2SystemsDeviceInfo.NetworkInterfaces` has an **empty** tag (`json:""`) — it + never unmarshals today. Confirm the real key (likely `networkInterfaces`) from a probe + dump before relying on it; leave it out of v1 metrics either way. +2. `DriveApiV2Pool.activeRaidGroupId` is **unexported** with a json tag — also dead. + Export as `ActiveRaidGroupID`. + +Use `FlexInt`/`FlexBool` for every numeric/boolean field. unaspoller's own comments flag +several as "float64? only ever seen ints" (temperatures, sector counts) — the flex types +are the lib convention and pre-empt exactly the unmarshal breakage its author predicted. + +### 1c. Tests + +JSON fixtures under `endpoints_data/`, table tests in the existing lib style. + +## Part 2 — `unpoller` — **done** + +Landed as `pkg/inputunas/{config.go,input.go,input_test.go,README.md}`, the `UNASDevices` +pair in `pkg/poller`, a blank import in `main.go`, `unas.go` in promunifi/influxunifi/ +datadogunifi with their dispatch wiring, `pkg/promunifi/unas_test.go`, and an `[unas]` section +in all three example configs. `go test ./...` and `golangci-lint run` are clean. + +Refinements over the plan below: + +1. **The opt-in mechanism is "no devices configured", not `disable`.** `Disable` zero-values + to false, so a field named `disable` cannot make a plugin default-off. `Initialize` returns + silently when the device list is empty, and unlike `inputunifi` it does **not** synthesize + a default device from `[unas.defaults]` — doing so would poll a host the operator never + named. `disable` remains as an explicit off switch. +2. **`Metrics` returns `(metrics, nil)` whenever any console was collected.** Not politeness: + `poller.collectMetrics` uses `if result.err != nil { ... } else if result.metric != nil`, + so returning both discards every metric in that cycle. One dead console out of three would + have thrown away the two that worked. (`inputunifi.Metrics` hits this at + `interface.go:303`; left alone as out of scope.) +3. **Re-auth fires on any total failure, not on a 401.** A mid-session 401 from `GetData` + surfaces as `ErrInvalidStatusCode`, not `ErrAuthenticationFailed` (`unifi.go:603`), so + there is no sentinel to match. Session expiry fails all four endpoints at once, which is + exactly the total-failure case, and one wasted re-login against an unreachable console is + cheaper than never recovering from an expired session. +4. **Env var names come from the `xml` tags, not `json`** — verified by binding a real config: + `UP_UNAS_DEFAULT_TIMEOUT` (singular) and `UP_UNAS_DEVICE_0_URL`. + +### Original Part 2 plan + +### 2a. `pkg/inputunas/` + +`config.go` + `input.go` + `README.md`. Config shape: + +```toml +[unas] + disable = false + [unas.defaults] + user = "unpoller" + pass = "" + verify_ssl = false + timeout = "60s" + [[unas.device]] + url = "https://192.168.1.10" + user = "..." + pass = "..." +``` + +`json`/`toml`/`xml`/`yaml` tags on every field; env vars map as `UP_UNAS_*` via cnfg. +Implement all five `poller.Input` methods — `Events` returns empty, `RawMetrics` and +`DebugInput` as stubs; `Metrics` returns `&poller.Metrics{UNASDevices: …}`. + +**Do not re-log endpoint failures.** `GetUNASDevice` already reports each failing endpoint +through `ErrorLog` once per poll — accepted deliberately: a 404ing endpoint is something an +operator should see, and `inputunifi` logs per-cycle problems the same way. The plugin must +not add a second layer of the same message. + +**Own re-auth-on-401 loop.** We do not inherit `inputunifi`'s. UNAS session tokens expire +around 2h, and re-login must re-attach fresh cookies — this is the bug alexgreenbank burned +days on. One re-login attempt per request, then fail. + +### 2b. Plumbing (the easy-to-miss half) + +3. `main.go` — blank import `_ "github.com/unpoller/unpoller/pkg/inputunas"`. +4. `pkg/poller/config.go` — add `UNASDevices []any` to `Metrics` **and** the matching + append in `AppendMetrics` (`pkg/poller/inputs.go` ~336). Omitting the second is silent + metric loss when two inputs run, and nothing errors. + +**One aggregate family, not five.** `UNASDevices []any` carrying `*unifi.UNASDevice` +(device-info + pools + disks + drives + net-io) keeps `AppendMetrics` to one line and lets +one export function per output emit every gauge family. Nothing needs pools or disks +correlated independently — no site name, no PII redaction — so flat-per-entity families buy +nothing and cost 5× the plumbing. + +### 2c. Outputs — follow the `ups.go` pattern + +- `pkg/promunifi/unas.go` + four edits in `collector.go`: descriptor struct field (~71), + `descUNASDevice()` call (~321), the `Describe` reflect list (~607), the collect loop (~836). +- `pkg/influxunifi/unas.go` + the type-switch loop in `influxdb.go`. +- `pkg/datadogunifi/unas.go` + `datadog.go` loop and type-switch (~476). +- `pkg/lokiunifi` — skip, events only. +- `pkg/otelunifi` — **skip in v1.** `report.go:178` walks only `m.Devices`; it's per-family + and UPSDevices is already absent there. Adding UNAS is a separate follow-up. + +**Namespace divergence, stated deliberately:** promunifi prefixes with `u.Namespace + "_"`, +so these land as `unifi_unas_disk_temperature`, not unaspoller's `unas_disk_temperature`. +The Grafana dashboard in issue #785 is built on the latter and will need its queries +adjusted. Consistency with every other unpoller metric wins. + +### 2d. Docs + +`examples/up.conf.example` (new `[unas]` section after `[unifi]` at line 133), +`up.json.example`, `up.yaml.example`, `pkg/inputunas/README.md`, and the `UP_UNAS_*` env +mapping. + +## Sequencing across the two repos + +`go.mod` pins `github.com/unpoller/unifi/v5 v5.30.0` with **no** `replace` directive +(CLAUDE.md's `/Users/briangates/unifi` reference is stale). So: + +1. Land the `../unifi` change, tag a release. +2. Bump `unifi/v5` in unpoller, land `pkg/inputunas`. + +Both steps are done: Part 1 released as **v5.31.0**, and unpoller's `go.mod` now pins that +version. Because the tag is published and proxy-resolvable, the `replace` directive this plan +originally called for was never needed — which removes the "strip the replace before merging" +footgun entirely. The pin bump also pulled `testify` to v1.12.0 and `golang.org/x/net` to +v0.58.0, both required by the new lib version. + +## Open items needing real hardware or community data + +- Real json key and shape for `networkInterfaces`, `usbs`, `sfpAggregation`. The port assumes + `networkInterfaces` (the reference implementation's empty tag means this was never + verified); if a live console disagrees, that one tag is the fix. +- Shapes for `cacheSlots`, `riskReasons`, `incompatibleReasons`, `expansions` — unaspoller + left these as empty placeholders. Out of v1 scope. diff --git a/examples/up.conf.example b/examples/up.conf.example index 10f7fad5..04eb2a88 100644 --- a/examples/up.conf.example +++ b/examples/up.conf.example @@ -267,3 +267,35 @@ # save_speedtest = true # verify_ssl = false # ssl_cert_paths = [] + +############################## +### UNAS Pro (opt-in) +############################## + +# A UNAS Pro is a standalone UniFi OS console with no Network application, so it is polled +# by its own input plugin with its own credentials and host -- not as a UniFi controller. +# +# This section is entirely optional and does nothing unless you add at least one +# [[unas.device]] below: with no devices configured the plugin stays silent and inert. +# Metrics land under the unifi_unas_ prefix in Prometheus, and in the unas_device, +# unas_pool, unas_disk, and unas_share measurements in InfluxDB. +# +#[unas] +# disable = false +# +# Defaults applied to every device that does not set its own value. +#[unas.defaults] +# user = "unpoller" +# pass = "" +# verify_ssl = false +# timeout = "60s" +# +# Repeat the following stanza for each UNAS console you want to poll. +# Use the console's own local-account credentials. Do not add a path after the host. +#[[unas.device]] +# url = "https://192.168.1.10" +# user = "unpoller" +# pass = "unpoller" +# verify_ssl = false +# timeout = "60s" +# ssl_cert_paths = [] diff --git a/examples/up.json.example b/examples/up.json.example index aa63775a..8a3c941e 100644 --- a/examples/up.json.example +++ b/examples/up.json.example @@ -84,5 +84,25 @@ "verify_ssl": false } ] + }, + + "unas": { + "//": "UNAS Pro consoles. JSON cannot carry comments, so this section ships disabled: it shows the shape, and flipping disable to false with a real url turns it on.", + "disable": true, + "defaults": { + "user": "unpoller", + "pass": "", + "verify_ssl": false, + "timeout": "60s" + }, + "devices": [ + { + "url": "https://192.168.1.10", + "user": "unpoller", + "pass": "unpoller", + "verify_ssl": false, + "timeout": "60s" + } + ] } } diff --git a/examples/up.yaml.example b/examples/up.yaml.example index 7cc02fd8..7767666b 100644 --- a/examples/up.yaml.example +++ b/examples/up.yaml.example @@ -86,3 +86,21 @@ unifi: save_sites: true hash_pii: false verify_ssl: false + +# A UNAS Pro is a standalone UniFi OS console with no Network application, so it is polled +# by its own input plugin with its own credentials and host -- not as a UniFi controller. +# This section is optional: with no devices configured the plugin stays silent and inert. +#unas: +# disable: false +# defaults: +# user: "unpoller" +# pass: "" +# verify_ssl: false +# timeout: "60s" +# devices: +# # Repeat the following stanza to poll multiple UNAS consoles. +# - url: "https://192.168.1.10" +# user: "unpoller" +# pass: "unpoller" +# verify_ssl: false +# timeout: "60s" diff --git a/go.mod b/go.mod index 2745b9ea..7ace0440 100644 --- a/go.mod +++ b/go.mod @@ -11,8 +11,8 @@ require ( github.com/prometheus/client_golang v1.24.1 github.com/prometheus/common v0.70.1 github.com/spf13/pflag v1.0.10 - github.com/stretchr/testify v1.11.1 - github.com/unpoller/unifi/v5 v5.30.0 + github.com/stretchr/testify v1.12.0 + github.com/unpoller/unifi/v5 v5.31.0 go.opentelemetry.io/otel v1.45.0 go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.45.0 go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.45.0 @@ -36,7 +36,7 @@ require ( go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/otel/trace v1.45.0 // indirect go.opentelemetry.io/proto/otlp v1.11.0 // indirect - golang.org/x/net v0.57.0 // indirect + golang.org/x/net v0.58.0 // indirect golang.org/x/text v0.41.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260803160001-6ac0973c030d // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d // indirect diff --git a/go.sum b/go.sum index bd1109ed..977d988b 100644 --- a/go.sum +++ b/go.sum @@ -80,6 +80,7 @@ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSS github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= @@ -87,8 +88,12 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/stretchr/testify v1.12.0 h1:K6Mr6jO9JICuend/5xzTM03ydSV3vdNRYAdPSukj8uI= +github.com/stretchr/testify v1.12.0/go.mod h1:bOYBZb5qJ00vPzWfIqBUZPaxK8jWiXc6d3ErP4Ca9Gw= github.com/unpoller/unifi/v5 v5.30.0 h1:M1L/VG4BkZKt+aoqaB/E7foEvDfVFgc5SdPeoyX/qx0= github.com/unpoller/unifi/v5 v5.30.0/go.mod h1:j897mt84iizeLfvXL6KEGlfRdbAua8nTWm5PZjtif30= +github.com/unpoller/unifi/v5 v5.31.0 h1:WKrVxDP5YZetehFnqDoalb3+eALbR33zWFV0eyBhk08= +github.com/unpoller/unifi/v5 v5.31.0/go.mod h1:gbg5QyCqI7PFjSLAlm3IfEE1m0205LQF8nUYRtuOrAE= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= @@ -124,6 +129,8 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= diff --git a/main.go b/main.go index 07b97e86..1deebfd5 100644 --- a/main.go +++ b/main.go @@ -8,6 +8,7 @@ import ( "github.com/unpoller/unpoller/pkg/poller" // Load input plugins! + _ "github.com/unpoller/unpoller/pkg/inputunas" _ "github.com/unpoller/unpoller/pkg/inputunifi" // Load output plugins! _ "github.com/unpoller/unpoller/pkg/datadogunifi" diff --git a/pkg/datadogunifi/datadog.go b/pkg/datadogunifi/datadog.go index 1fa13cf7..0f3e09ac 100644 --- a/pkg/datadogunifi/datadog.go +++ b/pkg/datadogunifi/datadog.go @@ -350,6 +350,10 @@ func (u *DatadogUnifi) loopPoints(r report) { u.switchExport(r, v) } + for _, v := range m.UNASDevices { + u.switchExport(r, v) + } + for _, v := range m.WANStatuses { u.switchExport(r, v) } @@ -475,6 +479,8 @@ func (u *DatadogUnifi) switchExport(r report, v any) { //nolint:cyclop u.batchSSLCertificate(r, v) case *unifi.UPSDeviceSelector: u.batchUPSDevice(r, v) + case *unifi.UNASDevice: + u.batchUNASDevice(r, v) case *unifi.WANStatus: u.batchWANStatus(r, v) case *unifi.IntegrationDeviceStats: diff --git a/pkg/datadogunifi/unas.go b/pkg/datadogunifi/unas.go new file mode 100644 index 00000000..4ef76199 --- /dev/null +++ b/pkg/datadogunifi/unas.go @@ -0,0 +1,135 @@ +package datadogunifi + +import ( + "github.com/unpoller/unifi/v5" +) + +// batchUNASDevice generates UNAS Pro storage console datapoints for Datadog. +// +// Each section is guarded independently because GetUNASDevice leaves the field for a failing +// endpoint nil rather than failing the whole poll -- a console that serves storage but not +// network-io still reports its disks. +func (u *DatadogUnifi) batchUNASDevice(r report, d *unifi.UNASDevice) { + if d == nil { + return + } + + metricName := metricNamespace("unas_device") + + tags := cleanTags(map[string]string{ + "source": d.SourceName, + "name": d.Name(), + "model": d.Model(), + }) + + deviceTags := tagMapToTags(tags) + + _ = r.reportGauge(metricName("present"), 1.0, deviceTags) + + if info := d.DeviceInfo; info != nil { + _ = r.reportGauge(metricName("cpu_load"), info.CPU.CurrentLoad.Val, deviceTags) + _ = r.reportGauge(metricName("cpu_temperature"), info.CPU.Temperature.Val, deviceTags) + _ = r.reportGauge(metricName("memory_total"), info.Memory.Total.Val, deviceTags) + _ = r.reportGauge(metricName("memory_free"), info.Memory.Free.Val, deviceTags) + _ = r.reportGauge(metricName("memory_available"), info.Memory.Available.Val, deviceTags) + } + + if io := d.NetworkIO; io != nil { + _ = r.reportGauge(metricName("receive_kbps"), io.ReceiveKBPS.Val, deviceTags) + _ = r.reportGauge(metricName("transmit_kbps"), io.TransmitKBPS.Val, deviceTags) + } + + if s := d.Storage; s != nil { + u.batchUNASPools(r, d, s) + u.batchUNASDisks(r, d, s) + } + + u.batchUNASShares(r, d) +} + +func (u *DatadogUnifi) batchUNASPools(r report, d *unifi.UNASDevice, s *unifi.UNASStorage) { + metricName := metricNamespace("unas_pool") + raidName := metricNamespace("unas_raid_group") + + for _, p := range s.Pools { + tags := tagMapToTags(cleanTags(map[string]string{ + "source": d.SourceName, + "name": d.Name(), + "pool_id": p.ID, + "pool_type": p.Type, + "status": p.Status, + })) + + _ = r.reportGauge(metricName("capacity"), p.Capacity.Val, tags) + _ = r.reportGauge(metricName("usage"), p.Usage.Val, tags) + + for _, rg := range p.RaidGroups { + raidTags := tagMapToTags(cleanTags(map[string]string{ + "source": d.SourceName, + "name": d.Name(), + "pool_id": p.ID, + "raid_group_id": rg.ID, + "current_level": rg.CurrentLevel, + "config_level": rg.ConfigLevel, + })) + + _ = r.reportGauge(raidName("current_protection"), rg.CurrentProtection.Val, raidTags) + _ = r.reportGauge(raidName("expected_protection"), rg.ExpectedProtection.Val, raidTags) + _ = r.reportGauge(raidName("progress"), rg.Progress.Val, raidTags) + } + } +} + +func (u *DatadogUnifi) batchUNASDisks(r report, d *unifi.UNASDevice, s *unifi.UNASStorage) { + metricName := metricNamespace("unas_disk") + + for _, disk := range s.Disks { + tags := tagMapToTags(cleanTags(map[string]string{ + "source": d.SourceName, + "name": d.Name(), + "slot_id": disk.SlotID, + "location": disk.Location, + "pool_id": disk.PoolID, + "disk_type": disk.Type, + "state": disk.State, + "model": disk.Model, + "serial": disk.Serial, + })) + + _ = r.reportGauge(metricName("size"), disk.Size.Val, tags) + _ = r.reportGauge(metricName("temperature"), disk.Temperature.Val, tags) + _ = r.reportGauge(metricName("health_score"), disk.HealthScore.Val, tags) + _ = r.reportGauge(metricName("power_on_hours"), disk.PowerOnHours.Val, tags) + _ = r.reportGauge(metricName("rpm"), disk.RPM.Val, tags) + _ = r.reportGauge(metricName("bad_sector_count"), disk.BadSectorCount.Val, tags) + _ = r.reportGauge(metricName("uncorrectable_sector_count"), disk.UncorrectableSectorCount.Val, tags) + _ = r.reportGauge(metricName("read_error_rate"), disk.ReadErrorRate.Val, tags) + _ = r.reportGauge(metricName("smart_read_error_count"), disk.SmartReadErrorCount.Val, tags) + _ = r.reportGauge(metricName("read_kbps"), disk.ReadKBPS.Val, tags) + _ = r.reportGauge(metricName("write_kbps"), disk.WriteKBPS.Val, tags) + } +} + +func (u *DatadogUnifi) batchUNASShares(r report, d *unifi.UNASDevice) { + metricName := metricNamespace("unas_share") + + for _, share := range d.Drives { + if share == nil { + continue + } + + tags := tagMapToTags(cleanTags(map[string]string{ + "source": d.SourceName, + "name": d.Name(), + "share_id": share.ID, + "share_name": share.Name, + "share_type": share.Type, + "status": share.Status, + "role": share.Role, + })) + + _ = r.reportGauge(metricName("quota"), share.Quota.Val, tags) + _ = r.reportGauge(metricName("usage"), share.Usage.Val, tags) + _ = r.reportGauge(metricName("member_count"), share.MemberCount.Val, tags) + } +} diff --git a/pkg/influxunifi/influxdb.go b/pkg/influxunifi/influxdb.go index 28fa59ba..bd2d849a 100644 --- a/pkg/influxunifi/influxdb.go +++ b/pkg/influxunifi/influxdb.go @@ -507,6 +507,12 @@ func (u *InfluxUnifi) loopPoints(r report) { } } + for _, nd := range m.UNASDevices { + if v, ok := nd.(*unifi.UNASDevice); ok { + u.batchUNASDevice(r, v) + } + } + for _, wb := range m.WifiBroadcasts { if v, ok := wb.(*unifi.WifiBroadcast); ok { u.batchWifiBroadcast(r, v) diff --git a/pkg/influxunifi/unas.go b/pkg/influxunifi/unas.go new file mode 100644 index 00000000..08563562 --- /dev/null +++ b/pkg/influxunifi/unas.go @@ -0,0 +1,151 @@ +package influxunifi + +import ( + "github.com/unpoller/unifi/v5" +) + +// batchUNASDevice generates InfluxDB points for one UNAS Pro storage console. +// +// Four measurements are emitted rather than one: the console, its storage pools, its physical +// disks, and its shares each have their own tag set, and folding them together would give +// every point the union of those tags with most of them empty. +// +// Each section is guarded independently because GetUNASDevice leaves the field for a failing +// endpoint nil rather than failing the whole poll -- a console that serves storage but not +// network-io still reports its disks. +func (u *InfluxUnifi) batchUNASDevice(r report, d *unifi.UNASDevice) { // nolint: funlen + if d == nil { + return + } + + tags := map[string]string{ + "source": d.SourceName, + "name": d.Name(), + "model": d.Model(), + } + + fields := map[string]any{"present": 1} + + if info := d.DeviceInfo; info != nil { + tags["version"] = info.Version + tags["firmware_version"] = info.FirmwareVersion + tags["status"] = info.Status + fields["cpu_load"] = info.CPU.CurrentLoad.Val + fields["cpu_temperature"] = info.CPU.Temperature.Val + fields["memory_total"] = info.Memory.Total.Val + fields["memory_free"] = info.Memory.Free.Val + fields["memory_available"] = info.Memory.Available.Val + fields["startup_time"] = info.StartupTime + } + + if io := d.NetworkIO; io != nil { + fields["receive_kbps"] = io.ReceiveKBPS.Val + fields["transmit_kbps"] = io.TransmitKBPS.Val + } + + r.send(&metric{Table: "unas_device", Tags: tags, Fields: fields}) + + if s := d.Storage; s != nil { + u.batchUNASStorage(r, d, s) + } + + for _, share := range d.Drives { + if share == nil { + continue + } + + r.send(&metric{ + Table: "unas_share", + Tags: map[string]string{ + "source": d.SourceName, + "name": d.Name(), + "share_id": share.ID, + "share_name": share.Name, + "share_type": share.Type, + "status": share.Status, + "storage_pool_id": share.StoragePoolID, + "role": share.Role, + "encryption_status": share.Protections.EncryptionStatus, + }, + Fields: map[string]any{ + "quota": share.Quota.Val, + "usage": share.Usage.Val, + "member_count": share.MemberCount.Val, + "snapshot_enabled": share.Protections.SnapshotEnabled.Val, + "remote_backup_enabled": share.Protections.RemoteBackupEnabled.Val, + "deduplication": share.Deduplication, + "compression_level": share.CompressionLevel, + }, + }) + } +} + +func (u *InfluxUnifi) batchUNASStorage(r report, d *unifi.UNASDevice, s *unifi.UNASStorage) { + for _, p := range s.Pools { + fields := map[string]any{ + "capacity": p.Capacity.Val, + "usage": p.Usage.Val, + "raid_group_count": len(p.RaidGroups), + "initializing_status": p.InitializingStatus, + } + + // A pool has one active RAID group; its redundancy is the number an operator alerts + // on, so it is flattened onto the pool point rather than given its own measurement. + for _, rg := range p.RaidGroups { + if rg.ID != p.ActiveRaidGroupID { + continue + } + + fields["current_protection"] = rg.CurrentProtection.Val + fields["expected_protection"] = rg.ExpectedProtection.Val + fields["progress"] = rg.Progress.Val + fields["current_level"] = rg.CurrentLevel + } + + r.send(&metric{ + Table: "unas_pool", + Tags: map[string]string{ + "source": d.SourceName, + "name": d.Name(), + "pool_id": p.ID, + "pool_type": p.Type, + "status": p.Status, + "prefer_level": p.PreferLevel, + }, + Fields: fields, + }) + } + + for _, disk := range s.Disks { + r.send(&metric{ + Table: "unas_disk", + Tags: map[string]string{ + "source": d.SourceName, + "name": d.Name(), + "slot_id": disk.SlotID, + "location": disk.Location, + "pool_id": disk.PoolID, + "disk_type": disk.Type, + "state": disk.State, + "model": disk.Model, + "serial": disk.Serial, + "firmware": disk.Firmware, + }, + Fields: map[string]any{ + "size": disk.Size.Val, + "temperature": disk.Temperature.Val, + "health_score": disk.HealthScore.Val, + "power_on_hours": disk.PowerOnHours.Val, + "rpm": disk.RPM.Val, + "bad_sector_count": disk.BadSectorCount.Val, + "uncorrectable_sector_count": disk.UncorrectableSectorCount.Val, + "read_error_rate": disk.ReadErrorRate.Val, + "smart_read_error_count": disk.SmartReadErrorCount.Val, + "read_kbps": disk.ReadKBPS.Val, + "write_kbps": disk.WriteKBPS.Val, + "is_global_hot_spare": disk.IsGlobalHotSpare.Val, + "is_local_hot_spare": disk.IsLocalHotSpare.Val, + }, + }) + } +} diff --git a/pkg/inputunas/README.md b/pkg/inputunas/README.md new file mode 100644 index 00000000..85c5cff3 --- /dev/null +++ b/pkg/inputunas/README.md @@ -0,0 +1,120 @@ +# UNAS Pro Input Plugin + +Polls UniFi UNAS Pro storage consoles and hands their metrics to every configured output. + +**This plugin is opt-in and does nothing until you configure at least one device.** With no +`[unas]` section, or a section with no devices, it stays silent and inert — it logs nothing +and polls nothing. + +## Why a separate plugin? + +A UNAS Pro is a standalone UniFi OS console with **no Network application**: no sites, no +`/status` endpoint, its own credentials, and usually its own host. `inputunifi`'s +sites-then-devices flow has nothing to offer it, so UNAS consoles are configured separately +rather than as another UniFi controller. + +## Configuration + +```toml +[unas] + disable = false + +# Applied to any device that does not set its own value. +[unas.defaults] + user = "unpoller" + pass = "" + verify_ssl = false + timeout = "60s" + +# Repeat for each console. Use its own local-account credentials. +# Do not add a path after the host. +[[unas.device]] + url = "https://192.168.1.10" + user = "unpoller" + pass = "unpoller" + verify_ssl = false + timeout = "60s" + ssl_cert_paths = [] +``` + +JSON and YAML use `devices` (plural) as the list key; TOML uses repeated `[[unas.device]]` +tables. See `examples/up.{conf,json,yaml}.example`. + +### Environment variables + +| Variable | Meaning | +|---|---| +| `UP_UNAS_DISABLE` | Disable the plugin outright. | +| `UP_UNAS_DEFAULT_USER` | Default username for every device. | +| `UP_UNAS_DEFAULT_PASS` | Default password. | +| `UP_UNAS_DEFAULT_VERIFY_SSL` | Default TLS verification. | +| `UP_UNAS_DEFAULT_TIMEOUT` | Default HTTP timeout, e.g. `60s`. | +| `UP_UNAS_DEVICE_0_URL` | First console's URL. | +| `UP_UNAS_DEVICE_0_USER` | First console's username. | +| `UP_UNAS_DEVICE_0_PASS` | First console's password. | +| `UP_UNAS_DEVICE_0_VERIFY_SSL` | First console's TLS verification. | +| `UP_UNAS_DEVICE_0_TIMEOUT` | First console's HTTP timeout. | + +Increment the index for additional consoles. Note the defaults block is `DEFAULT`, singular. + +### Authentication + +Username and password only. API keys are **not** supported: the UniFi library routes key +auth through `/status`, which a storage-only console does not serve. + +A UNAS session expires after roughly two hours. When it does, every endpoint starts failing +at once; the plugin logs back in and retries once per poll, so this recovers without a +restart. + +## Metrics + +Data comes from the UniFi Drive API. Four endpoints are polled per cycle: + +| Endpoint | Yields | +|---|---| +| `/proxy/drive/api/v2/systems/device-info` | name, model, version, firmware, status, CPU load and temperature, memory | +| `/proxy/drive/api/v2/storage` | pools (capacity, usage, status, RAID groups) and disks (health score, temperature, power-on hours, RPM, size, bad and uncorrectable sectors, read/write KB/s) | +| `/proxy/users/drive/api/v2/drives` | per-share id, name, type, status, quota, usage, member count | +| `/proxy/drive/api/v2/systems/network-io` | receive and transmit KB/s | + +An endpoint that fails is logged and its metrics omitted for that cycle; the rest are still +reported. A console that fails *every* endpoint is reported as an error, and one dead console +never suppresses the metrics of a healthy one. + +### Prometheus + +Metric names are prefixed with the configured namespace, so they read as +`unifi_unas_disk_temperature_celsius`, `unifi_unas_pool_usage_bytes`, +`unifi_unas_share_quota_bytes`, and so on. Note this differs from `unaspoller`, which uses a +bare `unas_` prefix — the Grafana dashboard in +[#785](https://github.com/unpoller/unpoller/issues/785) needs its queries adjusted +accordingly. Consistency with every other unpoller metric wins here. + +### InfluxDB and DataDog + +Four measurements / metric prefixes: `unas_device`, `unas_pool`, `unas_disk`, and +`unas_share` (DataDog also emits `unas_raid_group`). They are kept separate because each has +its own tag set; folding them together would give every point the union of those tags with +most of them empty. + +Loki and OpenTelemetry are not wired up: Loki carries events only, and a UNAS console exposes +no event endpoints in this version. + +## Credit + +Endpoint discovery and the JSON shapes come from +[alexgreenbank/unaspoller](https://github.com/alexgreenbank/unaspoller) (MIT), which worked +out the UniFi Drive API by observing the console's own web UI. See +[#785](https://github.com/unpoller/unpoller/issues/785). + +## Known gaps + +These need data from real hardware: + +- The `networkInterfaces` JSON key is an informed assumption — the reference implementation's + struct tag for it was empty, so it was never actually verified. Interface data is not + exported as metrics either way. +- `cacheSlots`, `expansions`, `usbs`, `riskReasons`, and `incompatibleReasons` are kept as + raw JSON in the library because no populated example exists. +- `/proxy/drive/api/v2/systems/disk-stats` is not polled: it returns time series that do not + map onto gauges, and its per-disk throughput is already in `/storage`. diff --git a/pkg/inputunas/config.go b/pkg/inputunas/config.go new file mode 100644 index 00000000..76e45c6d --- /dev/null +++ b/pkg/inputunas/config.go @@ -0,0 +1,163 @@ +// 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/v5" + "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"` + Disable bool `json:"disable" toml:"disable" xml:"disable,attr" yaml:"disable"` + 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. That is what keeps UNAS support opt-in. +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 +} diff --git a/pkg/inputunas/input.go b/pkg/inputunas/input.go new file mode 100644 index 00000000..8897b56c --- /dev/null +++ b/pkg/inputunas/input.go @@ -0,0 +1,339 @@ +package inputunas + +import ( + "errors" + "fmt" + "strconv" + "strings" + "time" + + "github.com/unpoller/unifi/v5" + "github.com/unpoller/unpoller/pkg/poller" + "github.com/unpoller/unpoller/pkg/webserver" +) + +// ErrNoDevices is returned by DebugInput when the plugin is enabled but nothing is configured. +var ErrNoDevices = errors.New("no UNAS devices configured") + +/* This file contains the poller.Input interface methods. */ + +// Initialize gets called one time when starting up. +// Satisfies poller.Input interface. +func (u *InputUNAS) Initialize(l poller.Logger) error { + if u.Config == nil { + u.Config = &Config{Disable: true} + } + + if u.Logger = l; u.Disable { + return nil + } + + u.Devices = u.configuredDevices() + + // No [unas] block means no devices, which means nothing to say. Staying silent here is + // what makes the plugin opt-in: an operator who has never heard of UNAS should see no + // trace of it in the log. + if len(u.Devices) == 0 { + return nil + } + + for i, d := range u.Devices { + if err := u.login(d); err != nil { + // Not fatal: a console that is down at startup gets another attempt every poll. + u.LogErrorf("UNAS console %d of %d auth or connection error, retrying next poll: %v", + i+1, len(u.Devices), err) + + continue + } + + u.Logf("Configured UNAS console %d of %d:", i+1, len(u.Devices)) + u.logDevice(d) + } + + webserver.UpdateInput(&webserver.Input{Name: PluginName, Config: formatConfig(u.Config)}) + + return nil +} + +// configuredDevices returns the devices with defaults applied, dropping any without a URL. +// A device with no URL is a config typo (or an empty [[unas.device]] table); polling it +// would only produce a confusing error against the empty string every cycle. +func (u *InputUNAS) configuredDevices() []*Device { + devices := make([]*Device, 0, len(u.Devices)) + + for _, d := range u.Devices { + if d == nil { + continue + } + + if d.URL == "" { + u.LogErrorf("Ignoring UNAS device with no url configured.") + + continue + } + + devices = append(devices, u.setDefaults(d)) + } + + return devices +} + +func (u *InputUNAS) logDevice(d *Device) { + u.Logf(" => URL: %s (verify SSL: %v, timeout: %v)", d.URL, *d.VerifySSL, d.Timeout.Duration) + + if len(d.CertPaths) > 0 { + u.Logf(" => Cert Files: %s", strings.Join(d.CertPaths, ", ")) + } + + u.Logf(" => Username: %s (has password: %v)", d.User, d.Pass != "") +} + +// Metrics polls every configured UNAS console and returns the aggregate. +// +// A per-console failure is logged and skipped rather than returned, and an error comes back +// only when nothing at all was collected. This is not politeness: poller.collectMetrics +// discards the metrics entirely when an input returns both a result and an error, so +// returning `(metrics, err)` after one console of three fails would throw away the two that +// worked. Satisfies poller.Input interface. +func (u *InputUNAS) Metrics(filter *poller.Filter) (*poller.Metrics, error) { + if u.Disable { + return nil, nil + } + + metrics := &poller.Metrics{TS: time.Now()} + + if filter == nil { + filter = &poller.Filter{} + } + + var errs []error + + for _, d := range u.Devices { + if filter.Path != "" && !strings.EqualFold(d.URL, filter.Path) { + continue + } + + device, err := u.collectDevice(d) + if err != nil { + u.LogErrorf("Failed to collect metrics from UNAS console %s: %v", d.URL, err) + errs = append(errs, fmt.Errorf("%s: %w", d.URL, err)) + + continue + } + + metrics.UNASDevices = append(metrics.UNASDevices, device) + } + + if len(metrics.UNASDevices) == 0 && len(errs) > 0 { + return nil, errors.Join(errs...) + } + + return metrics, nil +} + +// collectDevice fetches one console, re-authenticating once if the whole fetch failed. +// +// UNAS session cookies expire after roughly two hours, so a long-running poller must expect +// to be logged out mid-life and must re-attach fresh cookies rather than reuse the dead jar. +// We retry on any total failure rather than on a 401 specifically: a mid-session 401 from +// GetData surfaces as ErrInvalidStatusCode, not ErrAuthenticationFailed, so there is no +// sentinel to match on. Session expiry fails every endpoint at once, which is exactly the +// total-failure case, and one wasted re-login against a console that is simply unreachable +// is cheaper than never recovering from an expired session. Partial failures never reach +// here: GetUNASDevice logs those endpoints and leaves their fields nil. +func (u *InputUNAS) collectDevice(d *Device) (*unifi.UNASDevice, error) { + device, err := u.getUNASDevice(d) + if err == nil { + return device, nil + } + + u.Logf("Re-authenticating to UNAS console: %s", d.URL) + + if lerr := u.login(d); lerr != nil { + // Report the original failure too, so a console that is merely down does not look + // like a credentials problem. + return nil, fmt.Errorf("re-authenticating after %v: %w", err, lerr) + } + + return u.getUNASDevice(d) +} + +// getUNASDevice reads the client under the read lock and releases it before polling, so a +// slow console cannot block a concurrent re-auth (which takes the write lock). +func (u *InputUNAS) getUNASDevice(d *Device) (*unifi.UNASDevice, error) { + u.RLock() + + client := d.unas + + u.RUnlock() + + if client == nil { + return nil, unifi.ErrNilUnifi + } + + return client.GetUNASDevice() +} + +// Events is a no-op: a UNAS console exposes no event or log endpoints in this version. +// Satisfies poller.Input interface. +func (u *InputUNAS) Events(_ *poller.Filter) (*poller.Events, error) { + return &poller.Events{}, nil +} + +// RawMetrics returns the raw JSON from one UNAS endpoint, selected by filter.Kind. +// Adjust filter.Unit to pull from a console other than the first. +// Satisfies poller.Input interface. +func (u *InputUNAS) RawMetrics(filter *poller.Filter) ([]byte, error) { + if filter == nil { + filter = &poller.Filter{} + } + + if l := len(u.Devices); filter.Unit >= l { + return nil, fmt.Errorf("%d UNAS console(s) configured, '%d': %w", l, filter.Unit, ErrNoDevices) + } + + d := u.Devices[filter.Unit] + + u.RLock() + + client := d.unas + + u.RUnlock() + + if client == nil { + if err := u.login(d); err != nil { + return nil, err + } + + u.RLock() + + client = d.unas + + u.RUnlock() + } + + var path string + + switch filter.Kind { + case "", "device", "device-info", "d": + path = unifi.APIUNASDeviceInfoPath + case "storage", "s": + path = unifi.APIUNASStoragePath + case "drives", "dr": + path = unifi.APIUNASDrivesPath + case "network-io", "n": + path = unifi.APIUNASNetworkIOPath + default: + return nil, fmt.Errorf("must provide filter: device-info, storage, drives, network-io: %w", + unifi.ErrEndpointNotFound) + } + + return client.GetJSON(path) +} + +// DebugInput checks that every configured console can be reached and authenticated against. +// Satisfies poller.Input interface. +func (u *InputUNAS) DebugInput() (bool, error) { + if u == nil || u.Config == nil || u.Disable { + return true, nil + } + + // Safe to assign without holding the lock: --debugio is a one-shot mode that exits before + // Run() ever starts polling (see poller.Start), so this cannot race a Metrics call. It is + // not redundant with Initialize either -- that never runs in this mode. + u.Devices = u.configuredDevices() + + if len(u.Devices) == 0 { + return true, nil + } + + allOK := true + + var errs []error + + for i, d := range u.Devices { + if err := u.login(d); err != nil { + u.LogErrorf("UNAS console %d of %d auth or connection error: %v", i+1, len(u.Devices), err) + + allOK = false + + errs = append(errs, err) + + continue + } + + u.Logf("Valid UNAS console %d of %d:", i+1, len(u.Devices)) + u.logDevice(d) + } + + return allOK, errors.Join(errs...) +} + +// formatConfig copies the config for display on the web interface, replacing the password +// with whether one is set. +func formatConfig(config *Config) *Config { + devices := make([]*Device, len(config.Devices)) + + for i, d := range config.Devices { + devices[i] = &Device{ + URL: d.URL, + User: d.User, + Pass: strconv.FormatBool(d.Pass != ""), + VerifySSL: d.VerifySSL, + CertPaths: d.CertPaths, + Timeout: d.Timeout, + } + } + + return &Config{ + Default: Device{ + URL: config.Default.URL, + User: config.Default.User, + Pass: strconv.FormatBool(config.Default.Pass != ""), + VerifySSL: config.Default.VerifySSL, + CertPaths: config.Default.CertPaths, + Timeout: config.Default.Timeout, + }, + Disable: config.Disable, + Devices: devices, + } +} + +// Logf logs a message. +func (u *InputUNAS) Logf(msg string, v ...any) { + webserver.NewInputEvent(PluginName, PluginName, &webserver.Event{ + Ts: time.Now(), + Msg: fmt.Sprintf(msg, v...), + Tags: map[string]string{"type": "info"}, + }) + + if u.Logger != nil { + u.Logger.Logf(msg, v...) + } +} + +// LogErrorf logs an error message. +func (u *InputUNAS) LogErrorf(msg string, v ...any) { + webserver.NewInputEvent(PluginName, PluginName, &webserver.Event{ + Ts: time.Now(), + Msg: fmt.Sprintf(msg, v...), + Tags: map[string]string{"type": "error"}, + }) + + if u.Logger != nil { + u.Logger.LogErrorf(msg, v...) + } +} + +// LogDebugf logs a debug message. +func (u *InputUNAS) LogDebugf(msg string, v ...any) { + webserver.NewInputEvent(PluginName, PluginName, &webserver.Event{ + Ts: time.Now(), + Msg: fmt.Sprintf(msg, v...), + Tags: map[string]string{"type": "debug"}, + }) + + if u.Logger != nil { + u.Logger.LogDebugf(msg, v...) + } +} diff --git a/pkg/inputunas/input_test.go b/pkg/inputunas/input_test.go new file mode 100644 index 00000000..b8f53024 --- /dev/null +++ b/pkg/inputunas/input_test.go @@ -0,0 +1,219 @@ +package inputunas_test + +import ( + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/unpoller/unifi/v5" + "github.com/unpoller/unpoller/pkg/inputunas" + "github.com/unpoller/unpoller/pkg/poller" +) + +const ( + deviceInfoJSON = `{"name":"UNAS-Pro","model":"UNASPro","version":"4.0.6","firmwareVersion":"4.0.6", + "status":"HEALTHY","cpu":{"currentLoad":12,"temperature":45}, + "memory":{"free":1000,"total":8000,"available":6000}}` + storageJSON = `{"pools":[{"number":1,"id":"pool-1","type":"RAID","status":"HEALTHY", + "capacity":1000,"usage":400,"activeRaidGroupId":"rg-1", + "raidGroups":[{"number":1,"id":"rg-1","currentLevel":"RAID5","configLevel":"RAID5", + "currentProtection":1,"expectedProtection":1,"progress":100}]}], + "disks":[{"slotId":"1","poolId":"pool-1","type":"HDD","state":"HEALTHY", + "model":"HAT5300","serial":"ABC123","temperature":38,"healthScore":100,"size":500}]}` + drivesJSON = `{"drives":[{"id":"d-1","name":"media","type":"SHARE","status":"HEALTHY","quota":100,"usage":50,"memberCount":3}]}` + networkIOJSON = `{"receiveKBPS":120,"transmitKBPS":340,"timestamp":"2026-01-01T00:00:00Z"}` +) + +// unasServer is a fake UNAS Pro console. failData, when set, makes every data endpoint +// return 401 -- which is how an expired session presents itself. +type unasServer struct { + logins atomic.Int64 + failData atomic.Bool +} + +func (s *unasServer) start(t *testing.T) *httptest.Server { + t.Helper() + + mux := http.NewServeMux() + mux.HandleFunc("/api/auth/login", func(w http.ResponseWriter, _ *http.Request) { + s.logins.Add(1) + w.Header().Set("x-csrf-token", "csrf-token") + w.WriteHeader(http.StatusOK) + }) + + for path, body := range map[string]string{ + unifi.APIUNASDeviceInfoPath: deviceInfoJSON, + unifi.APIUNASStoragePath: storageJSON, + unifi.APIUNASDrivesPath: drivesJSON, + unifi.APIUNASNetworkIOPath: networkIOJSON, + } { + mux.HandleFunc(path, func(w http.ResponseWriter, _ *http.Request) { + if s.failData.Load() { + w.WriteHeader(http.StatusUnauthorized) + + return + } + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(body)) + }) + } + + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + + return srv +} + +func newInput(t *testing.T, urls ...string) *inputunas.InputUNAS { + t.Helper() + + devices := make([]*inputunas.Device, len(urls)) + for i, u := range urls { + devices[i] = &inputunas.Device{URL: u, User: "unpoller", Pass: "secret"} + } + + return &inputunas.InputUNAS{Config: &inputunas.Config{Devices: devices}} +} + +func TestMetricsCollectsConsole(t *testing.T) { + t.Parallel() + + srv := (&unasServer{}).start(t) + u := newInput(t, srv.URL) + require.NoError(t, u.Initialize(nil)) + + m, err := u.Metrics(nil) + require.NoError(t, err) + require.NotNil(t, m) + require.Len(t, m.UNASDevices, 1) + + d, ok := m.UNASDevices[0].(*unifi.UNASDevice) + require.True(t, ok, "outputs type-assert to *unifi.UNASDevice, so nothing else may be appended") + + a := assert.New(t) + a.Equal("UNAS-Pro", d.Name()) + a.Equal("UNASPro", d.Model()) + a.Equal(srv.URL, d.SourceName) + require.NotNil(t, d.Storage) + a.Len(d.Storage.Pools, 1) + a.Len(d.Storage.Disks, 1) + a.Len(d.Drives, 1) + require.NotNil(t, d.NetworkIO) + a.InDelta(120, d.NetworkIO.ReceiveKBPS.Val, 0.01) +} + +// A UNAS session cookie expires after roughly two hours. When it does, every endpoint fails +// at once, and the plugin must log back in and re-attach fresh cookies rather than reuse the +// dead jar -- otherwise a long-running poller reports nothing until it is restarted. +func TestMetricsReauthenticatesAfterSessionExpiry(t *testing.T) { + t.Parallel() + + fake := &unasServer{} + srv := fake.start(t) + u := newInput(t, srv.URL) + require.NoError(t, u.Initialize(nil)) + require.Equal(t, int64(1), fake.logins.Load()) + + fake.failData.Store(true) + + m, err := u.Metrics(nil) + require.Error(t, err, "a console that fails every endpoint even after re-auth is a real error") + require.Nil(t, m) + require.Equal(t, int64(2), fake.logins.Load(), "a total failure must trigger exactly one re-login") + + // Once the console answers again, the refreshed session works without another login. + fake.failData.Store(false) + + m, err = u.Metrics(nil) + require.NoError(t, err) + require.Len(t, m.UNASDevices, 1) + require.Equal(t, int64(2), fake.logins.Load(), "a healthy poll must not re-authenticate") +} + +// One dead console must not discard the metrics of a healthy one. poller.collectMetrics +// drops the result entirely when an input returns both metrics and an error, so returning +// them together here would throw away everything the working console reported. +func TestMetricsPartialFailureStillReportsHealthyConsole(t *testing.T) { + t.Parallel() + + good := (&unasServer{}).start(t) + dead := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + + defer dead.Close() + + u := newInput(t, dead.URL, good.URL) + require.NoError(t, u.Initialize(nil)) + + m, err := u.Metrics(nil) + require.NoError(t, err, "one bad console out of two is not an error") + require.Len(t, m.UNASDevices, 1) +} + +func TestMetricsDisabled(t *testing.T) { + t.Parallel() + + u := &inputunas.InputUNAS{Config: &inputunas.Config{Disable: true}} + require.NoError(t, u.Initialize(nil)) + + m, err := u.Metrics(nil) + require.NoError(t, err) + require.Nil(t, m) +} + +// With no [unas] block the plugin must be silent and inert: that is what makes UNAS support +// opt-in. It must not synthesize a default device the way inputunifi does with localhost. +func TestUnconfiguredIsInert(t *testing.T) { + t.Parallel() + + u := &inputunas.InputUNAS{} + require.NoError(t, u.Initialize(nil)) + + m, err := u.Metrics(nil) + require.NoError(t, err) + require.Nil(t, m, "an unconfigured plugin disables itself") + + ok, err := u.DebugInput() + require.NoError(t, err) + require.True(t, ok) +} + +func TestDeviceWithNoURLIsIgnored(t *testing.T) { + t.Parallel() + + u := &inputunas.InputUNAS{Config: &inputunas.Config{Devices: []*inputunas.Device{{}, nil}}} + require.NoError(t, u.Initialize(nil)) + + m, err := u.Metrics(nil) + require.NoError(t, err) + require.Empty(t, m.UNASDevices) +} + +func TestEventsAreEmpty(t *testing.T) { + t.Parallel() + + e, err := (&inputunas.InputUNAS{}).Events(&poller.Filter{}) + require.NoError(t, err) + require.NotNil(t, e) + require.Empty(t, e.Logs) +} + +func TestRawMetrics(t *testing.T) { + t.Parallel() + + srv := (&unasServer{}).start(t) + u := newInput(t, srv.URL) + require.NoError(t, u.Initialize(nil)) + + body, err := u.RawMetrics(&poller.Filter{Kind: "storage"}) + require.NoError(t, err) + require.Contains(t, string(body), "pool-1") + + _, err = u.RawMetrics(&poller.Filter{Unit: 9}) + require.ErrorIs(t, err, inputunas.ErrNoDevices) +} diff --git a/pkg/poller/config.go b/pkg/poller/config.go index 4986638b..f162ed02 100644 --- a/pkg/poller/config.go +++ b/pkg/poller/config.go @@ -129,6 +129,8 @@ type Metrics struct { DPICategories []any // *unifi.DPICategory — DPI category catalogue (global) PendingDevices []any // *unifi.PendingDevice — devices awaiting adoption (global) Countries []any // *unifi.Country — country list for geo-filters (global) + // Added by the inputunas plugin: + UNASDevices []any // *unifi.UNASDevice — UNAS Pro storage console (one per configured device) } // Events defines the type for log entries. diff --git a/pkg/poller/inputs.go b/pkg/poller/inputs.go index 4b6d5073..0c64cab6 100644 --- a/pkg/poller/inputs.go +++ b/pkg/poller/inputs.go @@ -351,6 +351,7 @@ func AppendMetrics(existing *Metrics, m *Metrics) *Metrics { existing.DPICategories = append(existing.DPICategories, m.DPICategories...) existing.PendingDevices = append(existing.PendingDevices, m.PendingDevices...) existing.Countries = append(existing.Countries, m.Countries...) + existing.UNASDevices = append(existing.UNASDevices, m.UNASDevices...) return existing } diff --git a/pkg/promunifi/collector.go b/pkg/promunifi/collector.go index 82f158e2..c8e73c94 100644 --- a/pkg/promunifi/collector.go +++ b/pkg/promunifi/collector.go @@ -46,29 +46,30 @@ const ( 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 - DHCPLease *dhcplease - WAN *wan - Controller *controller - FirewallPolicy *firewallpolicy - Topology *topology - PortAnomaly *portanomaly - VPNMesh *vpnmesh + *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 + DHCPLease *dhcplease + WAN *wan + Controller *controller + FirewallPolicy *firewallpolicy + Topology *topology + PortAnomaly *portanomaly + VPNMesh *vpnmesh IntegrationDevice *integrationDevice WANStatus *wanStatus PortForward *portForward SSLCertificate *sslCertificate UPSDevice *upsDevice + UNASDevice *unasDevice WifiBroadcast *wifiBroadcast FirewallZone *firewallZone ACLRule *aclRule @@ -319,6 +320,7 @@ func (u *promUnifi) Run(c poller.Collect) error { u.PortForward = descPortForward(u.Namespace + "_") u.SSLCertificate = descSSLCertificate(u.Namespace + "_") u.UPSDevice = descUPSDevice(u.Namespace + "_") + u.UNASDevice = descUNASDevice(u.Namespace + "_") u.WifiBroadcast = descWifiBroadcast(u.Namespace + "_") u.FirewallZone = descFirewallZone(u.Namespace + "_") u.ACLRule = descACLRule(u.Namespace + "_") @@ -609,6 +611,7 @@ func (u *promUnifi) Describe(ch chan<- *prometheus.Desc) { u.LAG, u.MCLAGDomain, u.SwitchStack, u.DNSPolicy, u.RADIUSProfile, u.TrafficMatchingList, u.HotspotVoucher, u.DPIApplication, u.DPICategory, u.PendingDevice, u.Country, + u.UNASDevice, } { v := reflect.Indirect(reflect.ValueOf(f)) @@ -839,6 +842,12 @@ func (u *promUnifi) loopExports(r report) { } } + for _, nd := range m.UNASDevices { + if v, ok := nd.(*unifi.UNASDevice); ok { + u.exportUNASDevice(r, v) + } + } + for _, wb := range m.WifiBroadcasts { if v, ok := wb.(*unifi.WifiBroadcast); ok { u.exportWifiBroadcast(r, v) diff --git a/pkg/promunifi/unas.go b/pkg/promunifi/unas.go new file mode 100644 index 00000000..cd63579d --- /dev/null +++ b/pkg/promunifi/unas.go @@ -0,0 +1,209 @@ +package promunifi + +import ( + "github.com/prometheus/client_golang/prometheus" + "github.com/unpoller/unifi/v5" +) + +// unasDevice holds Prometheus descriptors for UNAS Pro storage console metrics. +// +// The struct is deliberately flat even though the metrics come from five different entities +// (console, network IO, pool, RAID group, disk, share) with different label sets: Describe +// reflects over the members of this struct and sends each one to the channel, so a nested +// sub-struct would be sent as something that is not a *prometheus.Desc. +type unasDevice struct { + // Console. + Presence *prometheus.Desc + CPULoad *prometheus.Desc + CPUTemperature *prometheus.Desc + MemoryTotal *prometheus.Desc + MemoryFree *prometheus.Desc + MemoryAvail *prometheus.Desc + ReceiveKBPS *prometheus.Desc + TransmitKBPS *prometheus.Desc + // Storage pools and their RAID groups. + PoolCapacity *prometheus.Desc + PoolUsage *prometheus.Desc + RaidCurrentProtection *prometheus.Desc + RaidExpectedProtection *prometheus.Desc + RaidProgress *prometheus.Desc + // Physical disks. + DiskSize *prometheus.Desc + DiskTemperature *prometheus.Desc + DiskHealthScore *prometheus.Desc + DiskPowerOnHours *prometheus.Desc + DiskRPM *prometheus.Desc + DiskBadSectorCount *prometheus.Desc + DiskUncorrectableSectorCount *prometheus.Desc + DiskReadErrorRate *prometheus.Desc + DiskSmartReadErrorCount *prometheus.Desc + DiskReadKBPS *prometheus.Desc + DiskWriteKBPS *prometheus.Desc + // Shares ("drives"). + ShareQuota *prometheus.Desc + ShareUsage *prometheus.Desc + ShareMemberCount *prometheus.Desc +} + +func descUNASDevice(ns string) *unasDevice { // nolint: funlen + ns += "unas_" + device := []string{"source", "name", "model"} + console := []string{"source", "name"} + pool := []string{"source", "name", "pool_id", "pool_type", "status"} + raid := []string{"source", "name", "pool_id", "raid_group_id", "current_level", "config_level"} + disk := []string{"source", "name", "slot_id", "pool_id", "disk_type", "state", "model", "serial"} + share := []string{"source", "name", "share_id", "share_name", "share_type", "status"} + + return &unasDevice{ + Presence: prometheus.NewDesc(ns+"device_present", + "UNAS console reachable (always 1 when present)", device, nil), + CPULoad: prometheus.NewDesc(ns+"cpu_load_percent", + "UNAS console current CPU load", console, nil), + CPUTemperature: prometheus.NewDesc(ns+"cpu_temperature_celsius", + "UNAS console CPU temperature", console, nil), + MemoryTotal: prometheus.NewDesc(ns+"memory_total_bytes", + "UNAS console total memory", console, nil), + MemoryFree: prometheus.NewDesc(ns+"memory_free_bytes", + "UNAS console free memory", console, nil), + MemoryAvail: prometheus.NewDesc(ns+"memory_available_bytes", + "UNAS console available memory", console, nil), + ReceiveKBPS: prometheus.NewDesc(ns+"receive_kbps", + "UNAS console instantaneous receive throughput in KB/s", console, nil), + TransmitKBPS: prometheus.NewDesc(ns+"transmit_kbps", + "UNAS console instantaneous transmit throughput in KB/s", console, nil), + PoolCapacity: prometheus.NewDesc(ns+"pool_capacity_bytes", + "UNAS storage pool total capacity", pool, nil), + PoolUsage: prometheus.NewDesc(ns+"pool_usage_bytes", + "UNAS storage pool bytes in use", pool, nil), + RaidCurrentProtection: prometheus.NewDesc(ns+"raid_group_current_protection", + "UNAS RAID group current redundancy (compare to expected to spot a degraded array)", + raid, nil), + RaidExpectedProtection: prometheus.NewDesc(ns+"raid_group_expected_protection", + "UNAS RAID group expected redundancy", raid, nil), + RaidProgress: prometheus.NewDesc(ns+"raid_group_progress_percent", + "UNAS RAID group rebuild or expansion progress", raid, nil), + DiskSize: prometheus.NewDesc(ns+"disk_size_bytes", + "UNAS disk size", disk, nil), + DiskTemperature: prometheus.NewDesc(ns+"disk_temperature_celsius", + "UNAS disk temperature", disk, nil), + DiskHealthScore: prometheus.NewDesc(ns+"disk_health_score", + "UNAS disk health score as reported by the console", disk, nil), + DiskPowerOnHours: prometheus.NewDesc(ns+"disk_power_on_hours", + "UNAS disk SMART power-on hours", disk, nil), + DiskRPM: prometheus.NewDesc(ns+"disk_rpm", + "UNAS disk rotational speed (0 for solid state)", disk, nil), + DiskBadSectorCount: prometheus.NewDesc(ns+"disk_bad_sectors", + "UNAS disk bad sector count", disk, nil), + DiskUncorrectableSectorCount: prometheus.NewDesc(ns+"disk_uncorrectable_sectors", + "UNAS disk uncorrectable sector count", disk, nil), + DiskReadErrorRate: prometheus.NewDesc(ns+"disk_read_error_rate", + "UNAS disk SMART read error rate", disk, nil), + DiskSmartReadErrorCount: prometheus.NewDesc(ns+"disk_smart_read_errors", + "UNAS disk SMART read error count", disk, nil), + DiskReadKBPS: prometheus.NewDesc(ns+"disk_read_kbps", + "UNAS disk instantaneous read throughput in KB/s", disk, nil), + DiskWriteKBPS: prometheus.NewDesc(ns+"disk_write_kbps", + "UNAS disk instantaneous write throughput in KB/s", disk, nil), + ShareQuota: prometheus.NewDesc(ns+"share_quota_bytes", + "UNAS share quota (0 when unlimited)", share, nil), + ShareUsage: prometheus.NewDesc(ns+"share_usage_bytes", + "UNAS share bytes in use", share, nil), + ShareMemberCount: prometheus.NewDesc(ns+"share_members", + "UNAS share member count", share, nil), + } +} + +// exportUNASDevice emits every metric family for one UNAS console. +// +// Each section is guarded independently because GetUNASDevice leaves the field for a failing +// endpoint nil rather than failing the whole poll -- a console that serves storage but not +// network-io still reports its disks. +func (u *promUnifi) exportUNASDevice(r report, d *unifi.UNASDevice) { + if d == nil { + return + } + + name, model := d.Name(), d.Model() + console := []string{d.SourceName, name} + + r.send([]*metric{{u.UNASDevice.Presence, gauge, 1.0, []string{d.SourceName, name, model}}}) + + if info := d.DeviceInfo; info != nil { + r.send([]*metric{ + {u.UNASDevice.CPULoad, gauge, info.CPU.CurrentLoad, console}, + {u.UNASDevice.CPUTemperature, gauge, info.CPU.Temperature, console}, + {u.UNASDevice.MemoryTotal, gauge, info.Memory.Total, console}, + {u.UNASDevice.MemoryFree, gauge, info.Memory.Free, console}, + {u.UNASDevice.MemoryAvail, gauge, info.Memory.Available, console}, + }) + } + + if io := d.NetworkIO; io != nil { + r.send([]*metric{ + {u.UNASDevice.ReceiveKBPS, gauge, io.ReceiveKBPS, console}, + {u.UNASDevice.TransmitKBPS, gauge, io.TransmitKBPS, console}, + }) + } + + if s := d.Storage; s != nil { + u.exportUNASStorage(r, d, s) + } + + for _, share := range d.Drives { + if share == nil { + continue + } + + labels := []string{d.SourceName, name, share.ID, share.Name, share.Type, share.Status} + + r.send([]*metric{ + {u.UNASDevice.ShareQuota, gauge, share.Quota, labels}, + {u.UNASDevice.ShareUsage, gauge, share.Usage, labels}, + {u.UNASDevice.ShareMemberCount, gauge, share.MemberCount, labels}, + }) + } +} + +func (u *promUnifi) exportUNASStorage(r report, d *unifi.UNASDevice, s *unifi.UNASStorage) { + name := d.Name() + + for _, p := range s.Pools { + labels := []string{d.SourceName, name, p.ID, p.Type, p.Status} + + r.send([]*metric{ + {u.UNASDevice.PoolCapacity, gauge, p.Capacity, labels}, + {u.UNASDevice.PoolUsage, gauge, p.Usage, labels}, + }) + + for _, rg := range p.RaidGroups { + raid := []string{d.SourceName, name, p.ID, rg.ID, rg.CurrentLevel, rg.ConfigLevel} + + r.send([]*metric{ + {u.UNASDevice.RaidCurrentProtection, gauge, rg.CurrentProtection, raid}, + {u.UNASDevice.RaidExpectedProtection, gauge, rg.ExpectedProtection, raid}, + {u.UNASDevice.RaidProgress, gauge, rg.Progress, raid}, + }) + } + } + + for _, disk := range s.Disks { + labels := []string{ + d.SourceName, name, disk.SlotID, disk.PoolID, + disk.Type, disk.State, disk.Model, disk.Serial, + } + + r.send([]*metric{ + {u.UNASDevice.DiskSize, gauge, disk.Size, labels}, + {u.UNASDevice.DiskTemperature, gauge, disk.Temperature, labels}, + {u.UNASDevice.DiskHealthScore, gauge, disk.HealthScore, labels}, + {u.UNASDevice.DiskPowerOnHours, gauge, disk.PowerOnHours, labels}, + {u.UNASDevice.DiskRPM, gauge, disk.RPM, labels}, + {u.UNASDevice.DiskBadSectorCount, gauge, disk.BadSectorCount, labels}, + {u.UNASDevice.DiskUncorrectableSectorCount, gauge, disk.UncorrectableSectorCount, labels}, + {u.UNASDevice.DiskReadErrorRate, gauge, disk.ReadErrorRate, labels}, + {u.UNASDevice.DiskSmartReadErrorCount, gauge, disk.SmartReadErrorCount, labels}, + {u.UNASDevice.DiskReadKBPS, gauge, disk.ReadKBPS, labels}, + {u.UNASDevice.DiskWriteKBPS, gauge, disk.WriteKBPS, labels}, + }) + } +} diff --git a/pkg/promunifi/unas_test.go b/pkg/promunifi/unas_test.go new file mode 100644 index 00000000..16419925 --- /dev/null +++ b/pkg/promunifi/unas_test.go @@ -0,0 +1,103 @@ +//nolint:testpackage // white-box: exercises the unexported descriptors and export path. +package promunifi + +import ( + "reflect" + "testing" + + "github.com/prometheus/client_golang/prometheus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/unpoller/unifi/v5" +) + +// fakeReport collects the metrics an export function sends, and nothing else. It embeds +// *Report (nil) only to satisfy the rest of the report interface; the export path under test +// calls nothing but send. +type fakeReport struct { + *Report + + sent []*metric +} + +func (f *fakeReport) send(m []*metric) { f.sent = append(f.sent, m...) } + +func testUNASDevice() *unifi.UNASDevice { + return &unifi.UNASDevice{ + SourceName: "https://unas.example", + DeviceInfo: &unifi.UNASDeviceInfo{ + Name: "UNAS-Pro", + Model: "UNASPro", + CPU: unifi.UNASCPU{CurrentLoad: unifi.FlexInt{Val: 12}, Temperature: unifi.FlexInt{Val: 45}}, + Memory: unifi.UNASMemory{Total: unifi.FlexInt{Val: 8000}}, + }, + NetworkIO: &unifi.UNASNetworkIO{ReceiveKBPS: unifi.FlexInt{Val: 120}}, + Storage: &unifi.UNASStorage{ + Pools: []unifi.UNASPool{{ + ID: "pool-1", RaidGroups: []unifi.UNASRaidGroup{{ID: "rg-1"}}, + }}, + Disks: []unifi.UNASDisk{{SlotID: "1", Temperature: unifi.FlexInt{Val: 38}}}, + }, + Drives: []*unifi.UNASDrive{{ID: "d-1", Name: "media"}, nil}, + } +} + +// TestUNASDescriptorsAreReachableByDescribe replicates the exact reflect loop Describe uses. +// That loop skips any member which is not a *prometheus.Desc, so a nested sub-struct would be +// dropped silently rather than noisily -- and an unassigned field would be a nil descriptor. +// The loop is duplicated rather than driven through Describe itself because Describe walks +// every metric family and the descriptors are assigned inline in Run, which also starts an +// HTTP listener; there is no way to build a collector holding only this family. +func TestUNASDescriptorsAreReachableByDescribe(t *testing.T) { + t.Parallel() + + descs := descUNASDevice("unifi_") + v := reflect.Indirect(reflect.ValueOf(descs)) + got := []*prometheus.Desc{} + + for i := 0; i < v.NumField(); i++ { + desc, ok := v.Field(i).Interface().(*prometheus.Desc) + require.True(t, ok, "field %d of unasDevice is not a *prometheus.Desc; Describe would skip it", + i) + require.NotNil(t, desc, "field %d of unasDevice was never assigned by descUNASDevice", i) + + got = append(got, desc) + } + + assert.Len(t, got, v.NumField()) +} + +func TestExportUNASDevice(t *testing.T) { + t.Parallel() + + r := &fakeReport{} + u := &promUnifi{UNASDevice: descUNASDevice("unifi_")} + u.exportUNASDevice(r, testUNASDevice()) + + // presence(1) + console(5) + net io(2) + pool(2) + raid(3) + disk(11) + one share(3). + // This is the flattened total across the seven send() calls -- it equals the descriptor + // struct's field count only because this fixture fires each descriptor exactly once. Do + // not "simplify" it to reflect.NumField(): add a second pool or disk and they diverge. + assert.Len(t, r.sent, 27, "every family is emitted for a fully populated console") + + for i, m := range r.sent { + require.NotNil(t, m.Desc, "metric %d has no descriptor", i) + require.NotEmpty(t, m.Labels, "metric %d has no labels", i) + } +} + +// GetUNASDevice leaves the field for a failing endpoint nil instead of failing the whole +// poll, so every section must be independently guarded. +func TestExportUNASDeviceTolerantOfNilSections(t *testing.T) { + t.Parallel() + + u := &promUnifi{UNASDevice: descUNASDevice("unifi_")} + + r := &fakeReport{} + u.exportUNASDevice(r, &unifi.UNASDevice{SourceName: "https://unas.example"}) + assert.Len(t, r.sent, 1, "a console with no readable endpoints still reports its presence") + + empty := &fakeReport{} + u.exportUNASDevice(empty, nil) + assert.Empty(t, empty.sent) +}