From d5dfc771d50958bfa78656a4a4654364faef399e Mon Sep 17 00:00:00 2001 From: Cody Lee Date: Wed, 19 Aug 2026 07:52:37 -0500 Subject: [PATCH 1/3] 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) +} From 3d178bed8dd6a20841229040368edb813097f0f2 Mon Sep 17 00:00:00 2001 From: Cody Lee Date: Wed, 19 Aug 2026 07:54:38 -0500 Subject: [PATCH 2/3] fix: AppendMetrics silently dropped SpeedTests and the batch timestamp AppendMetrics merges every slice field on Metrics except SpeedTests, so speed test results were discarded on the way from the input to the outputs. Both call sites start from a non-nil &Metrics{}, so this hit every user on every poll: inputunifi collected the results, and the export code in promunifi, influxunifi and datadogunifi was dead. TS was dropped the same way. The aggregate starts bare and nothing restored the timestamp, so it stayed zero -- and influxunifi's collect() stamps any point that carries no timestamp of its own with the aggregate's, which meant the zero time. Both Influx clients omit a zero timestamp and let the server assign one, so the damage was limited to points being stamped on arrival rather than at poll time, but it made the fallback path meaningless. First writer wins: the earliest input's timestamp is the one that describes the batch. The failure mode here is what makes it worth guarding rather than just patching. A new metric family needs a field on Metrics and an append line in AppendMetrics, and omitting the second loses every metric in that family with no error, no log line, and a passing build. TestAppendMetricsCoversEverySliceField walks the struct by reflection and fails naming any slice field that is not merged, so a new field is covered the moment it is declared rather than when someone notices the graphs are empty. Co-Authored-By: Claude Opus 5 --- pkg/poller/inputs.go | 14 ++++++++++ pkg/poller/inputs_test.go | 59 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+) diff --git a/pkg/poller/inputs.go b/pkg/poller/inputs.go index 0c64cab6..c37816b1 100644 --- a/pkg/poller/inputs.go +++ b/pkg/poller/inputs.go @@ -315,10 +315,24 @@ func AppendMetrics(existing *Metrics, m *Metrics) *Metrics { return existing } + // The aggregate starts as a bare &Metrics{}, so without this its TS stays zero and + // influxunifi's per-point fallback (see influxdb.go collect) stamps every point that + // carries no timestamp of its own with the zero time. + // + // First writer wins, and that is whichever input's result is merged first -- in + // collectMetrics the inputs run concurrently and are drained in completion order, so with + // more than one input configured it is not deterministic which one supplies the TS. That + // is fine here: every input stamps the moment it began polling within the same tick, so + // any of them places the batch in the right poll interval. Do not read this as "earliest". + if existing.TS.IsZero() { + existing.TS = m.TS + } + existing.SitesDPI = append(existing.SitesDPI, m.SitesDPI...) existing.Sites = append(existing.Sites, m.Sites...) existing.ClientsDPI = append(existing.ClientsDPI, m.ClientsDPI...) existing.RogueAPs = append(existing.RogueAPs, m.RogueAPs...) + existing.SpeedTests = append(existing.SpeedTests, m.SpeedTests...) existing.Clients = append(existing.Clients, m.Clients...) existing.Devices = append(existing.Devices, m.Devices...) existing.CountryTraffic = append(existing.CountryTraffic, m.CountryTraffic...) diff --git a/pkg/poller/inputs_test.go b/pkg/poller/inputs_test.go index 6a41f2d1..577d7e7f 100644 --- a/pkg/poller/inputs_test.go +++ b/pkg/poller/inputs_test.go @@ -1,7 +1,9 @@ package poller_test import ( + "reflect" "testing" + "time" "github.com/unpoller/unpoller/pkg/poller" @@ -96,3 +98,60 @@ func TestCollectEventsRecoversPanickingInput(t *testing.T) { require.Error(t, err) assert.Contains(t, err.Error(), "panic-input") } + +// TestAppendMetricsCoversEverySliceField walks the Metrics struct by reflection and fails if +// any slice field is not merged by AppendMetrics. +// +// This is a guard against a silent, invisible failure mode: a new metric family needs both a +// field here and an append line there, and omitting the second loses every one of those +// metrics with no error, no log line, and a passing build. SpeedTests was lost that way -- +// collected by inputunifi and exported by all three output plugins, but never merged, so the +// export code was dead for every user. Asserting field-by-field would just repeat the bug; +// reflection means a new field is covered the moment it is declared. +func TestAppendMetricsCoversEverySliceField(t *testing.T) { + t.Parallel() + + // Put one zero-valued element in every slice, so a merged field has two and a dropped + // field has one. The element values are irrelevant -- only the count is under test. + fill := func(m *poller.Metrics) { + v := reflect.ValueOf(m).Elem() + for i := 0; i < v.NumField(); i++ { + if f := v.Field(i); f.Kind() == reflect.Slice { + f.Set(reflect.Append(f, reflect.Zero(f.Type().Elem()))) + } + } + } + + existing, incoming := &poller.Metrics{}, &poller.Metrics{} + fill(existing) + fill(incoming) + + got := reflect.ValueOf(poller.AppendMetrics(existing, incoming)).Elem() + + for i := 0; i < got.NumField(); i++ { + f := got.Field(i) + if f.Kind() != reflect.Slice { + continue + } + + assert.Equal(t, 2, f.Len(), + "Metrics.%s is missing an append line in AppendMetrics, so those metrics are silently dropped", + got.Type().Field(i).Name) + } +} + +func TestAppendMetricsPropagatesTS(t *testing.T) { + t.Parallel() + + first := time.Now().Add(-time.Minute) + second := time.Now() + + // The aggregate starts bare, so it must adopt the first input's timestamp -- otherwise it + // stays zero and influxunifi stamps untimed points with the zero time. + got := poller.AppendMetrics(&poller.Metrics{}, &poller.Metrics{TS: first}) + assert.Equal(t, first, got.TS) + + // A timestamp already set wins: it marks when the batch began. + got = poller.AppendMetrics(&poller.Metrics{TS: first}, &poller.Metrics{TS: second}) + assert.Equal(t, first, got.TS) +} From 01ac7ca2b732cbe92b0e36ac541a8b02c5ae4834 Mon Sep 17 00:00:00 2001 From: Cody Lee Date: Wed, 19 Aug 2026 09:34:02 -0500 Subject: [PATCH 3/3] refactor(unas): replace disable flag with enable, defaulting to off `disable = false` is a double negative, and a bool named disable cannot express opt-in anyway: it zero-values to false, so the flag was inert and opt-in rested entirely on the device list being empty. `enable` defaults to false and is now the real gate -- Initialize, Metrics and DebugInput all return early unless it is set. The two existing guards remain: an empty device list is still a no-op, and no default URL is ever synthesized. Configuring devices while enable is false is always a mistake, so that combination logs one error instead of silently collecting nothing. Adds binding tests for the flag across toml, json, yaml and UP_UNAS_ENABLE (the env name derives from the xml tag, not the json one), plus a test that all three shipped examples default to off. Both were verified by mutation: breaking a struct tag or flipping an example fails the suite. Co-Authored-By: Claude Opus 5 --- docs/plan-unas-support.md | 18 +++---- examples/up.conf.example | 6 +-- examples/up.json.example | 4 +- examples/up.yaml.example | 2 +- pkg/inputunas/README.md | 11 +++-- pkg/inputunas/config.go | 8 ++-- pkg/inputunas/input.go | 24 ++++++---- pkg/inputunas/input_test.go | 93 ++++++++++++++++++++++++++++++++++--- 8 files changed, 129 insertions(+), 37 deletions(-) diff --git a/docs/plan-unas-support.md b/docs/plan-unas-support.md index f8235d7b..2b7a6bf7 100644 --- a/docs/plan-unas-support.md +++ b/docs/plan-unas-support.md @@ -9,8 +9,8 @@ alexgreenbank in the package header and README. 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. +`Controller`. So: a new `pkg/inputunas` plugin with its own `[unas]` config block, off by +default and a no-op when unconfigured. ### The one real blocker @@ -130,11 +130,13 @@ 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. +1. **Opt-in is `enable`, defaulting to false — not `disable`.** A field named `disable` + zero-values to false, so it cannot make a plugin default-off and reads as a double + negative; `enable bool` defaults to off and says what it does. Two further guards: + `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. Devices configured while `enable` is false log one error, + since that combination is always a mistake. 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 @@ -156,7 +158,7 @@ Refinements over the plan below: ```toml [unas] - disable = false + enable = true [unas.defaults] user = "unpoller" pass = "" diff --git a/examples/up.conf.example b/examples/up.conf.example index 04eb2a88..7120125f 100644 --- a/examples/up.conf.example +++ b/examples/up.conf.example @@ -275,13 +275,13 @@ # 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. +# This section is entirely optional. The plugin is off unless you set enable = true and +# add at least one [[unas.device]] below; otherwise it 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 +# enable = true # # Defaults applied to every device that does not set its own value. #[unas.defaults] diff --git a/examples/up.json.example b/examples/up.json.example index 8a3c941e..b167eb1f 100644 --- a/examples/up.json.example +++ b/examples/up.json.example @@ -87,8 +87,8 @@ }, "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, + "//": "UNAS Pro consoles. JSON cannot carry comments, so this section ships off: it shows the shape, and setting enable to true with a real url turns it on.", + "enable": false, "defaults": { "user": "unpoller", "pass": "", diff --git a/examples/up.yaml.example b/examples/up.yaml.example index 7767666b..67b3dfd2 100644 --- a/examples/up.yaml.example +++ b/examples/up.yaml.example @@ -91,7 +91,7 @@ unifi: # 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 +# enable: true # defaults: # user: "unpoller" # pass: "" diff --git a/pkg/inputunas/README.md b/pkg/inputunas/README.md index 85c5cff3..c4039c25 100644 --- a/pkg/inputunas/README.md +++ b/pkg/inputunas/README.md @@ -2,9 +2,10 @@ 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. +**This plugin is opt-in: `enable` defaults to `false`.** It does nothing until you set +`enable = true` *and* configure at least one device. With no `[unas]` section it stays silent +and inert — it logs nothing and polls nothing. If you configure devices but leave `enable` +unset, it logs one error saying so rather than failing quietly. ## Why a separate plugin? @@ -17,7 +18,7 @@ rather than as another UniFi controller. ```toml [unas] - disable = false + enable = true # Applied to any device that does not set its own value. [unas.defaults] @@ -44,7 +45,7 @@ tables. See `examples/up.{conf,json,yaml}.example`. | Variable | Meaning | |---|---| -| `UP_UNAS_DISABLE` | Disable the plugin outright. | +| `UP_UNAS_ENABLE` | Turn the plugin on. Defaults to `false`. | | `UP_UNAS_DEFAULT_USER` | Default username for every device. | | `UP_UNAS_DEFAULT_PASS` | Default password. | | `UP_UNAS_DEFAULT_VERIFY_SSL` | Default TLS verification. | diff --git a/pkg/inputunas/config.go b/pkg/inputunas/config.go index 76e45c6d..b60c8c91 100644 --- a/pkg/inputunas/config.go +++ b/pkg/inputunas/config.go @@ -46,9 +46,9 @@ type Device struct { // 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"` + 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 @@ -64,7 +64,7 @@ func init() { // nolint: gochecknoinits // 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. +// 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 diff --git a/pkg/inputunas/input.go b/pkg/inputunas/input.go index 8897b56c..af7db324 100644 --- a/pkg/inputunas/input.go +++ b/pkg/inputunas/input.go @@ -21,18 +21,26 @@ var ErrNoDevices = errors.New("no UNAS devices configured") // Satisfies poller.Input interface. func (u *InputUNAS) Initialize(l poller.Logger) error { if u.Config == nil { - u.Config = &Config{Disable: true} + u.Config = &Config{} } - if u.Logger = l; u.Disable { + u.Logger = l + + // enable defaults to false, so the plugin is off until an operator asks for it. Warn when + // consoles are configured but the switch was never flipped -- that combination is always a + // mistake, and silence would leave the operator hunting for missing metrics. An operator + // who has never heard of UNAS has no devices either, and still sees nothing. + if !u.Enable { + if len(u.configuredDevices()) > 0 { + u.LogErrorf("UNAS devices are configured but unas.enable is false; not polling them") + } + 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. + // Enabled with nothing to poll: nothing to say, and nothing to do. if len(u.Devices) == 0 { return nil } @@ -96,7 +104,7 @@ func (u *InputUNAS) logDevice(d *Device) { // 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 { + if !u.Enable { return nil, nil } @@ -234,7 +242,7 @@ func (u *InputUNAS) RawMetrics(filter *poller.Filter) ([]byte, error) { // 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 { + if u == nil || u.Config == nil || !u.Enable { return true, nil } @@ -294,7 +302,7 @@ func formatConfig(config *Config) *Config { CertPaths: config.Default.CertPaths, Timeout: config.Default.Timeout, }, - Disable: config.Disable, + Enable: config.Enable, Devices: devices, } } diff --git a/pkg/inputunas/input_test.go b/pkg/inputunas/input_test.go index b8f53024..252e1a5c 100644 --- a/pkg/inputunas/input_test.go +++ b/pkg/inputunas/input_test.go @@ -3,6 +3,8 @@ package inputunas_test import ( "net/http" "net/http/httptest" + "os" + "path/filepath" "sync/atomic" "testing" @@ -11,6 +13,8 @@ import ( "github.com/unpoller/unifi/v5" "github.com/unpoller/unpoller/pkg/inputunas" "github.com/unpoller/unpoller/pkg/poller" + "golift.io/cnfg" + "golift.io/cnfgfile" ) const ( @@ -76,7 +80,7 @@ func newInput(t *testing.T, urls ...string) *inputunas.InputUNAS { devices[i] = &inputunas.Device{URL: u, User: "unpoller", Pass: "secret"} } - return &inputunas.InputUNAS{Config: &inputunas.Config{Devices: devices}} + return &inputunas.InputUNAS{Config: &inputunas.Config{Enable: true, Devices: devices}} } func TestMetricsCollectsConsole(t *testing.T) { @@ -155,15 +159,25 @@ func TestMetricsPartialFailureStillReportsHealthyConsole(t *testing.T) { require.Len(t, m.UNASDevices, 1) } -func TestMetricsDisabled(t *testing.T) { +// enable defaults to false, so a config that names consoles but never sets it must not poll +// them. Configuring the devices here is the point: an empty config would pass even if the +// switch were ignored entirely. +func TestNotEnabled(t *testing.T) { t.Parallel() - u := &inputunas.InputUNAS{Config: &inputunas.Config{Disable: true}} + srv := (&unasServer{}).start(t) + u := &inputunas.InputUNAS{Config: &inputunas.Config{ + Devices: []*inputunas.Device{{URL: srv.URL, User: "unpoller", Pass: "secret"}}, + }} require.NoError(t, u.Initialize(nil)) m, err := u.Metrics(nil) require.NoError(t, err) - require.Nil(t, m) + require.Nil(t, m, "enable is false, so nothing is polled") + + ok, err := u.DebugInput() + require.NoError(t, err) + require.True(t, ok, "--debugio must not reach a console the operator has not enabled") } // With no [unas] block the plugin must be silent and inert: that is what makes UNAS support @@ -176,7 +190,7 @@ func TestUnconfiguredIsInert(t *testing.T) { m, err := u.Metrics(nil) require.NoError(t, err) - require.Nil(t, m, "an unconfigured plugin disables itself") + require.Nil(t, m, "an unconfigured plugin stays off") ok, err := u.DebugInput() require.NoError(t, err) @@ -186,7 +200,7 @@ func TestUnconfiguredIsInert(t *testing.T) { func TestDeviceWithNoURLIsIgnored(t *testing.T) { t.Parallel() - u := &inputunas.InputUNAS{Config: &inputunas.Config{Devices: []*inputunas.Device{{}, nil}}} + u := &inputunas.InputUNAS{Config: &inputunas.Config{Enable: true, Devices: []*inputunas.Device{{}, nil}}} require.NoError(t, u.Initialize(nil)) m, err := u.Metrics(nil) @@ -217,3 +231,70 @@ func TestRawMetrics(t *testing.T) { _, err = u.RawMetrics(&poller.Filter{Unit: 9}) require.ErrorIs(t, err, inputunas.ErrNoDevices) } + +// The enable switch has to survive four independent binding paths -- toml, json, yaml and the +// UP_ environment -- each driven by its own struct tag. A typo in any one tag leaves the +// plugin silently off for users of that format, which is the same class of invisible failure +// as a missing tag anywhere else in this config. Note the env name derives from the xml tag, +// not the json one. +func TestEnableBindsInEveryConfigFormat(t *testing.T) { + t.Parallel() + + write := func(t *testing.T, name, body string) string { + t.Helper() + + path := filepath.Join(t.TempDir(), name) + require.NoError(t, os.WriteFile(path, []byte(body), 0o600)) + + return path + } + + for _, tc := range []struct { + name string + file string + body string + }{ + {"toml", "up.conf", "[unas]\n enable = true\n[[unas.device]]\n url = \"https://nas\"\n"}, + {"json", "up.json", `{"unas":{"enable":true,"devices":[{"url":"https://nas"}]}}`}, + {"yaml", "up.yaml", "unas:\n enable: true\n devices:\n - url: \"https://nas\"\n"}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + input := &inputunas.InputUNAS{Config: &inputunas.Config{}} + require.NoError(t, cnfgfile.Unmarshal(input, write(t, tc.file, tc.body))) + assert.True(t, input.Enable, "enable did not bind from %s", tc.name) + require.Len(t, input.Devices, 1) + assert.Equal(t, "https://nas", input.Devices[0].URL) + }) + } +} + +// Not parallel: t.Setenv is incompatible with a parallel test or parent. +// +// The env variable name comes from the xml tag, not the json one, which is why this is worth +// asserting rather than assuming from the toml/json/yaml cases above. +func TestEnableBindsFromEnvironment(t *testing.T) { + t.Setenv("UP_UNAS_ENABLE", "true") + + input := &inputunas.InputUNAS{Config: &inputunas.Config{}} + _, err := cnfg.UnmarshalENV(input, "UP") + require.NoError(t, err) + assert.True(t, input.Enable, "UP_UNAS_ENABLE did not bind") +} + +// The shipped examples must all default to off, so that installing unpoller never starts +// polling a storage console nobody asked for. +func TestShippedExamplesDoNotEnableUNAS(t *testing.T) { + t.Parallel() + + for _, name := range []string{"up.conf.example", "up.json.example", "up.yaml.example"} { + t.Run(name, func(t *testing.T) { + t.Parallel() + + input := &inputunas.InputUNAS{Config: &inputunas.Config{}} + require.NoError(t, cnfgfile.Unmarshal(input, filepath.Join("..", "..", "examples", name))) + assert.False(t, input.Enable, "%s ships with UNAS enabled", name) + }) + } +}