diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b3837ed..d8cd9a1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -91,15 +91,24 @@ jobs: - run: mix credo --strict test: - name: Tests (${{ matrix.os }} / OTP ${{ matrix.otp }} / Elixir ${{ matrix.elixir }}) + name: Tests (OTP ${{ matrix.otp }} / Elixir ${{ matrix.elixir }}) runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: include: + # Minimum supported: Elixir 1.18 (stdlib JSON) on OTP 26. + - os: ubuntu-latest + elixir: "1.18.0" + otp: "26.2.5" + # Canonical combo also used by format / credo / dialyzer / publish. - os: ubuntu-latest elixir: "1.18.3" otp: "27.2" + # Latest in the 1.18 line on the latest OTP. + - os: ubuntu-latest + elixir: "1.18.4" + otp: "27.2" steps: - uses: actions/checkout@v6 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..e2e45ac --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,202 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +- `cert_fingerprints` option on `UnifiApi.new/1` for SHA-256 certificate + pinning. Set this to a list of fingerprints (`"sha256:AB:CD:..."`, + `"AB:CD:..."`, or plain 64-char hex) to verify the controller's + self-signed certificate without disabling TLS validation entirely. + Overrides `:verify_ssl` when present. Also configurable via + `config :unifi_api, :cert_fingerprints, [...]`. +- `UnifiApi.Auth.Cookie.login/4` for cookie + CSRF authentication. Use this + to access endpoints that Ubiquiti has not yet exposed under `x-api-key` + (events, alarms, IDS, anomalies, historical clients, DPI, topology, …) + and on Cloud Key controllers without an API key. Supports both + `:udm` (`/api/auth/login`) and `:cloud_key` (`/api/login`) styles. + Also exposes `refresh_csrf/2`, `csrf_token/1`, and `logout/2`. + CSRF rotation is **not** auto-handled in this release — see the module + docs for trade-offs. +- `UnifiApi.detect/1` probes `GET /` and reports whether the controller is + UniFi OS (`:udm`) or a standalone / Cloud Key (`:cloud_key`), returning a + bundle with `network_prefix`, `protect_prefix`, `v1_prefix`, and + `auth_path`. Heuristic mirrors `unpoller/unpoller`. +- `UnifiApi.Client.v1_prefix/0` and matching `:v1_path` config key + (default `/proxy/network`, override with `""` for Cloud Key or + `UNIFI_V1_PATH`) to support the legacy `/api/s/{site}/...` endpoints. +- `UnifiApi.Client.get_v1/3` — generic GET that unwraps the + `%{"meta" => %{"rc" => "ok"}, "data" => [...]}` envelope used by every + legacy v1 endpoint, surfacing `meta.rc == "error"` as + `{:error, {:unifi_error, msg}}`. +- `:params` passthrough on all `Client.{get,post,put,patch,delete}/3` + for arbitrary query params (used by v1 modules to send `_start`, + `_limit`, `within`, etc. without polluting the integration-API param + builder). +- New v1 / v2 endpoint modules (require cookie + CSRF auth). Full Phase 3 + surface, mirroring `unpoller/unpoller`: + - `UnifiApi.Network.Events` — `/api/s/{site}/stat/event`. + - `UnifiApi.Network.Alarms` — `/api/s/{site}/list/alarm` plus + `archive/3`. + - `UnifiApi.Network.Anomalies` — `/api/s/{site}/stat/anomalies`. + - `UnifiApi.Network.IDS` — `/api/s/{site}/stat/ips/event`. + - `UnifiApi.Network.RogueAP` — `/api/s/{site}/stat/rogueap` and + `/rest/rogueknown`. + - `UnifiApi.Network.ClientsLive` — `/api/s/{site}/stat/sta` (rich + wireless stats) plus `list_all/3` for `/stat/alluser`. + - `UnifiApi.Network.ClientsHistory` — + `/v2/api/site/{site}/clients/history`. + - `UnifiApi.Network.DPI` — `/api/s/{site}/stat/sitedpi` and + `/stat/stadpi`. + - `UnifiApi.Network.Traffic` — `/v2/api/site/{site}/traffic` and + `/country-traffic`. + - `UnifiApi.Network.SystemLog` — + `/v2/api/site/{site}/system-log/all`. + - `UnifiApi.Network.ActiveLeases` — + `/v2/api/site/{site}/active-leases`. + - `UnifiApi.Network.WAN` — `/wan/enriched-configuration`, + `/wan/{id}/isp-status`, `/wan/load-balancing`, `/wan-slas`. + - `UnifiApi.Network.PortAnomalies` — + `/v2/api/site/{site}/ports/port-anomalies`. + - `UnifiApi.Network.UPS` — `/api/s/{site}/stat/ups-devices`. + - `UnifiApi.Network.PortForward` — full CRUD for + `/api/s/{site}/rest/portforward`. + - `UnifiApi.Network.Dashboard` — + `/v2/api/site/{site}/aggregated-dashboard?historySeconds=N`. + - `UnifiApi.Network.Topology` — `/v2/api/site/{site}/topology`. + - `UnifiApi.Protect.Events` — `/proxy/protect/api/events`, + `/api/events/{id}/thumbnail` (binary JPEG), `/api/events/system-logs`. +- `UnifiApi.Formatter` shortcuts for the new modules: `events/1`, + `alarms/1` (severity-coloured), `clients_live/1`, `anomalies/1`. Plus + new `:subsystem` and `:severity` colour rules on `table/3`. +- New examples scripts under `examples/`: + - `operational.exs` — cookie auth + recent events + active alarms + + worst-RSSI clients, with `UnifiApi.detect/1` controller probe. + - `protect_events.exs` — pulls Protect motion / smartDetect events + from the last hour and saves each thumbnail as a JPEG. +- README "Multiple Controllers" section with parallel `Task.async_stream` + pattern and a per-controller path-config recipe for mixed UDM / + Cloud Key fleets. +- `UnifiApi.Auth.Session` — supervised GenServer that holds cookie + + CSRF auth state and auto-rotates the CSRF token from response + headers. Add it to your supervision tree once and call + `Session.client/1` to get a `Req.Request` whose request steps pull + the latest auth state at send time. Closes out the deferred + auto-refresh story in `UnifiApi.Auth.Cookie`. +- `UnifiApi.Client.stream_v1/3` — paginates legacy v1 endpoints via + `_start` / `_limit`, mirroring `Client.stream/3`. Added + `stream/3` variants on `UnifiApi.Network.Events`, `Alarms`, and + `IDS`. +- `UnifiApi.Network.DPI.with_names/2` — joins numeric `cat` / `app` + IDs against `Resources.list_dpi_categories/1` and + `list_dpi_applications/1`, populating `category_name` and + `application_name` on every `by_cat` / `by_app` entry. +- `UnifiApi.Formatter` numeric colour rules `:rssi` (signal-strength + buckets) and `:satisfaction` (UniFi 0..100 score). Wired into the + `clients_live/1` shortcut so the `signal` and `satisfaction` columns + render colour-coded by value. +- CI: the `test` job now runs across three Elixir / OTP combinations + (`1.18.0` on OTP 26.2.5, `1.18.3` on OTP 27.2, `1.18.4` on OTP 27.2) + to catch compat regressions across the supported floor. +- `UnifiApi.Client.stream_paged/2` — generic page-number paginator + (`pageSize` / `pageNumber` style) for endpoints that don't fit the + integration `offset`/`limit` or v1 `_start`/`_limit` patterns. Used + by the new v2 streams below. +- `UnifiApi.Network.ClientsHistory.stream/3` and + `UnifiApi.Network.SystemLog.stream/3` — auto-paginated lazy streams, + closing the last gaps in the pagination audit. +- `UnifiApi.ping/1` — auth-agnostic `GET /` reachability check. +- `UnifiApi.Time` — `now_ms/0`, `minutes_ago/1`, `hours_ago/1`, + `days_ago/1` for the unix-millisecond timestamp params used by + `Protect.Events`, `Network.Traffic`, etc. +- `UnifiApi.Network.Sites.find_by_name/2` and + `find_by_internal_reference/2` — resolve a site map by human-readable + name or controller slug without writing + `Sites.list(client) |> Enum.find(...)` boilerplate. +- README: expanded "Self-Signed Certificates" section covering all three + TLS modes (`verify_ssl: false`, fingerprint pinning, real CA), with an + `openssl` recipe for extracting the fingerprint. + +### Notes + +- `UnifiApi.Auth.Cookie` and `UnifiApi.detect/1` are unit-tested against + mocked `Req.Test` plugs but not yet exercised end-to-end against live + UDM Pro and Cloud Key hardware. Please file an issue with controller + model and firmware version if you encounter shape mismatches. + +## [0.3.0] - 2026-05-02 + +> **Upgrading from 0.2.x?** See [UPGRADING.md](UPGRADING.md) for a step-by-step +> migration guide with before/after examples and a search-and-replace cheat +> sheet for the breaking change below. + +### Added + +- Typed errors: `UnifiApi.RateLimitError` (with parsed `Retry-After`) and + `UnifiApi.AuthError` are now returned for 429, 401, and 403 responses, so + callers can pattern-match without inspecting the status tuple. +- Runnable example scripts under `examples/` (`quickstart.exs`, + `dashboard.exs`, `snapshots.exs`). +- `CHANGELOG.md` is now bundled in the generated docs. +- README: status badges, "Self-signed certificates" section, expanded error + handling docs with the new typed errors and a 0.2.x → 0.3.0 migration note. +- `UnifiApi.Network.Devices` `@moduledoc` now documents response fields and + the shape returned by `get_statistics/3`. + +### Changed + +- **Breaking:** 401, 403, and 429 responses now return exception structs + (`%UnifiApi.AuthError{}` / `%UnifiApi.RateLimitError{}`) instead of + `{:error, {status, body}}` tuples. The motivation is twofold: pattern + matching on specific HTTP status numbers leaks transport-level concerns + into caller code, and the parsed `Retry-After` (clamped 1..300s) lets + pollers back off correctly without re-parsing the response. Callers + matching `{:error, {401, _}}`, `{:error, {403, _}}`, or `{:error, {429, _}}` + must update to match the new structs — see [UPGRADING.md](UPGRADING.md). + Other non-2xx responses still return `{:error, {status, body}}`. Catch-all + `{:error, _}` matches are unaffected. +- `mix.exs` package metadata: added `maintainers`, `Changelog` and `Upgrading` + links, and bundled `CHANGELOG.md` + `UPGRADING.md` in `docs.extras`. + +## [0.2.0] - 2026-04-30 + +### Added + +- Stream-based auto-pagination via `Stream.resource/3` for every list endpoint + (`UnifiApi.Network.Devices.stream/3`, `Clients.stream/3`, etc.). +- ANSI formatter (`UnifiApi.Formatter`) for printing API responses as colored + tables in IEx, with shortcuts for devices/clients/cameras/networks/sites. +- UDM proxy path support: `Client.network_prefix/0` and + `Client.protect_prefix/0` default to `/proxy/network/integration` and + `/proxy/protect/integration`; override with `network_path` / + `protect_path` config (or `UNIFI_NETWORK_PATH` / `UNIFI_PROTECT_PATH` env). +- Comprehensive dashboard data scraper recipe in the README. +- CI/CD pipeline: format check, Credo strict, Dialyzer, ExUnit on Elixir + 1.18.3 / OTP 27.2, automated Hex publish on tags. +- Full `@spec` coverage and `@moduledoc` / `@doc` for every public function. + +### Changed + +- Bumped Elixir requirement to `~> 1.18`. +- Replaced `Jason` with the Elixir 1.18 stdlib `JSON` module. +- Formatter now correctly handles wrapped (`%{"data" => [...]}`) responses. + +## [0.1.0] + +### Added + +- Initial implementation of the UniFi Network and Protect API client over + Req, with API-key authentication and the core Network (Sites, Devices, + Clients, Networks, Wifi, Firewall, Hotspot, ACL, DNS, TrafficMatching, + Resources) and Protect (Cameras, NVR, Sensors, Lights, Chimes, Viewers, + Liveviews) modules. + +[Unreleased]: https://github.com/nyo16/unifi_api/compare/v0.3.0...HEAD +[0.3.0]: https://github.com/nyo16/unifi_api/compare/v0.2.0...v0.3.0 +[0.2.0]: https://github.com/nyo16/unifi_api/compare/v0.1.0...v0.2.0 +[0.1.0]: https://github.com/nyo16/unifi_api/releases/tag/v0.1.0 diff --git a/README.md b/README.md index d949be5..bf5a3bc 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,10 @@ # UnifiApi +[![CI](https://github.com/nyo16/unifi_api/actions/workflows/ci.yml/badge.svg)](https://github.com/nyo16/unifi_api/actions/workflows/ci.yml) +[![Hex.pm](https://img.shields.io/hexpm/v/unifi_api.svg)](https://hex.pm/packages/unifi_api) +[![HexDocs](https://img.shields.io/badge/hex-docs-blue.svg)](https://hexdocs.pm/unifi_api) +[![License](https://img.shields.io/hexpm/l/unifi_api.svg)](https://github.com/nyo16/unifi_api/blob/master/LICENSE) + Elixir HTTP client for **UniFi Dream Machine** APIs, covering both the **Network API** (v10.1.84) and the **Protect API** (v6.2.88). Built on [Req](https://hexdocs.pm/req). ## Installation @@ -9,7 +14,7 @@ Add `unifi_api` to your list of dependencies in `mix.exs`: ```elixir def deps do [ - {:unifi_api, "~> 0.2.0"} + {:unifi_api, "~> 0.3.0"} ] end ``` @@ -93,6 +98,32 @@ client = UnifiApi.new(base_url: "https://192.168.1.1", api_key: "my-key") {:ok, cameras} = UnifiApi.Protect.Cameras.list(client) ``` +## Quality-of-Life Helpers + +A few small utilities that show up everywhere: + +```elixir +# Reachability — works against both API-key and cookie-authed clients +:ok = UnifiApi.ping(client) + +# Detect controller style and the matching path conventions +{:ok, %{style: :udm, network_prefix: _, v1_prefix: _, auth_path: _}} = + UnifiApi.detect(client) + +# Resolve a site by display name without listing manually +{:ok, %{"id" => site_id}} = UnifiApi.Network.Sites.find_by_name(client, "HQ") +{:ok, default} = UnifiApi.Network.Sites.find_by_internal_reference(client, "default") + +# Unix-millisecond helpers for time-window queries +import UnifiApi.Time + +UnifiApi.Protect.Events.list(authed, + start: hours_ago(1), + end: now_ms(), + types: ["motion"] +) +``` + ## Network API All Network API functions require a `site_id` (except `Info`, `Resources.list_dpi_categories/2`, `Resources.list_dpi_applications/2`, `Resources.list_countries/2`, and `Devices.list_pending/2`). @@ -371,6 +402,150 @@ File.write!("snapshot.jpg", jpeg) # Each has: id, name, state, cameraIds, ringSettings ``` +## Operational Data (Legacy v1 / v2 API) + +The integration API doesn't expose the operational and monitoring data +ops users actually want — events, alarms, IDS detections, rich live +wireless stats, topology, traffic, WAN health, and so on. These live on +the legacy `/api/s/{site}/...` and `/v2/api/site/{site}/...` paths and +require **cookie + CSRF authentication** rather than `x-api-key`. + +### Authenticate (one-shot scripts) + +```elixir +# Build an unauthenticated client, then log in. +client = UnifiApi.new(base_url: "https://192.168.1.1", verify_ssl: false) + +{:ok, authed} = UnifiApi.Auth.Cookie.login(client, "admin", "password", + style: :udm # or :cloud_key +) +``` + +If you're not sure which style your controller uses, probe it first: + +```elixir +{:ok, info} = UnifiApi.detect(client) +{:ok, authed} = UnifiApi.Auth.Cookie.login(client, user, pass, style: info.style) +``` + +For Cloud Key controllers, also set +`Application.put_env(:unifi_api, :v1_path, "")` (default is +`/proxy/network` for UDM). + +### Authenticate (long-running app) + +For pollers and supervised processes that need cookie auth over hours +or days, `UnifiApi.Auth.Cookie.login/4`'s static request struct goes +stale when the controller rotates the CSRF token. Use +`UnifiApi.Auth.Session` instead — a supervised GenServer that holds +the cookie + CSRF state and auto-rotates the token from response +headers: + +```elixir +children = [ + {UnifiApi.Auth.Session, + name: MyApp.UnifiSession, + client: UnifiApi.new(base_url: "https://192.168.1.1", verify_ssl: false), + username: System.fetch_env!("UNIFI_USERNAME"), + password: System.fetch_env!("UNIFI_PASSWORD"), + style: :udm} +] + +Supervisor.start_link(children, strategy: :one_for_one) + +# Anywhere in your app: +authed = UnifiApi.Auth.Session.client(MyApp.UnifiSession) +{:ok, events} = UnifiApi.Network.Events.list(authed, "default") +``` + +Every request through `authed` pulls the current cookies + CSRF from +the GenServer at send time and writes back any rotated token captured +from the response. `Session.refresh/1` and `Session.relogin/1` are +escape hatches for the rare case the auto-rotation misses. + +### Available modules + +| Module | Endpoint | Purpose | +|--------|----------|---------| +| `Network.Events` | `/stat/event` | Client/AP/system events | +| `Network.Alarms` | `/list/alarm` | Active and archived alarms | +| `Network.Anomalies` | `/stat/anomalies` | Diagnostic anomalies | +| `Network.IDS` | `/stat/ips/event` | IDS / IPS detections | +| `Network.RogueAP` | `/stat/rogueap`, `/rest/rogueknown` | Neighbouring / rogue APs | +| `Network.ClientsLive` | `/stat/sta`, `/stat/alluser` | Rich wireless stats; offline history | +| `Network.ClientsHistory` | `/v2/.../clients/history` | Searchable client history | +| `Network.DPI` | `/stat/sitedpi`, `/stat/stadpi` | DPI by site / per-client | +| `Network.Traffic` | `/v2/.../traffic`, `/country-traffic` | Time-series by client / country | +| `Network.SystemLog` | `/v2/.../system-log/all` | Controller system log | +| `Network.ActiveLeases` | `/v2/.../active-leases` | Live DHCP table | +| `Network.WAN` | `/v2/.../wan/...`, `/wan-slas` | WAN config, ISP status, SLAs | +| `Network.PortAnomalies` | `/v2/.../ports/port-anomalies` | Switch port anomalies | +| `Network.UPS` | `/stat/ups-devices` | UPS battery / load | +| `Network.PortForward` | `/rest/portforward` | NAT port forward CRUD | +| `Network.Dashboard` | `/v2/.../aggregated-dashboard` | One-shot dashboard payload | +| `Network.Topology` | `/v2/.../topology` | Topology graph | +| `Protect.Events` | `/proxy/protect/api/events` | Motion, ring, smartDetect events + thumbnails | + +### Quick example + +```elixir +# Recent events (last 24 hours, up to 1000) +{:ok, events} = UnifiApi.Network.Events.list(authed, "default", + within_hours: 24, limit: 1000) + +# Worst-RSSI wireless clients right now +{:ok, clients} = UnifiApi.Network.ClientsLive.list(authed, "default") +worst = + clients + |> Enum.reject(& &1["is_wired"]) + |> Enum.sort_by(& &1["signal"]) + |> Enum.take(10) + +# All Protect motion events in the last hour, with thumbnails +hour_ago = System.os_time(:millisecond) - 60 * 60 * 1000 +{:ok, motion} = + UnifiApi.Protect.Events.list(authed, start: hour_ago, types: ["motion"]) + +for ev <- motion do + {:ok, jpeg} = UnifiApi.Protect.Events.thumbnail(authed, ev["id"]) + File.write!("event-#{ev["id"]}.jpg", jpeg) +end +``` + +### DPI with names + +The legacy DPI endpoints return numeric `cat` and `app` IDs only. +Combine them with the integration-API category / application lists +via `UnifiApi.Network.DPI.with_names/2`: + +```elixir +# These don't change often — fetch once, reuse: +{:ok, categories} = UnifiApi.Network.Resources.list_dpi_categories(client) +{:ok, applications} = UnifiApi.Network.Resources.list_dpi_applications(client) + +# Then on every poll: +{:ok, dpi} = UnifiApi.Network.DPI.by_site(authed, "default") + +named = + UnifiApi.Network.DPI.with_names(dpi, + categories: categories, + applications: applications + ) + +# Top 10 apps by tx_bytes +named +|> Enum.flat_map(& &1["by_app"]) +|> Enum.sort_by(& &1["tx_bytes"], :desc) +|> Enum.take(10) +|> Enum.map(&{&1["application_name"], &1["tx_bytes"]}) +``` + +> **Note:** v1 / v2 endpoint shapes are documented from community +> sources (primarily `unpoller/unpoller`). They have not been exercised +> end-to-end against live UDM Pro / Cloud Key hardware in v0.3.0. Please +> file an issue with controller model and firmware version if anything +> looks off — most fixes will be one-line tweaks. + ## Streaming & Pagination Every list endpoint has a `stream` variant that returns a lazy `Stream` powered by @@ -418,6 +593,8 @@ Stream functions raise on API errors, making them safe to compose in pipelines. ### Available stream functions +Integration API (offset / limit): + | Module | Function | |--------|----------| | Sites | `stream/2` | @@ -432,6 +609,36 @@ Stream functions raise on API errors, making them safe to compose in pipelines. | TrafficMatching | `stream/3` | | Resources | `stream_wans/3`, `stream_vpn_tunnels/3`, `stream_vpn_servers/3`, `stream_radius_profiles/3`, `stream_device_tags/3`, `stream_dpi_categories/2`, `stream_dpi_applications/2`, `stream_countries/2` | +Operational v1 API (`_start` / `_limit`, requires cookie auth): + +| Module | Function | +|--------|----------| +| Events | `stream/3` (with `:within_hours`) | +| Alarms | `stream/3` (with `:archived`) | +| IDS | `stream/3` (with `:within_hours`) | + +Operational v2 API (`pageSize` / `pageNumber`, requires cookie auth): + +| Module | Function | +|--------|----------| +| ClientsHistory | `stream/3` (with `:within_hours`, `:type`, `:search`) | +| SystemLog | `stream/3` | + +```elixir +# Stream every event in the last 24 hours, no manual paging +UnifiApi.Network.Events.stream(authed, "default", within_hours: 24) +|> Enum.to_list() + +# Top 5 most recent IDS detections +UnifiApi.Network.IDS.stream(authed, "default", within_hours: 1) +|> Enum.take(5) +``` + +For other v1 endpoints, drop down to `UnifiApi.Client.stream_v1/3` +directly — it takes a path and arbitrary `:params`. For arbitrary +page-numbered v2 endpoints, use `UnifiApi.Client.stream_paged/2` +with a custom `fetch_page` function. + ### Manual pagination If you need per-page control, use `list` with `:offset` and `:limit`: @@ -783,6 +990,27 @@ UnifiApi.Formatter.cameras(cameras) UnifiApi.Formatter.networks(networks) ``` +Operational (v1) shortcuts (use the cookie-auth `authed` from +`UnifiApi.Auth.Cookie.login/4` or `UnifiApi.Auth.Session.client/1`): + +```elixir +{:ok, events} = UnifiApi.Network.Events.list(authed, "default", within_hours: 1) +UnifiApi.Formatter.events(events) +# subsystem column is color-coded: magenta=wlan, blue=lan, cyan=wan, red=ips, ... + +{:ok, alarms} = UnifiApi.Network.Alarms.list(authed, "default") +UnifiApi.Formatter.alarms(alarms) +# severity column is color-coded: red=critical, yellow=warn, blue=info + +{:ok, clients} = UnifiApi.Network.ClientsLive.list(authed, "default") +UnifiApi.Formatter.clients_live(clients) +# signal column buckets RSSI by strength (green ≥ -60, yellow -60..-70, red < -70) +# satisfaction column buckets the 0..100 score (green ≥ 80, yellow ≥ 50, red < 50) + +{:ok, anomalies} = UnifiApi.Network.Anomalies.list(authed, "default") +UnifiApi.Formatter.anomalies(anomalies) +``` + ### Custom tables ```elixir @@ -798,26 +1026,177 @@ UnifiApi.Formatter.table(devices, ["name", "mac", "model", "state", "ip"], UnifiApi.Formatter.detail(nvr, title: "NVR Info") ``` +### Built-in colour rules + +Pass any of these as values in the `:colors` map on `table/3` to +colour a column based on its cell value: + +| Rule | Behaviour | +|------|-----------| +| `:state` | `CONNECTED`/`ONLINE` → green, `CONNECTING`/`UPDATING` → yellow, `DISCONNECTED`/`OFFLINE` → red | +| `:type` | `WIRED` → blue, `WIRELESS` → magenta, `VPN` → cyan, `TELEPORT` → yellow | +| `:subsystem` | `wlan` → magenta, `lan` → blue, `wan`/`vpn` → cyan, `ips`/`alarm` → red, `system` → yellow | +| `:severity` | `critical`/`error` → red, `warn`/`warning` → yellow, `info` → blue | +| `:rssi` | numeric dBm: ≥ -60 green, ≥ -70 yellow, else red | +| `:satisfaction` | numeric 0..100: ≥ 80 green, ≥ 50 yellow, else red | + ## Error Handling -All functions return `{:ok, body}` on success or `{:error, reason}` on failure: +All functions return `{:ok, body}` on success or `{:error, reason}` on failure. + +Auth and rate-limit errors are surfaced as exception structs so callers can +pattern-match without inspecting the status code: ```elixir case UnifiApi.Network.Devices.get(client, site_id, "bad-id") do {:ok, device} -> IO.inspect(device) + {:error, %UnifiApi.AuthError{reason: :unauthorized}} -> + IO.puts("Invalid API key") + + {:error, %UnifiApi.AuthError{reason: :forbidden}} -> + IO.puts("API key lacks permission") + + {:error, %UnifiApi.RateLimitError{retry_after: seconds}} -> + Process.sleep(seconds * 1000) + retry() + {:error, {404, body}} -> IO.puts("Not found: #{inspect(body)}") - {:error, {401, _}} -> - IO.puts("Invalid API key") + {:error, {status, body}} -> + IO.puts("HTTP #{status}: #{inspect(body)}") {:error, reason} -> - IO.puts("Connection error: #{inspect(reason)}") + IO.puts("Transport error: #{inspect(reason)}") +end +``` + +Other non-2xx responses are returned as `{:error, {status, body}}` tuples. + +### Upgrading from a previous version + +See [UPGRADING.md](UPGRADING.md) for breaking-change details and concrete +before/after examples. The headline change in 0.3.0: 401, 403, and 429 +responses now return `%UnifiApi.AuthError{}` and `%UnifiApi.RateLimitError{}` +structs instead of `{:error, {status, body}}` tuples. Catch-all +`{:error, _}` matches still work; only callers that pattern-matched the +specific status codes need to update. + +## Multiple Controllers + +The library is stateless — every API call takes a `Req.Request.t()` as +its first argument and the request struct holds all the configuration. +Managing multiple controllers is just managing multiple request structs: + +```elixir +controllers = %{ + hq: UnifiApi.new(base_url: "https://10.0.0.1", api_key: hq_key), + branch: UnifiApi.new(base_url: "https://10.1.0.1", api_key: branch_key), + home: UnifiApi.new(base_url: "https://192.168.1.1", api_key: home_key) +} + +# Pull devices from every controller in parallel +controllers +|> Task.async_stream(fn {name, client} -> + case UnifiApi.Network.Sites.list(client) do + {:ok, sites} -> {name, length(sites)} + {:error, _} -> {name, :unreachable} + end + end, + max_concurrency: 5, + timeout: 10_000 +) +|> Enum.to_list() +``` + +If the controllers use different path conventions (UDM vs Cloud Key), +hold per-controller paths alongside the client and apply them as needed: + +```elixir +defmodule MyApp.Controllers do + @controllers %{ + hq: %{client: UnifiApi.new(base_url: "https://10.0.0.1", api_key: System.fetch_env!("HQ_KEY")), + v1: "/proxy/network", network: "/proxy/network/integration"}, + branch: %{client: UnifiApi.new(base_url: "https://10.1.0.1", api_key: System.fetch_env!("BRANCH_KEY")), + v1: "", network: "/integration"} + } + + def call(name, fun) do + %{client: client, v1: v1, network: network} = @controllers[name] + Application.put_env(:unifi_api, :v1_path, v1) + Application.put_env(:unifi_api, :network_path, network) + fun.(client) + end end + +MyApp.Controllers.call(:branch, fn client -> + UnifiApi.Network.Sites.list(client) +end) +``` + +For long-running pollers that need cookie-authenticated v1 access on +multiple controllers, log in once per controller at startup and reuse +the authenticated request struct — `UnifiApi.Auth.Cookie.refresh_csrf/2` +can refresh the CSRF token without a full re-login. + +## Self-Signed Certificates + +UDM and Cloud Key controllers use self-signed TLS certificates by default. +You have three options: + +### 1. No verification (default — easy, weakest) + +```elixir +client = UnifiApi.new(verify_ssl: false) # default ``` +The connection is encrypted but unauthenticated. Anyone on the network path +between you and the controller could intercept traffic without detection. +Fine for local trusted networks; **don't ship this to production**. + +### 2. Fingerprint pinning (recommended for self-signed setups) + +```elixir +client = UnifiApi.new( + base_url: "https://192.168.1.1", + api_key: "abc", + cert_fingerprints: ["sha256:AB:CD:EF:..."] +) +``` + +The TLS handshake is rejected unless the controller's leaf certificate +matches one of the configured SHA-256 fingerprints. This pins the +connection to the specific physical device — much stronger than +`verify_ssl: false` without requiring a CA. + +Get the fingerprint with `openssl`: + +```bash +echo | openssl s_client -connect 192.168.1.1:443 2>/dev/null \ + | openssl x509 -fingerprint -sha256 -noout +# => sha256 Fingerprint=AB:CD:EF:... +``` + +Accepted formats: + +```elixir +cert_fingerprints: ["sha256:AB:CD:EF:01:..."] # ssh-keygen / openssl style +cert_fingerprints: ["AB:CD:EF:01:..."] # without prefix +cert_fingerprints: ["abcdef01..."] # plain 64-char hex +cert_fingerprints: ["fp1...", "fp2..."] # multiple (e.g. cert rotation) +``` + +### 3. Real CA verification + +```elixir +client = UnifiApi.new(verify_ssl: true) +``` + +Use this if you've installed your own CA on the controller and trusted it +at the OS level. The strongest option, but rarely how UniFi gear is run. + ## Generating Docs ```bash diff --git a/UPGRADING.md b/UPGRADING.md new file mode 100644 index 0000000..99177ad --- /dev/null +++ b/UPGRADING.md @@ -0,0 +1,157 @@ +# Upgrading + +A guide for users of `unifi_api` upgrading between versions. Each section +covers one release boundary — read only the section(s) that apply to the +versions you're moving across. + +For the full release history, see [CHANGELOG.md](CHANGELOG.md). + +## v0.2.x → v0.3.0 + +**Released:** 2026-05-02 + +### Summary of breaking changes + +| Area | What changed | Severity | +|------|--------------|----------| +| 401 / 403 responses | Now return `%UnifiApi.AuthError{}` instead of `{:error, {401, body}}` / `{:error, {403, body}}`. | Breaking — pattern-matched callers must update. | +| 429 responses | Now return `%UnifiApi.RateLimitError{retry_after: seconds, ...}` (with parsed `Retry-After`) instead of `{:error, {429, body}}`. | Breaking — same as above. | + +Other status codes (404, 500, etc.) and transport errors are **unchanged**. + +### Why + +Three reasons: + +1. **Pattern matching on `{:error, {401, _}}` couples callers to HTTP status + numbers.** Auth and rate-limit failures are semantically distinct from a + generic non-2xx response and deserve their own shapes. +2. **`Retry-After` was previously invisible.** With the old tuple shape, + callers had to either re-parse the response body or give up on rate-limit + backoff entirely. The new struct surfaces a clamped, parsed integer so + pollers can `Process.sleep(seconds * 1000)` directly. +3. **Aligns with Elixir conventions.** Exception structs are how Plug, Phoenix, + Ecto, Req, and Tesla all signal these conditions. + +### Migration + +#### 1. Find every `{401, _}`, `{403, _}`, `{429, _}` pattern in your code + +A grep over your codebase will find them: + +```bash +grep -rE '\{:error, \{4(01|03|29)' lib/ test/ +``` + +#### 2. Rewrite the matches + +**Before (0.2.x):** + +```elixir +case UnifiApi.Network.Sites.list(client) do + {:ok, sites} -> + sites + + {:error, {401, _body}} -> + raise "API key invalid" + + {:error, {403, _body}} -> + raise "API key lacks permission" + + {:error, {429, _body}} -> + Process.sleep(60_000) + retry() + + {:error, reason} -> + raise "request failed: #{inspect(reason)}" +end +``` + +**After (0.3.0):** + +```elixir +case UnifiApi.Network.Sites.list(client) do + {:ok, sites} -> + sites + + {:error, %UnifiApi.AuthError{reason: :unauthorized}} -> + raise "API key invalid" + + {:error, %UnifiApi.AuthError{reason: :forbidden}} -> + raise "API key lacks permission" + + {:error, %UnifiApi.RateLimitError{retry_after: seconds}} -> + Process.sleep(seconds * 1000) + retry() + + {:error, reason} -> + raise "request failed: #{inspect(reason)}" +end +``` + +Note `retry_after` comes from the `Retry-After` response header (parsed as +seconds or HTTP-date) and is clamped to the range 1..300 seconds. If the +header is missing or unparseable it defaults to 60. + +#### 3. If you don't care about these errors, no action is needed + +Code that only matches `{:ok, _}` or uses a catch-all `{:error, _}` continues +to work unchanged: + +```elixir +case UnifiApi.Network.Sites.list(client) do + {:ok, sites} -> sites + {:error, _} -> [] +end +``` + +The new error structs are still `{:error, _}` tuples — only the inner term +changed. + +### Supporting both 0.2 and 0.3 in a downstream library + +If you maintain a library that wraps `unifi_api` and needs to support both +versions during a deprecation window, match both shapes: + +```elixir +case UnifiApi.Network.Sites.list(client) do + {:ok, sites} -> + {:ok, sites} + + # 0.3+ + {:error, %UnifiApi.AuthError{}} -> + {:error, :unauthorized} + + {:error, %UnifiApi.RateLimitError{retry_after: s}} -> + {:error, {:rate_limited, s}} + + # 0.2.x fallback — remove once you bump unifi_api to ~> 0.3 + {:error, {status, _}} when status in [401, 403] -> + {:error, :unauthorized} + + {:error, {429, _}} -> + {:error, {:rate_limited, 60}} + + {:error, reason} -> + {:error, reason} +end +``` + +### Reference: error struct shapes + +```elixir +%UnifiApi.AuthError{ + status: 401 | 403, + reason: :unauthorized | :forbidden, + body: term() +} + +%UnifiApi.RateLimitError{ + status: 429, + retry_after: 1..300, + body: term() +} +``` + +Both implement `Exception`, so `Exception.message/1` and `raise` work as +expected. diff --git a/config/config.exs b/config/config.exs index b10f3f5..dda9bd1 100644 --- a/config/config.exs +++ b/config/config.exs @@ -4,6 +4,8 @@ config :unifi_api, base_url: "https://192.168.1.1", api_key: "", verify_ssl: false, - # UDM defaults — for Cloud Key, set both to "/integration" + # UDM defaults — for Cloud Key, set network_path / protect_path to + # "/integration" and v1_path to "". network_path: "/proxy/network/integration", - protect_path: "/proxy/protect/integration" + protect_path: "/proxy/protect/integration", + v1_path: "/proxy/network" diff --git a/config/runtime.exs b/config/runtime.exs index 1e3e920..c42c59c 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -5,4 +5,5 @@ config :unifi_api, api_key: System.get_env("UNIFI_API_KEY", ""), verify_ssl: System.get_env("UNIFI_VERIFY_SSL", "false") == "true", network_path: System.get_env("UNIFI_NETWORK_PATH", "/proxy/network/integration"), - protect_path: System.get_env("UNIFI_PROTECT_PATH", "/proxy/protect/integration") + protect_path: System.get_env("UNIFI_PROTECT_PATH", "/proxy/protect/integration"), + v1_path: System.get_env("UNIFI_V1_PATH", "/proxy/network") diff --git a/examples/dashboard.exs b/examples/dashboard.exs new file mode 100644 index 0000000..d1e92a2 --- /dev/null +++ b/examples/dashboard.exs @@ -0,0 +1,90 @@ +#!/usr/bin/env elixir + +# Dashboard scraper: pulls a snapshot of sites, devices, clients, networks, +# WiFi, WANs, and Protect cameras/sensors/lights, then writes dashboard.json. +# +# Run: +# UNIFI_BASE_URL=https://192.168.1.1 \ +# UNIFI_API_KEY=your-api-key \ +# elixir examples/dashboard.exs + +Mix.install([{:unifi_api, "~> 0.3"}]) + +base_url = System.fetch_env!("UNIFI_BASE_URL") +api_key = System.fetch_env!("UNIFI_API_KEY") + +client = UnifiApi.new(base_url: base_url, api_key: api_key, verify_ssl: false) + +{:ok, info} = UnifiApi.Network.Info.get_info(client) + +sites_data = + UnifiApi.Network.Sites.stream(client) + |> Enum.map(fn site -> + sid = site["id"] + clients = UnifiApi.Network.Clients.stream(client, sid) |> Enum.to_list() + + client_breakdown = + clients + |> Enum.group_by(& &1["type"]) + |> Map.new(fn {type, list} -> {type, length(list)} end) + + devices = UnifiApi.Network.Devices.stream(client, sid) |> Enum.to_list() + + %{ + site_id: sid, + site_name: site["name"], + clients: %{ + total: length(clients), + wired: client_breakdown["WIRED"] || 0, + wireless: client_breakdown["WIRELESS"] || 0, + vpn: client_breakdown["VPN"] || 0, + teleport: client_breakdown["TELEPORT"] || 0 + }, + devices: %{ + total: length(devices), + connected: Enum.count(devices, &(&1["state"] == "CONNECTED")), + disconnected: Enum.count(devices, &(&1["state"] == "DISCONNECTED")) + }, + networks: + UnifiApi.Network.Networks.stream(client, sid) + |> Enum.map(&Map.take(&1, ["id", "name", "vlanId"])), + ssids: + UnifiApi.Network.Wifi.stream(client, sid) + |> Enum.map(&Map.take(&1, ["id", "name", "enabled"])), + wans: + UnifiApi.Network.Resources.stream_wans(client, sid) + |> Enum.map(&Map.take(&1, ["id", "name", "status"])) + } + end) + +{:ok, cameras} = UnifiApi.Protect.Cameras.list(client) +{:ok, sensors} = UnifiApi.Protect.Sensors.list(client) +{:ok, lights} = UnifiApi.Protect.Lights.list(client) +{:ok, nvr} = UnifiApi.Protect.NVR.get(client) + +protect_data = %{ + nvr: Map.take(nvr, ["id", "name", "modelKey"]), + cameras: %{ + total: length(cameras), + connected: Enum.count(cameras, &(&1["state"] == "CONNECTED")) + }, + sensors: %{ + total: length(sensors), + open_doors: Enum.count(sensors, & &1["isOpened"]), + motion_detected: Enum.count(sensors, & &1["isMotionDetected"]) + }, + lights: %{ + total: length(lights), + on: Enum.count(lights, & &1["isLightOn"]) + } +} + +dashboard = %{ + controller_version: info["applicationVersion"], + scraped_at: DateTime.utc_now(), + sites: sites_data, + protect: protect_data +} + +File.write!("dashboard.json", JSON.encode!(dashboard)) +IO.puts("Wrote dashboard.json (#{length(sites_data)} sites)") diff --git a/examples/operational.exs b/examples/operational.exs new file mode 100644 index 0000000..9e72f74 --- /dev/null +++ b/examples/operational.exs @@ -0,0 +1,50 @@ +#!/usr/bin/env elixir + +# Operational data: cookie-auth login + recent events, active alarms, and +# live wireless client stats. Demonstrates the v1 endpoint surface that +# requires UnifiApi.Auth.Cookie (not the integration API key). +# +# Run: +# UNIFI_BASE_URL=https://192.168.1.1 \ +# UNIFI_USERNAME=admin \ +# UNIFI_PASSWORD=secret \ +# UNIFI_SITE=default \ +# elixir examples/operational.exs + +Mix.install([{:unifi_api, "~> 0.3"}]) + +base_url = System.fetch_env!("UNIFI_BASE_URL") +username = System.fetch_env!("UNIFI_USERNAME") +password = System.fetch_env!("UNIFI_PASSWORD") +site = System.get_env("UNIFI_SITE", "default") + +client = UnifiApi.new(base_url: base_url, verify_ssl: false) + +{:ok, info} = UnifiApi.detect(client) +IO.puts("Controller style: #{info.style}") + +# Apply detected paths so v1 calls land in the right place. +Application.put_env(:unifi_api, :v1_path, info.v1_prefix) + +{:ok, authed} = UnifiApi.Auth.Cookie.login(client, username, password, style: info.style) +IO.puts("Logged in as #{username}.\n") + +# Recent events +{:ok, events} = UnifiApi.Network.Events.list(authed, site, within_hours: 1, limit: 25) +UnifiApi.Formatter.events(events) + +# Active alarms only +{:ok, alarms} = UnifiApi.Network.Alarms.list(authed, site, archived: false) +UnifiApi.Formatter.alarms(alarms) + +# Live wireless client signal quality +{:ok, clients} = UnifiApi.Network.ClientsLive.list(authed, site) + +worst = + clients + |> Enum.reject(& &1["is_wired"]) + |> Enum.sort_by(& &1["signal"]) + |> Enum.take(10) + +IO.puts("Worst-RSSI wireless clients:") +UnifiApi.Formatter.clients_live(worst) diff --git a/examples/protect_events.exs b/examples/protect_events.exs new file mode 100644 index 0000000..96958f8 --- /dev/null +++ b/examples/protect_events.exs @@ -0,0 +1,45 @@ +#!/usr/bin/env elixir + +# Protect events: pulls every motion / smartDetect event from the last +# hour and saves each one's thumbnail to disk. Demonstrates the +# binary-payload endpoints (UnifiApi.Protect.Events.thumbnail/3). +# +# Run: +# UNIFI_BASE_URL=https://192.168.1.1 \ +# UNIFI_USERNAME=admin \ +# UNIFI_PASSWORD=secret \ +# elixir examples/protect_events.exs + +Mix.install([{:unifi_api, "~> 0.3"}]) + +base_url = System.fetch_env!("UNIFI_BASE_URL") +username = System.fetch_env!("UNIFI_USERNAME") +password = System.fetch_env!("UNIFI_PASSWORD") + +client = UnifiApi.new(base_url: base_url, verify_ssl: false) +{:ok, authed} = UnifiApi.Auth.Cookie.login(client, username, password, style: :udm) + +File.mkdir_p!("protect_events") + +start = System.os_time(:millisecond) - 60 * 60 * 1000 + +{:ok, events} = + UnifiApi.Protect.Events.list(authed, + start: start, + types: ["motion", "smartDetectZone"], + limit: 200 + ) + +IO.puts("Pulled #{length(events)} events from the last hour.") + +for ev <- events do + case UnifiApi.Protect.Events.thumbnail(authed, ev["id"], width: 640) do + {:ok, jpeg} -> + path = "protect_events/#{ev["id"]}.jpg" + File.write!(path, jpeg) + IO.puts("Saved #{path} (#{byte_size(jpeg)} bytes)") + + {:error, reason} -> + IO.puts("Skipped #{ev["id"]}: #{inspect(reason)}") + end +end diff --git a/examples/quickstart.exs b/examples/quickstart.exs new file mode 100644 index 0000000..43503b4 --- /dev/null +++ b/examples/quickstart.exs @@ -0,0 +1,41 @@ +#!/usr/bin/env elixir + +# Quickstart: list sites, devices, and clients on a UniFi controller. +# +# Run: +# UNIFI_BASE_URL=https://192.168.1.1 \ +# UNIFI_API_KEY=your-api-key \ +# elixir examples/quickstart.exs +# +# For Cloud Key controllers, set: +# UNIFI_NETWORK_PATH=/integration + +Mix.install([{:unifi_api, "~> 0.3"}]) + +base_url = System.fetch_env!("UNIFI_BASE_URL") +api_key = System.fetch_env!("UNIFI_API_KEY") +network_path = System.get_env("UNIFI_NETWORK_PATH", "/proxy/network/integration") + +Application.put_env(:unifi_api, :network_path, network_path) + +client = UnifiApi.new(base_url: base_url, api_key: api_key, verify_ssl: false) + +{:ok, info} = UnifiApi.Network.Info.get_info(client) +IO.puts("Controller version: #{info["applicationVersion"]}") + +{:ok, sites} = UnifiApi.Network.Sites.list(client) +IO.puts("\nSites (#{length(sites)}):") +UnifiApi.Formatter.sites(sites) + +[%{"id" => first_site} = site | _] = sites +IO.puts("\nDevices on #{site["name"]}:") + +UnifiApi.Network.Devices.stream(client, first_site) +|> Enum.to_list() +|> UnifiApi.Formatter.devices() + +IO.puts("\nClients on #{site["name"]}:") + +UnifiApi.Network.Clients.stream(client, first_site) +|> Enum.to_list() +|> UnifiApi.Formatter.clients() diff --git a/examples/snapshots.exs b/examples/snapshots.exs new file mode 100644 index 0000000..128a5c0 --- /dev/null +++ b/examples/snapshots.exs @@ -0,0 +1,33 @@ +#!/usr/bin/env elixir + +# Saves a high-quality snapshot from every connected Protect camera into +# the snapshots/ directory. +# +# Run: +# UNIFI_BASE_URL=https://192.168.1.1 \ +# UNIFI_API_KEY=your-api-key \ +# elixir examples/snapshots.exs + +Mix.install([{:unifi_api, "~> 0.3"}]) + +base_url = System.fetch_env!("UNIFI_BASE_URL") +api_key = System.fetch_env!("UNIFI_API_KEY") + +client = UnifiApi.new(base_url: base_url, api_key: api_key, verify_ssl: false) + +File.mkdir_p!("snapshots") + +{:ok, cameras} = UnifiApi.Protect.Cameras.list(client) + +for camera <- cameras, camera["state"] == "CONNECTED" do + case UnifiApi.Protect.Cameras.snapshot(client, camera["id"], high_quality: true) do + {:ok, jpeg} -> + name = camera["name"] |> String.replace(~r/[^\w]/, "_") + path = "snapshots/#{name}.jpg" + File.write!(path, jpeg) + IO.puts("Saved #{path} (#{byte_size(jpeg)} bytes)") + + {:error, reason} -> + IO.puts("Failed #{camera["name"]}: #{inspect(reason)}") + end +end diff --git a/lib/unifi_api.ex b/lib/unifi_api.ex index fab6ab5..c35a755 100644 --- a/lib/unifi_api.ex +++ b/lib/unifi_api.ex @@ -45,7 +45,14 @@ defmodule UnifiApi do * `:base_url` — UniFi controller URL (e.g. `"https://192.168.0.1"`) * `:api_key` — API key for authentication - * `:verify_ssl` — whether to verify SSL certificates (default: `false`) + * `:verify_ssl` — whether to verify SSL certificates against the OS CA + store (default: `false`). Ignored when `:cert_fingerprints` is set. + * `:cert_fingerprints` — list of SHA-256 fingerprints of acceptable + peer certificates. When set, the connection is verified by pinning + the leaf certificate to one of these fingerprints; CA validation is + skipped. Each entry is a hex string, optionally prefixed with + `"sha256:"` and/or separated by colons. Example: + `["sha256:AB:CD:..."]` or `["abcd...32-byte-hex..."]`. ## Examples @@ -55,6 +62,13 @@ defmodule UnifiApi do # With explicit options client = UnifiApi.new(base_url: "https://192.168.0.1", api_key: "abc123") + # Pin the controller's self-signed certificate + client = UnifiApi.new( + base_url: "https://192.168.0.1", + api_key: "abc123", + cert_fingerprints: ["sha256:AB:CD:EF:..."] + ) + # Same client works for both APIs UnifiApi.Network.Sites.list(client) UnifiApi.Protect.Cameras.list(client) @@ -62,4 +76,119 @@ defmodule UnifiApi do @spec new() :: Req.Request.t() @spec new(keyword()) :: Req.Request.t() defdelegate new(opts \\ []), to: UnifiApi.Client + + @typedoc """ + Result of `detect/1`. The string fields are absolute path prefixes you can + use directly with `Application.put_env(:unifi_api, ..., ...)` or with the + per-call routing in `UnifiApi.Auth.Cookie.login/4`. + """ + @type controller_info :: %{ + style: :udm | :cloud_key, + network_prefix: String.t(), + protect_prefix: String.t(), + v1_prefix: String.t(), + auth_path: String.t() + } + + @doc """ + Probes the controller and reports which path conventions to use. + + Issues `GET /` against the configured `base_url` with redirects disabled + and applies the heuristic popularised by the + [unpoller](https://github.com/unpoller/unpoller) project: + + * `200` — UniFi OS device (UDM, UDM Pro, UDM SE, UCK-G2). The Network + and Protect APIs live under `/proxy/network` and `/proxy/protect` + respectively, and login is at `/api/auth/login`. + * `301`/`302`/`303` — standalone controller / Cloud Key. Network and + Protect live at the root, and login is at `/api/login`. + + This heuristic is the same one unpoller uses against tens of thousands + of deployments, but it is **not** infallible — set the prefixes + manually via application config if `detect/1` mis-identifies your + controller. + + ## Examples + + client = UnifiApi.new(base_url: "https://192.168.1.1", verify_ssl: false) + + {:ok, info} = UnifiApi.detect(client) + # %{style: :udm, network_prefix: "/proxy/network/integration", + # protect_prefix: "/proxy/protect/integration", + # v1_prefix: "/proxy/network", auth_path: "/api/auth/login"} + + # Apply the discovered prefixes to subsequent calls: + Application.put_env(:unifi_api, :network_path, info.network_prefix) + Application.put_env(:unifi_api, :protect_path, info.protect_prefix) + """ + @spec detect(Req.Request.t()) :: {:ok, controller_info()} | {:error, term()} + def detect(client) do + probe = Req.merge(client, redirect: false) + + case Req.get(probe, url: "/") do + {:ok, %Req.Response{status: status}} when status in [301, 302, 303] -> + {:ok, info(:cloud_key)} + + {:ok, %Req.Response{status: 200}} -> + {:ok, info(:udm)} + + {:ok, %Req.Response{status: status, body: body}} -> + {:error, {:unexpected_status, status, body}} + + {:error, reason} -> + {:error, reason} + end + end + + @doc """ + Lightweight reachability check. + + Issues `GET /` against the controller (with redirects disabled) and + returns `:ok` for any 2xx or 3xx — both are signs the controller is + alive and responding. Returns `{:error, reason}` for transport + failures or 4xx/5xx. + + Auth-agnostic: works with both the integration API key client and a + cookie-authenticated client, since `/` is unauthenticated on every + controller flavour. + + ## Examples + + :ok = UnifiApi.ping(client) + + case UnifiApi.ping(client) do + :ok -> :alive + {:error, _} -> :unreachable + end + """ + @spec ping(Req.Request.t()) :: :ok | {:error, term()} + def ping(client) do + probe = Req.merge(client, redirect: false) + + case Req.get(probe, url: "/") do + {:ok, %Req.Response{status: status}} when status in 200..399 -> :ok + {:ok, %Req.Response{status: status, body: body}} -> {:error, {status, body}} + {:error, reason} -> {:error, reason} + end + end + + defp info(:udm) do + %{ + style: :udm, + network_prefix: "/proxy/network/integration", + protect_prefix: "/proxy/protect/integration", + v1_prefix: "/proxy/network", + auth_path: "/api/auth/login" + } + end + + defp info(:cloud_key) do + %{ + style: :cloud_key, + network_prefix: "/integration", + protect_prefix: "/integration", + v1_prefix: "", + auth_path: "/api/login" + } + end end diff --git a/lib/unifi_api/auth/cookie.ex b/lib/unifi_api/auth/cookie.ex new file mode 100644 index 0000000..80feaf8 --- /dev/null +++ b/lib/unifi_api/auth/cookie.ex @@ -0,0 +1,226 @@ +defmodule UnifiApi.Auth.Cookie do + @moduledoc """ + Cookie + CSRF authentication for UniFi controllers. + + Use this when the controller does not expose API-key auth (Cloud Key, + older UniFi OS releases) or when you need access to the legacy + `/api/s/{site}/...` and `/v2/api/site/{site}/...` endpoints, which + Ubiquiti has not exposed under `x-api-key`. + + ## Quick start + + client = UnifiApi.new(base_url: "https://192.168.1.1", verify_ssl: false) + + {:ok, authed} = + UnifiApi.Auth.Cookie.login(client, "admin", "password", + style: :udm # or :cloud_key + ) + + # `authed` is a Req.Request with session cookies and CSRF token baked in. + # Pass it to any UnifiApi.Network or UnifiApi.Protect module. + UnifiApi.Network.Sites.list(authed) + + ## Style selection + + Controllers use one of two login flows. Pass `:style` explicitly, or use + `UnifiApi.detect/1` first and read the `:auth_path` field. + + | Style | Login path | Auth header source | + |-------|------------|---------------------| + | `:udm` (UDM / UDM Pro / UDM SE / UCK-G2) | `POST /api/auth/login` | `X-CSRF-Token` response header | + | `:cloud_key` (Cloud Key, standalone) | `POST /api/login` | `csrf_token` cookie | + + ## CSRF rotation + + UniFi controllers may rotate the CSRF token mid-session. `login/4` returns + a static `Req.Request.t()` and does **not** auto-refresh. + + For long-running pollers that perform writes, prefer + `UnifiApi.Auth.Session` — a supervised GenServer that holds the auth state + and auto-rotates CSRF from response headers on every request. This module + (`Cookie`) is the right call for one-shot scripts and tests. + + Notes for the stateless flow: + + * Read-only requests (GET) work indefinitely — CSRF is only enforced + on mutating verbs. + * If a write returns 403, call `refresh_csrf/2` (issues a lightweight + GET and updates the token from the response header) or simply call + `login/4` again. + + > **Note:** This module is implemented to the documented and observed + > shape of the UniFi login endpoints. It has been unit-tested against + > mocked `Req.Test` plugs but not yet end-to-end against a live UDM Pro + > or Cloud Key. Please file an issue with the controller model and + > firmware version if you encounter shape mismatches. + """ + + alias UnifiApi.AuthError + + @type style :: :udm | :cloud_key + + @doc """ + Authenticates against the controller and returns a session-bearing client. + + ## Options + + * `:style` — `:udm` (default) or `:cloud_key`. Selects the login path. + * `:remember` — for `:cloud_key`, sets the `remember` flag on the login + payload (default: `false`). + + ## Returns + + * `{:ok, %Req.Request{}}` — the request struct has session cookies in + its headers and the captured CSRF token under + `:private.unifi_api_csrf` so subsequent calls can replay it. + * `{:error, %UnifiApi.AuthError{}}` — credentials rejected (HTTP 401/403). + * `{:error, term()}` — transport or unexpected response. + """ + @spec login(Req.Request.t(), String.t(), String.t(), keyword()) :: + {:ok, Req.Request.t()} | {:error, term()} + def login(client, username, password, opts \\ []) do + style = Keyword.get(opts, :style, :udm) + remember = Keyword.get(opts, :remember, false) + body = build_login_body(style, username, password, remember) + path = login_path(style) + + case Req.post(client, url: path, json: body) do + {:ok, %Req.Response{status: 200} = resp} -> + {:ok, install_session(client, resp, style)} + + {:ok, %Req.Response{status: status, body: body}} when status in [401, 403] -> + reason = if status == 401, do: :unauthorized, else: :forbidden + {:error, %AuthError{status: status, reason: reason, body: body}} + + {:ok, %Req.Response{status: status, body: body}} -> + {:error, {status, body}} + + {:error, reason} -> + {:error, reason} + end + end + + @doc """ + Logs out and invalidates the controller-side session. + + Best-effort — the client struct is not modified, since `Req.Request` is + not mutated in place. Discard the request after calling this. + """ + @spec logout(Req.Request.t(), keyword()) :: :ok | {:error, term()} + def logout(client, opts \\ []) do + style = Keyword.get(opts, :style, :udm) + path = if style == :udm, do: "/api/auth/logout", else: "/api/logout" + + case Req.post(client, url: path, json: %{}) do + {:ok, _resp} -> :ok + {:error, reason} -> {:error, reason} + end + end + + @doc """ + Refreshes the CSRF token by issuing a lightweight GET and capturing the + rotated token from the response header. + + Useful when a mutating call has returned 403 due to CSRF expiry and you + want to retry without a full re-login. + + Returns a new `Req.Request.t()` with the refreshed token; the original + is unchanged. + """ + @spec refresh_csrf(Req.Request.t(), keyword()) :: {:ok, Req.Request.t()} | {:error, term()} + def refresh_csrf(client, opts \\ []) do + probe_path = Keyword.get(opts, :probe_path, "/") + + case Req.get(client, url: probe_path) do + {:ok, %Req.Response{} = resp} -> + case extract_csrf(resp) do + nil -> {:ok, client} + token -> {:ok, put_csrf(client, token)} + end + + {:error, reason} -> + {:error, reason} + end + end + + @doc """ + Returns the CSRF token currently stored on the client, if any. + """ + @spec csrf_token(Req.Request.t()) :: String.t() | nil + def csrf_token(%Req.Request{private: private}) do + Map.get(private, :unifi_api_csrf) + end + + defp build_login_body(:udm, user, pass, _remember), + do: %{username: user, password: pass} + + defp build_login_body(:cloud_key, user, pass, remember), + do: %{username: user, password: pass, remember: remember} + + defp login_path(:udm), do: "/api/auth/login" + defp login_path(:cloud_key), do: "/api/login" + + defp install_session(client, %Req.Response{} = resp, _style) do + cookies = extract_cookies(resp) + csrf = extract_csrf(resp) || extract_csrf_from_cookies(cookies) + + client + |> put_cookies(cookies) + |> put_csrf(csrf) + end + + defp put_cookies(client, []), do: client + + defp put_cookies(client, cookies) do + cookie_header = + cookies + |> Enum.map(fn {name, value} -> "#{name}=#{value}" end) + |> Enum.join("; ") + + Req.Request.put_header(client, "cookie", cookie_header) + end + + defp put_csrf(client, nil), do: client + + defp put_csrf(client, token) do + client + |> Req.Request.put_header("x-csrf-token", token) + |> Req.Request.put_private(:unifi_api_csrf, token) + end + + # Parses Set-Cookie headers into a list of {name, value} tuples. + # Discards Path / Domain / Secure / HttpOnly / SameSite attributes. + defp extract_cookies(%Req.Response{} = resp) do + resp + |> Req.Response.get_header("set-cookie") + |> Enum.map(&parse_set_cookie/1) + |> Enum.reject(&is_nil/1) + end + + defp parse_set_cookie(set_cookie) when is_binary(set_cookie) do + case String.split(set_cookie, ";", parts: 2) do + [pair | _] -> + case String.split(pair, "=", parts: 2) do + [name, value] -> {String.trim(name), String.trim(value)} + _ -> nil + end + + _ -> + nil + end + end + + defp extract_csrf(%Req.Response{} = resp) do + case Req.Response.get_header(resp, "x-csrf-token") do + [token | _] -> token + _ -> nil + end + end + + defp extract_csrf_from_cookies(cookies) do + Enum.find_value(cookies, fn + {"csrf_token", value} -> value + _ -> nil + end) + end +end diff --git a/lib/unifi_api/auth/session.ex b/lib/unifi_api/auth/session.ex new file mode 100644 index 0000000..bc3f042 --- /dev/null +++ b/lib/unifi_api/auth/session.ex @@ -0,0 +1,247 @@ +defmodule UnifiApi.Auth.Session do + @moduledoc """ + Supervised, auto-refreshing cookie + CSRF session. + + `UnifiApi.Auth.Cookie.login/4` returns a static `Req.Request.t()` — + fine for one-shot scripts, but when the controller rotates the CSRF + token mid-session, callers have to either notice the 403, manually + call `refresh_csrf/2`, and retry, or just re-login. + + `UnifiApi.Auth.Session` wraps the auth state in a GenServer: + + * The request struct returned by `client/1` has request steps + that pull the current cookies + CSRF from the GenServer at + send time. + * Response steps capture any rotated `x-csrf-token` header from + the response and update the GenServer state for the next call. + + Use this in long-running pollers that mix reads and writes against + the v1 / v2 endpoints. + + ## Why a GenServer + + A GenServer is justified here per the + [Iron Law](https://hexdocs.pm/elixir/processes.html): the CSRF token + mutates across calls and may be touched concurrently from multiple + consumers in the same BEAM node. Read-only stateless usage should + stick with `UnifiApi.Auth.Cookie.login/4`. + + ## Quick start + + children = [ + {UnifiApi.Auth.Session, + name: MyApp.UnifiSession, + client: UnifiApi.new(base_url: "https://192.168.1.1", verify_ssl: false), + username: System.fetch_env!("UNIFI_USERNAME"), + password: System.fetch_env!("UNIFI_PASSWORD"), + style: :udm} + ] + + Supervisor.start_link(children, strategy: :one_for_one) + + # Anywhere in your app: + authed = UnifiApi.Auth.Session.client(MyApp.UnifiSession) + UnifiApi.Network.Events.list(authed, "default") + """ + + use GenServer + + alias UnifiApi.Auth.Cookie + + @typedoc """ + Options accepted by `start_link/1`. + + * `:client` — base `Req.Request.t()` from `UnifiApi.new/1`. **Required.** + * `:username` / `:password` — controller credentials. **Required.** + * `:style` — `:udm` (default) or `:cloud_key`. + * `:name` — process name (any GenServer name). + * `:remember` — passed through to `UnifiApi.Auth.Cookie.login/4`. + """ + @type option :: + {:client, Req.Request.t()} + | {:username, String.t()} + | {:password, String.t()} + | {:style, :udm | :cloud_key} + | {:name, GenServer.name()} + | {:remember, boolean()} + + @doc """ + Starts the session and logs in synchronously during `init/1`. + + Returns `{:error, %UnifiApi.AuthError{}}` (or other error term) if + login fails — the supervisor will see this and apply its restart + policy. + """ + @spec start_link([option()]) :: GenServer.on_start() + def start_link(opts) do + {gen_opts, opts} = Keyword.split(opts, [:name]) + GenServer.start_link(__MODULE__, opts, gen_opts) + end + + @doc """ + Returns a `Req.Request.t()` configured to pull current cookies + CSRF + from this session on every request and to capture rotated tokens. + + The returned struct is safe to cache — its request/response steps + reference the session pid, not a snapshot of the state. + """ + @spec client(GenServer.server()) :: Req.Request.t() + def client(session), do: GenServer.call(session, :client) + + @doc """ + Forces a CSRF refresh by issuing a lightweight GET against the + controller. Useful after a 403 to recover without a full re-login. + """ + @spec refresh(GenServer.server()) :: :ok | {:error, term()} + def refresh(session), do: GenServer.call(session, :refresh) + + @doc """ + Forces a full re-login. Use this when the session has fully expired + (typically a 401 on a request that worked previously). + """ + @spec relogin(GenServer.server()) :: :ok | {:error, term()} + def relogin(session), do: GenServer.call(session, :relogin, 30_000) + + @doc """ + Returns the current CSRF token. + """ + @spec csrf_token(GenServer.server()) :: String.t() | nil + def csrf_token(session), do: GenServer.call(session, :csrf_token) + + @doc false + def child_spec(opts) do + %{ + id: opts[:name] || __MODULE__, + start: {__MODULE__, :start_link, [opts]}, + type: :worker, + restart: :permanent + } + end + + # --- GenServer callbacks --- + + @impl true + def init(opts) do + base = require_opt!(opts, :client) + username = require_opt!(opts, :username) + password = require_opt!(opts, :password) + style = Keyword.get(opts, :style, :udm) + remember = Keyword.get(opts, :remember, false) + + case Cookie.login(base, username, password, style: style, remember: remember) do + {:ok, authed} -> + state = %{ + base: base, + username: username, + password: password, + style: style, + remember: remember, + authed: authed + } + + {:ok, state} + + {:error, reason} -> + {:stop, reason} + end + end + + @impl true + def handle_call(:client, _from, %{authed: authed} = state) do + server = self() + + wrapped = + authed + |> drop_static_auth_headers() + |> Req.Request.append_request_steps(unifi_session_inject: &inject_auth(&1, server)) + |> Req.Request.append_response_steps( + unifi_session_capture: &capture_rotated_csrf(&1, server) + ) + + {:reply, wrapped, state} + end + + def handle_call(:refresh, _from, state) do + case Cookie.refresh_csrf(state.authed) do + {:ok, refreshed} -> {:reply, :ok, %{state | authed: refreshed}} + err -> {:reply, err, state} + end + end + + def handle_call(:relogin, _from, state) do + case Cookie.login(state.base, state.username, state.password, + style: state.style, + remember: state.remember + ) do + {:ok, authed} -> {:reply, :ok, %{state | authed: authed}} + err -> {:reply, err, state} + end + end + + def handle_call(:csrf_token, _from, state) do + {:reply, Cookie.csrf_token(state.authed), state} + end + + def handle_call(:auth_snapshot, _from, %{authed: authed} = state) do + snapshot = %{ + cookie: header(authed, "cookie"), + csrf: Cookie.csrf_token(authed) + } + + {:reply, snapshot, state} + end + + @impl true + def handle_cast({:csrf_rotated, token}, state) do + new_authed = + state.authed + |> Req.Request.put_header("x-csrf-token", token) + |> Req.Request.put_private(:unifi_api_csrf, token) + + {:noreply, %{state | authed: new_authed}} + end + + # --- Helpers --- + + defp require_opt!(opts, key) do + case Keyword.fetch(opts, key) do + {:ok, value} -> value + :error -> raise ArgumentError, "UnifiApi.Auth.Session requires :#{key} option" + end + end + + defp inject_auth(req, server) do + %{cookie: cookie, csrf: csrf} = GenServer.call(server, :auth_snapshot) + + req + |> maybe_put_header("cookie", cookie) + |> maybe_put_header("x-csrf-token", csrf) + end + + defp capture_rotated_csrf({req, resp}, server) do + case Req.Response.get_header(resp, "x-csrf-token") do + [token | _] when is_binary(token) and token != "" -> + GenServer.cast(server, {:csrf_rotated, token}) + {req, resp} + + _ -> + {req, resp} + end + end + + defp drop_static_auth_headers(req) do + req + |> Req.Request.delete_header("cookie") + |> Req.Request.delete_header("x-csrf-token") + end + + defp header(req, name) do + case Req.Request.get_header(req, name) do + [value | _] -> value + _ -> nil + end + end + + defp maybe_put_header(req, _name, nil), do: req + defp maybe_put_header(req, name, value), do: Req.Request.put_header(req, name, value) +end diff --git a/lib/unifi_api/client.ex b/lib/unifi_api/client.ex index 691b221..fa2abb6 100644 --- a/lib/unifi_api/client.ex +++ b/lib/unifi_api/client.ex @@ -25,22 +25,84 @@ defmodule UnifiApi.Client do base_url = opts[:base_url] || Application.get_env(:unifi_api, :base_url) api_key = opts[:api_key] || Application.get_env(:unifi_api, :api_key) - verify_ssl = - Keyword.get(opts, :verify_ssl, Application.get_env(:unifi_api, :verify_ssl, false)) - - connect_opts = - if verify_ssl, - do: [], - else: [transport_opts: [verify: :verify_none]] - Req.new( base_url: base_url, headers: [{"x-api-key", api_key}], - connect_options: connect_opts, + connect_options: tls_connect_opts(opts), redirect: false ) end + defp tls_connect_opts(opts) do + fingerprints = + opts[:cert_fingerprints] || + Application.get_env(:unifi_api, :cert_fingerprints, []) + + verify_ssl = + Keyword.get(opts, :verify_ssl, Application.get_env(:unifi_api, :verify_ssl, false)) + + cond do + fingerprints != [] -> + [transport_opts: fingerprint_pinning_opts(fingerprints)] + + verify_ssl -> + [] + + true -> + [transport_opts: [verify: :verify_none]] + end + end + + defp fingerprint_pinning_opts(fingerprints) do + decoded = Enum.map(fingerprints, &decode_fingerprint!/1) + + [ + verify: :verify_peer, + cacerts: :public_key.cacerts_get(), + verify_fun: {build_fingerprint_verify_fun(decoded), nil} + ] + end + + defp build_fingerprint_verify_fun(decoded_fps) do + fn + _cert, {:bad_cert, _reason}, state -> {:valid, state} + _cert, {:extension, _ext}, state -> {:unknown, state} + cert, :valid, state -> check_fingerprint(cert, decoded_fps, state) + cert, :valid_peer, state -> check_fingerprint(cert, decoded_fps, state) + end + end + + defp check_fingerprint(otp_cert, allowed, state) do + der = :public_key.pkix_encode(:OTPCertificate, otp_cert, :otp) + fp = :crypto.hash(:sha256, der) + + if fp in allowed, + do: {:valid, state}, + else: {:fail, :fingerprint_mismatch} + end + + @doc false + @spec decode_fingerprint!(String.t()) :: <<_::256>> + def decode_fingerprint!(fingerprint) when is_binary(fingerprint) do + normalized = + fingerprint + |> String.trim() + |> String.downcase() + |> String.trim_leading("sha256:") + |> String.replace(":", "") + + case Base.decode16(normalized, case: :lower) do + {:ok, <>} -> + bin + + _ -> + raise ArgumentError, + "invalid SHA-256 cert fingerprint: #{inspect(fingerprint)} " <> + "(expected 64 hex chars, optionally prefixed with \"sha256:\" " <> + "and/or separated by colons)" + end + end + @doc """ Returns the Network API path prefix. @@ -63,6 +125,23 @@ defmodule UnifiApi.Client do Application.get_env(:unifi_api, :protect_path, "/proxy/protect/integration") end + @doc """ + Returns the legacy v1 Network API path prefix. + + This is the prefix for the older `/api/s/{site}/...` and + `/v2/api/site/{site}/...` endpoints (events, alarms, IDS, anomalies, + historical clients, DPI, topology, etc.) that Ubiquiti has not yet + exposed under `x-api-key`. These endpoints require cookie + CSRF auth + via `UnifiApi.Auth.Cookie`. + + Defaults to `"/proxy/network"` (UDM). For Cloud Key / standalone + controllers, configure `v1_path: ""` in application config. + """ + @spec v1_prefix() :: String.t() + def v1_prefix do + Application.get_env(:unifi_api, :v1_path, "/proxy/network") + end + @doc """ Performs a GET request. @@ -87,6 +166,42 @@ defmodule UnifiApi.Client do |> handle_response() end + @doc """ + Performs a GET request against a legacy v1 endpoint and unwraps the + `%{"meta" => %{"rc" => "ok"}, "data" => [...]}` envelope. + + Used by `UnifiApi.Network.Events`, `Alarms`, `ClientsLive`, etc. — all + the endpoints that require cookie + CSRF auth via `UnifiApi.Auth.Cookie`. + + Returns: + + * `{:ok, data}` when `meta.rc == "ok"` (or no envelope is present). + * `{:error, {:unifi_error, msg}}` when the controller returns + `meta.rc == "error"` (e.g. `meta.msg = "api.err.LoginRequired"`). + * `{:error, reason}` for transport / non-2xx responses, same as `get/3`. + + ## Options + + Same as `get/3`. The `:params` option is the most useful here: + + Client.get_v1(client, "/proxy/network/api/s/default/stat/event", + params: [_limit: 100, within: 24]) + """ + @spec get_v1(client(), String.t(), keyword()) :: response() + def get_v1(client, path, opts \\ []) do + with {:ok, body} <- get(client, path, opts) do + unwrap_v1(body) + end + end + + defp unwrap_v1(%{"meta" => %{"rc" => "ok"}, "data" => data}), do: {:ok, data} + + defp unwrap_v1(%{"meta" => %{"rc" => "error"} = meta}), + do: {:error, {:unifi_error, meta["msg"] || "unknown"}} + + defp unwrap_v1(%{"data" => data}), do: {:ok, data} + defp unwrap_v1(other), do: {:ok, other} + @doc """ Performs a POST request with a JSON body. @@ -173,8 +288,8 @@ defmodule UnifiApi.Client do {:ok, %Req.Response{status: status, body: body}} when status in 200..299 -> {:ok, body} - {:ok, %Req.Response{status: status, body: body}} -> - {:error, {status, body}} + {:ok, %Req.Response{} = resp} -> + {:error, error_from_response(resp)} {:error, reason} -> {:error, reason} @@ -242,8 +357,120 @@ defmodule UnifiApi.Client do ) end + @doc """ + Generic page-number paginator for v2 endpoints that don't fit + `stream/3` or `stream_v1/3`. + + Takes a `fetch_page` function that receives the current cursor and + returns `{:ok, list}` (the page of items) or `{:error, reason}`. + Halts when a page returns fewer than `:limit` items. + + ## Options + + * `:limit` — items per page (default 500). Used to detect the + last page (a short page halts the stream). + * `:start_at` — initial cursor value (default 0; for endpoints + that page from 1 set `start_at: 1`). + * `:increment` — how much to advance the cursor between pages. + Use `1` for `pageNumber`-style paging, or set to `:limit` (the + page size) for offset-style paging. + + ## Examples + + Client.stream_paged( + fn page -> + Client.get_v1(client, "/v2/api/site/default/system-log/all", + params: [pageSize: 500, pageNumber: page]) + end, + limit: 500 + ) + + Used by `UnifiApi.Network.ClientsHistory.stream/3` and + `UnifiApi.Network.SystemLog.stream/3`. + """ + @spec stream_paged((non_neg_integer() -> response()), keyword()) :: Enumerable.t() + def stream_paged(fetch_page, opts \\ []) when is_function(fetch_page, 1) do + page_size = opts[:limit] || 500 + initial = Keyword.get(opts, :start_at, 0) + increment = Keyword.get(opts, :increment, 1) + + Stream.resource( + fn -> initial end, + fn + :halt -> + {:halt, :done} + + cursor -> + case fetch_page.(cursor) do + {:ok, items} when is_list(items) -> + if length(items) < page_size, + do: {items, :halt}, + else: {items, cursor + increment} + + {:error, reason} -> + raise UnifiApi.StreamError, reason: reason, path: "stream_paged" + + {:ok, non_list} -> + raise UnifiApi.StreamError, + reason: {:unexpected_response, non_list}, + path: "stream_paged" + end + end, + fn _state -> :ok end + ) + end + + @doc """ + Like `stream/3` but for legacy v1 endpoints. + + Pages on `_start` / `_limit` (the v1 convention) rather than + `offset` / `limit`, and unwraps the v1 response envelope via + `get_v1/3`. Used by `UnifiApi.Network.Events.stream/3`, + `Alarms.stream/3`, etc. + + ## Options + + * `:limit` — items per page (default: 500, the typical v1 cap) + * `:params` — additional query params merged on every request + (e.g. `[within: 24]` to time-window the entire stream) + """ + @spec stream_v1(client(), String.t(), keyword()) :: Enumerable.t() + def stream_v1(client, path, opts \\ []) do + page_size = opts[:limit] || 500 + base_params = Keyword.get(opts, :params, []) + + Stream.resource( + fn -> 0 end, + fn + :halt -> + {:halt, :done} + + start -> + params = base_params ++ [_start: start, _limit: page_size] + + case get_v1(client, path, params: params) do + {:ok, items} when is_list(items) -> + if length(items) < page_size, + do: {items, :halt}, + else: {items, start + page_size} + + {:error, reason} -> + raise UnifiApi.StreamError, reason: reason, path: path + + {:ok, non_list} -> + raise UnifiApi.StreamError, + reason: {:unexpected_response, non_list}, + path: path + end + end, + fn _state -> :ok end + ) + end + defp build_params(opts) do - [] + extra = Keyword.get(opts, :params, []) + + extra |> maybe_add(:offset, opts[:offset]) |> maybe_add(:limit, opts[:limit]) |> maybe_add(:filter, opts[:filter]) @@ -258,11 +485,52 @@ defmodule UnifiApi.Client do {:ok, body} end - defp handle_response({:ok, %Req.Response{status: status, body: body}}) do - {:error, {status, body}} + defp handle_response({:ok, %Req.Response{} = resp}) do + {:error, error_from_response(resp)} end defp handle_response({:error, reason}) do {:error, reason} end + + defp error_from_response(%Req.Response{status: 429, body: body} = resp) do + %UnifiApi.RateLimitError{ + retry_after: parse_retry_after(Req.Response.get_header(resp, "retry-after")), + status: 429, + body: body + } + end + + defp error_from_response(%Req.Response{status: 401, body: body}) do + %UnifiApi.AuthError{status: 401, body: body, reason: :unauthorized} + end + + defp error_from_response(%Req.Response{status: 403, body: body}) do + %UnifiApi.AuthError{status: 403, body: body, reason: :forbidden} + end + + defp error_from_response(%Req.Response{status: status, body: body}) do + {status, body} + end + + # Parses Retry-After header (RFC 7231 §7.1.3): seconds or HTTP-date. + # Falls back to 60s. Clamped to 1..300. + defp parse_retry_after([]), do: 60 + + defp parse_retry_after([value | _]) when is_binary(value) do + case Integer.parse(value) do + {seconds, ""} -> + clamp_retry_after(seconds) + + _ -> + case DateTime.from_iso8601(value) do + {:ok, dt, _} -> clamp_retry_after(DateTime.diff(dt, DateTime.utc_now())) + _ -> 60 + end + end + end + + defp clamp_retry_after(seconds) when seconds <= 1, do: 1 + defp clamp_retry_after(seconds) when seconds >= 300, do: 300 + defp clamp_retry_after(seconds), do: seconds end diff --git a/lib/unifi_api/error.ex b/lib/unifi_api/error.ex new file mode 100644 index 0000000..6e044e4 --- /dev/null +++ b/lib/unifi_api/error.ex @@ -0,0 +1,70 @@ +defmodule UnifiApi.RateLimitError do + @moduledoc """ + Returned when the controller responds with HTTP 429. + + The `:retry_after` field is parsed from the `Retry-After` response header. + Both seconds (integer) and HTTP-date forms are supported. Defaults to 60 + seconds if absent or unparseable, and is clamped to the range 1..300. + + ## Example + + case UnifiApi.Network.Clients.list(client, site_id) do + {:ok, clients} -> + handle(clients) + + {:error, %UnifiApi.RateLimitError{retry_after: seconds}} -> + Process.sleep(seconds * 1000) + retry() + end + """ + + defexception [:retry_after, :status, :body] + + @type t :: %__MODULE__{ + retry_after: pos_integer(), + status: 429, + body: term() + } + + @impl true + def message(%__MODULE__{retry_after: seconds}) do + "rate limited by controller (HTTP 429); retry after #{seconds}s" + end +end + +defmodule UnifiApi.AuthError do + @moduledoc """ + Returned when the controller responds with HTTP 401 or 403. + + ## Example + + case UnifiApi.Network.Sites.list(client) do + {:ok, sites} -> + handle(sites) + + {:error, %UnifiApi.AuthError{reason: :unauthorized}} -> + # API key invalid or expired + :reauth + + {:error, %UnifiApi.AuthError{reason: :forbidden}} -> + # API key valid but lacks permission for this resource + :no_access + end + """ + + defexception [:status, :body, :reason] + + @type reason :: :unauthorized | :forbidden + @type t :: %__MODULE__{ + status: 401 | 403, + body: term(), + reason: reason() + } + + @impl true + def message(%__MODULE__{reason: :unauthorized}), + do: "unauthorized (HTTP 401): missing or invalid credentials" + + def message(%__MODULE__{reason: :forbidden}), + do: "forbidden (HTTP 403): credentials lack permission for this resource" +end diff --git a/lib/unifi_api/formatter.ex b/lib/unifi_api/formatter.ex index d5f2aa6..5c103d7 100644 --- a/lib/unifi_api/formatter.ex +++ b/lib/unifi_api/formatter.ex @@ -177,6 +177,55 @@ defmodule UnifiApi.Formatter do table(data, ["name", "id", "internalReference"], title: "Sites") end + @doc """ + Shortcut: prints events from `UnifiApi.Network.Events.list/3` as a + table with subsystem colour-coding. + + ## Examples + + {:ok, events} = UnifiApi.Network.Events.list(authed, "default", within_hours: 1) + UnifiApi.Formatter.events(events) + """ + @spec events(list(map()) | map()) :: :ok + def events(data) do + table(data, ["datetime", "key", "subsystem", "msg"], + title: "Events", + colors: %{"subsystem" => :subsystem} + ) + end + + @doc """ + Shortcut: prints alarms from `UnifiApi.Network.Alarms.list/3` as a + table with severity colour-coding. + """ + @spec alarms(list(map()) | map()) :: :ok + def alarms(data) do + table(data, ["datetime", "severity", "subsystem", "key", "msg"], + title: "Alarms", + colors: %{"severity" => :severity, "subsystem" => :subsystem} + ) + end + + @doc """ + Shortcut: prints live wireless clients from + `UnifiApi.Network.ClientsLive.list/2`. + """ + @spec clients_live(list(map()) | map()) :: :ok + def clients_live(data) do + table(data, ["hostname", "mac", "ip", "signal", "satisfaction", "essid", "ap_name"], + title: "Clients (live)", + colors: %{"signal" => :rssi, "satisfaction" => :satisfaction} + ) + end + + @doc """ + Shortcut: prints anomalies from `UnifiApi.Network.Anomalies.list/3`. + """ + @spec anomalies(list(map()) | map()) :: :ok + def anomalies(data) do + table(data, ["datetime", "anomaly", "mac", "ap", "count"], title: "Anomalies") + end + # --- Private --- defp get_value(map, key) when is_map(map) do @@ -247,5 +296,53 @@ defmodule UnifiApi.Formatter do end end + defp get_color(value, :subsystem) do + case value do + "wlan" -> IO.ANSI.magenta() + "lan" -> IO.ANSI.blue() + "wan" -> IO.ANSI.cyan() + "vpn" -> IO.ANSI.cyan() + "ips" -> IO.ANSI.red() + "system" -> IO.ANSI.yellow() + "alarm" -> IO.ANSI.red() + _ -> "" + end + end + + defp get_color(value, :severity) do + case value do + "critical" -> IO.ANSI.red() + "error" -> IO.ANSI.red() + "warn" -> IO.ANSI.yellow() + "warning" -> IO.ANSI.yellow() + "info" -> IO.ANSI.blue() + _ -> "" + end + end + + # WiFi RSSI in dBm. Closer to 0 is stronger. + # >= -60 → green (excellent) + # -60..-70 → yellow + # < -70 → red + defp get_color(value, :rssi) do + case Integer.parse(value) do + {n, _} when n >= -60 -> IO.ANSI.green() + {n, _} when n >= -70 -> IO.ANSI.yellow() + {_, _} -> IO.ANSI.red() + :error -> "" + end + end + + # UniFi "satisfaction" score, 0..100. + # >= 80 → green, 50..79 → yellow, < 50 → red + defp get_color(value, :satisfaction) do + case Integer.parse(value) do + {n, _} when n >= 80 -> IO.ANSI.green() + {n, _} when n >= 50 -> IO.ANSI.yellow() + {_, _} -> IO.ANSI.red() + :error -> "" + end + end + defp get_color(_value, _), do: "" end diff --git a/lib/unifi_api/network/active_leases.ex b/lib/unifi_api/network/active_leases.ex new file mode 100644 index 0000000..370eb0a --- /dev/null +++ b/lib/unifi_api/network/active_leases.ex @@ -0,0 +1,29 @@ +defmodule UnifiApi.Network.ActiveLeases do + @moduledoc """ + UniFi Network API (v2) — active DHCP leases. + + Returns the live DHCP lease table from the gateway: assigned IPs, + hostnames, MACs, and lease expiry timestamps. + + Requires cookie + CSRF authentication. See `UnifiApi.Auth.Cookie`. + + ## Lease fields + + * `mac`, `ip`, `hostname` + * `network_id`, `network_name` + * `lease_time`, `expires_at` + * `is_static` — boolean (true for reserved leases) + """ + + alias UnifiApi.Client + + @doc """ + Returns the active DHCP lease table. + """ + @spec list(Req.Request.t(), String.t()) :: {:ok, term()} | {:error, term()} + def list(client, site_id) do + Client.get_v1(client, "#{prefix()}/v2/api/site/#{site_id}/active-leases") + end + + defp prefix, do: Client.v1_prefix() +end diff --git a/lib/unifi_api/network/alarms.ex b/lib/unifi_api/network/alarms.ex new file mode 100644 index 0000000..d26a898 --- /dev/null +++ b/lib/unifi_api/network/alarms.ex @@ -0,0 +1,97 @@ +defmodule UnifiApi.Network.Alarms do + @moduledoc """ + UniFi Network API (v1) — alarms. + + Returns active and archived alarms — the entries that show in the + controller dashboard's "Alerts" pane: gateway down, AP disconnected, + IDS detection, threshold crossings, etc. + + Requires cookie + CSRF authentication. See `UnifiApi.Auth.Cookie`. + + ## Alarm fields + + * `_id`, `key`, `time`, `datetime`, `site_id` + * `archived` — boolean + * `msg` — human-readable description + * `subsystem` — `"wlan"`, `"lan"`, `"wan"`, `"vpn"`, `"system"`, + `"ips"`, `"alarm"` + * `severity` — `"info"`, `"warn"`, `"critical"` + * For IPS alarms: `app_proto`, `catname`, `dest_ip`, `src_ip`, + `proto`, `signature`, `usgip` (gateway IP), `inner_alert_action` + + > **Note:** Like `Events`, the field set comes from community/unpoller + > documentation rather than first-party schema. File an issue if your + > controller returns extra or differently-shaped fields. + """ + + alias UnifiApi.Client + + @doc """ + Lists alarms for a site. + + ## Options + + * `:archived` — `true` returns only archived alarms; `false` returns + only active ones; `nil` (default) returns both. + * `:limit` — `_limit=N` server-side cap. + * `:start` — `_start=N` pagination offset. + + ## Examples + + # All active alarms + {:ok, alarms} = UnifiApi.Network.Alarms.list(client, "default", archived: false) + + # Just IPS detections + {:ok, alarms} = UnifiApi.Network.Alarms.list(client, "default") + Enum.filter(alarms, &(&1["subsystem"] == "ips")) + """ + @spec list(Req.Request.t(), String.t(), keyword()) :: {:ok, term()} | {:error, term()} + def list(client, site_id, opts \\ []) do + params = + [] + |> maybe_param(:archived, opts[:archived]) + |> maybe_param(:_limit, opts[:limit]) + |> maybe_param(:_start, opts[:start]) + + Client.get_v1(client, "#{prefix()}/api/s/#{site_id}/list/alarm", params: params) + end + + @doc """ + Marks an alarm as archived. + + ## Examples + + {:ok, _} = UnifiApi.Network.Alarms.archive(client, "default", alarm_id) + """ + @spec archive(Req.Request.t(), String.t(), String.t()) :: {:ok, term()} | {:error, term()} + def archive(client, site_id, alarm_id) do + Client.post( + client, + "#{prefix()}/api/s/#{site_id}/cmd/evtmgr", + %{cmd: "archive-alarm", _id: alarm_id} + ) + end + + @doc """ + Returns a lazy stream that auto-paginates alarms via `_start` / `_limit`. + + ## Options + + * `:archived` — `true` for archived only, `false` for active only. + * `:limit` — page size (default 500). + """ + @spec stream(Req.Request.t(), String.t(), keyword()) :: Enumerable.t() + def stream(client, site_id, opts \\ []) do + base_params = maybe_param([], :archived, opts[:archived]) + + Client.stream_v1(client, "#{prefix()}/api/s/#{site_id}/list/alarm", + limit: opts[:limit] || 500, + params: base_params + ) + end + + defp maybe_param(params, _key, nil), do: params + defp maybe_param(params, key, value), do: [{key, value} | params] + + defp prefix, do: Client.v1_prefix() +end diff --git a/lib/unifi_api/network/anomalies.ex b/lib/unifi_api/network/anomalies.ex new file mode 100644 index 0000000..66a7a03 --- /dev/null +++ b/lib/unifi_api/network/anomalies.ex @@ -0,0 +1,45 @@ +defmodule UnifiApi.Network.Anomalies do + @moduledoc """ + UniFi Network API (v1) — anomalies. + + Returns the controller's anomaly history: client connect failures, + poor signal events, channel saturation, and similar diagnostic noise + the controller flags automatically. + + Requires cookie + CSRF authentication. See `UnifiApi.Auth.Cookie`. + + ## Anomaly fields + + * `_id`, `time`, `datetime`, `site_id` + * `anomaly` — the class label, e.g. `"poor_signal"`, + `"too_many_clients"`, `"high_retries"` + * `mac`, `ap`, `subsystem` + * `count` — occurrences in the reporting window + + > **Note:** Field set is community-documented; treat any field as + > optional. + """ + + alias UnifiApi.Client + + @doc """ + Lists site anomalies. + + ## Options + + * `:within_hours` — return anomalies seen within the last N hours + (sent as `within=N`). + """ + @spec list(Req.Request.t(), String.t(), keyword()) :: {:ok, term()} | {:error, term()} + def list(client, site_id, opts \\ []) do + params = + case opts[:within_hours] do + nil -> [] + n -> [{:within, n}] + end + + Client.get_v1(client, "#{prefix()}/api/s/#{site_id}/stat/anomalies", params: params) + end + + defp prefix, do: Client.v1_prefix() +end diff --git a/lib/unifi_api/network/clients_history.ex b/lib/unifi_api/network/clients_history.ex new file mode 100644 index 0000000..a9cffdd --- /dev/null +++ b/lib/unifi_api/network/clients_history.ex @@ -0,0 +1,83 @@ +defmodule UnifiApi.Network.ClientsHistory do + @moduledoc """ + UniFi Network API (v2) — client history. + + Returns past client connections — devices that have been seen on the + network even if they're currently offline. Complements + `UnifiApi.Network.ClientsLive` (currently-connected) with longer-tail + history. + + Requires cookie + CSRF authentication. See `UnifiApi.Auth.Cookie`. + + ## Client fields + + * `id`, `mac`, `name`, `hostname`, `oui` + * `is_wired`, `is_guest` + * `first_seen`, `last_seen`, `connected_time` + * `network`, `ap_mac` / `ap_name` for wireless + * `manufacturer`, `os_name` + """ + + alias UnifiApi.Client + + @doc """ + Lists historical clients. + + ## Options + + * `:within_hours` — `withinHours=N`. + * `:type` — filter by `"WIRED"` / `"WIRELESS"` / `"GUEST"` / `"VPN"` + (sent as `type=...`). + * `:search` — string match against name/hostname (`searchString=...`). + * `:limit` — `pageSize=N`. + * `:offset` — `pageNumber=N`. + """ + @spec list(Req.Request.t(), String.t(), keyword()) :: {:ok, term()} | {:error, term()} + def list(client, site_id, opts \\ []) do + params = + [] + |> maybe_param(:withinHours, opts[:within_hours]) + |> maybe_param(:type, opts[:type]) + |> maybe_param(:searchString, opts[:search]) + |> maybe_param(:pageSize, opts[:limit]) + |> maybe_param(:pageNumber, opts[:offset]) + + Client.get_v1(client, "#{prefix()}/v2/api/site/#{site_id}/clients/history", params: params) + end + + @doc """ + Returns a lazy stream that auto-paginates client history via + `pageSize` / `pageNumber`. + + ## Options + + * `:within_hours`, `:type`, `:search` — same as `list/3`. + * `:limit` — page size (default 500). + """ + @spec stream(Req.Request.t(), String.t(), keyword()) :: Enumerable.t() + def stream(client, site_id, opts \\ []) do + page_size = opts[:limit] || 500 + + base_params = + [] + |> maybe_param(:withinHours, opts[:within_hours]) + |> maybe_param(:type, opts[:type]) + |> maybe_param(:searchString, opts[:search]) + + path = "#{prefix()}/v2/api/site/#{site_id}/clients/history" + + Client.stream_paged( + fn page -> + Client.get_v1(client, path, + params: base_params ++ [pageSize: page_size, pageNumber: page] + ) + end, + limit: page_size + ) + end + + defp maybe_param(params, _key, nil), do: params + defp maybe_param(params, key, value), do: [{key, value} | params] + + defp prefix, do: Client.v1_prefix() +end diff --git a/lib/unifi_api/network/clients_live.ex b/lib/unifi_api/network/clients_live.ex new file mode 100644 index 0000000..138ed82 --- /dev/null +++ b/lib/unifi_api/network/clients_live.ex @@ -0,0 +1,94 @@ +defmodule UnifiApi.Network.ClientsLive do + @moduledoc """ + UniFi Network API (v1) — live wireless client statistics. + + Returns the rich, real-time client data the controller dashboard uses: + RSSI, signal/noise, CCQ, satisfaction, tx/rx rate and MCS index, retry + counts, AP attachment, channel/radio info, OS/device fingerprinting. + + This is the endpoint to use for wireless quality dashboards — the + integration `UnifiApi.Network.Clients.list/3` returns a much thinner + shape. + + Requires cookie + CSRF authentication. See `UnifiApi.Auth.Cookie`. + + ## Client fields (selected) + + Identity: + + * `_id`, `mac`, `ip`, `hostname`, `name`, `oui` + * `network`, `network_id`, `vlan` + * `is_wired`, `is_guest` + + Wireless link quality: + + * `signal` (dBm), `noise` (dBm), `rssi` (dB), `ccq` + * `satisfaction` (0..100) + * `tx_rate`, `rx_rate` (Kbps) + * `tx_mcs`, `rx_mcs` + * `radio_proto` — `"ng"`, `"na"`, `"ac"`, `"ax"` + * `channel`, `essid`, `bssid` + + AP attachment: + + * `ap_mac`, `ap_name`, `ap_displayName` + + Counters / timing: + + * `tx_bytes`, `rx_bytes`, `tx_packets`, `rx_packets`, `tx_retries` + * `uptime`, `idletime`, `assoc_time`, `last_seen`, `roam_count` + + Device fingerprinting: + + * `os_class`, `os_name`, `dev_family`, `dev_id`, `dev_vendor`, + `dev_cat` + + > **Note:** Field set is based on community documentation. The + > actual response varies by AP firmware and client OS. Treat any + > field as optional. + """ + + alias UnifiApi.Client + + @doc """ + Lists currently-connected clients with full statistics. + + ## Examples + + {:ok, clients} = UnifiApi.Network.ClientsLive.list(client, "default") + + # Worst-RSSI clients + clients + |> Enum.reject(& &1["is_wired"]) + |> Enum.sort_by(& &1["signal"]) + |> Enum.take(10) + """ + @spec list(Req.Request.t(), String.t()) :: {:ok, term()} | {:error, term()} + def list(client, site_id) do + Client.get_v1(client, "#{prefix()}/api/s/#{site_id}/stat/sta") + end + + @doc """ + Lists every client the controller has ever seen, including offline ones. + + Returns the same shape as `list/2` plus a `last_seen` timestamp and + `first_seen`. Useful for historical inventory and "where did this + device go?" investigations. + + ## Options + + * `:within_hours` — only include clients seen within the last N hours. + """ + @spec list_all(Req.Request.t(), String.t(), keyword()) :: {:ok, term()} | {:error, term()} + def list_all(client, site_id, opts \\ []) do + params = + case opts[:within_hours] do + nil -> [] + n -> [{:within, n}] + end + + Client.get_v1(client, "#{prefix()}/api/s/#{site_id}/stat/alluser", params: params) + end + + defp prefix, do: Client.v1_prefix() +end diff --git a/lib/unifi_api/network/dashboard.ex b/lib/unifi_api/network/dashboard.ex new file mode 100644 index 0000000..88a82a3 --- /dev/null +++ b/lib/unifi_api/network/dashboard.ex @@ -0,0 +1,52 @@ +defmodule UnifiApi.Network.Dashboard do + @moduledoc """ + UniFi Network API (v2) — aggregated dashboard. + + Returns the pre-aggregated payload that powers the controller's + dashboard view: client/device counts, traffic totals, top apps, top + clients, and time-series for the configured history window. A single + network call instead of stitching together + `UnifiApi.Network.Clients`, `Devices`, `Traffic`, etc. + + Requires cookie + CSRF authentication. See `UnifiApi.Auth.Cookie`. + + ## Response shape + + Map with at least: + + * `clients` — current totals broken down by type + * `devices` — current totals broken down by type and state + * `traffic` — totals + time series for the window + * `topClients`, `topApps` + * `wanStatus` — per-WAN reachability + """ + + alias UnifiApi.Client + + @doc """ + Returns the aggregated dashboard payload. + + ## Options + + * `:history_seconds` — window length, default `3600` (last hour). + Common values: `3600` (1h), `86400` (24h), `604800` (7d). + + ## Examples + + {:ok, dashboard} = UnifiApi.Network.Dashboard.get(client, "default") + + # Last week + {:ok, weekly} = UnifiApi.Network.Dashboard.get(client, "default", + history_seconds: 604_800) + """ + @spec get(Req.Request.t(), String.t(), keyword()) :: {:ok, term()} | {:error, term()} + def get(client, site_id, opts \\ []) do + seconds = Keyword.get(opts, :history_seconds, 3600) + + Client.get_v1(client, "#{prefix()}/v2/api/site/#{site_id}/aggregated-dashboard", + params: [historySeconds: seconds] + ) + end + + defp prefix, do: Client.v1_prefix() +end diff --git a/lib/unifi_api/network/devices.ex b/lib/unifi_api/network/devices.ex index eac31be..fd6a087 100644 --- a/lib/unifi_api/network/devices.ex +++ b/lib/unifi_api/network/devices.ex @@ -4,6 +4,31 @@ defmodule UnifiApi.Network.Devices do Manage adopted UniFi devices (APs, switches, gateways), view statistics, execute device and port actions, and list pending adoption requests. + + ## Device fields + + * `id`, `name`, `mac`, `ip` + * `model`, `modelName` — hardware model identifiers (e.g. `"US-24-250W"`) + * `state` — `"CONNECTED"`, `"CONNECTING"`, `"DISCONNECTED"`, `"PENDING"`, + `"ADOPTING"`, `"PROVISIONING"`, `"UNREACHABLE"`, or `"UPGRADING"` + * `adopted` — boolean + * `firmwareVersion` + * `uplink` — uplink interface metadata (deviceId, port idx, mac) + * `features` — feature flags supported by this device + * `interfaces` — `%{ "ports" => [...], "radios" => [...] }` for switches/APs + + ## Statistics fields (`get_statistics/3`) + + * `uptimeSec`, `lastHeartbeatAt` + * `loadAverage1Min`, `loadAverage5Min`, `loadAverage15Min` + * `cpuUtilizationPct`, `memoryUtilizationPct` + * `uplink` — `%{ "rxRateBps" => _, "txRateBps" => _ }` + * `interfaces` — per-port/radio counters (rx/tx packets, bytes, errors) + + ## Pending devices (`list_pending/2`) + + Devices reachable on the network that have not yet been adopted. Each entry + has `mac`, `model`, `firmwareVersion`, and `ip`. """ alias UnifiApi.Client diff --git a/lib/unifi_api/network/dpi.ex b/lib/unifi_api/network/dpi.ex new file mode 100644 index 0000000..61537f4 --- /dev/null +++ b/lib/unifi_api/network/dpi.ex @@ -0,0 +1,135 @@ +defmodule UnifiApi.Network.DPI do + @moduledoc """ + UniFi Network API (v1) — deep packet inspection statistics. + + Returns per-application and per-category traffic stats, both + site-aggregated and per-client. + + Requires cookie + CSRF authentication. See `UnifiApi.Auth.Cookie`. + + Use `UnifiApi.Network.Resources.list_dpi_categories/1` and + `list_dpi_applications/1` (integration API, no cookie auth required) + to translate the numeric `cat` and `app` IDs returned here into + human-readable names. + + ## Stats fields (per `by_app` / `by_cat` entry) + + * `app` / `cat` — numeric ID + * `tx_bytes`, `rx_bytes`, `tx_packets`, `rx_packets` + * `clients` — number of distinct clients observed using this + app/category + """ + + alias UnifiApi.Client + + @doc """ + Returns site-wide DPI stats grouped by application and category. + + Response is a list of one entry per site with `by_app` and `by_cat` + arrays. + """ + @spec by_site(Req.Request.t(), String.t()) :: {:ok, term()} | {:error, term()} + def by_site(client, site_id) do + Client.get_v1(client, "#{prefix()}/api/s/#{site_id}/stat/sitedpi") + end + + @doc """ + Returns DPI stats grouped by client (MAC). + + Response is a list with one entry per client containing `mac` and the + same `by_app` / `by_cat` shape as `by_site/2`. + """ + @spec by_client(Req.Request.t(), String.t()) :: {:ok, term()} | {:error, term()} + def by_client(client, site_id) do + Client.get_v1(client, "#{prefix()}/api/s/#{site_id}/stat/stadpi") + end + + @doc """ + Annotates DPI stats with human-readable category and application names. + + The legacy DPI endpoints return numeric `cat` and `app` IDs only. + This helper joins them against the lists returned by + `UnifiApi.Network.Resources.list_dpi_categories/1` and + `list_dpi_applications/1` (which work with the integration API, no + cookie auth required). + + Adds `"category_name"` to each `by_cat` entry and `"category_name"` + + `"application_name"` to each `by_app` entry. Original numeric IDs + and counters are preserved. Unknown IDs map to `nil`. + + ## Examples + + # Once per session — these don't change often, cache them: + {:ok, categories} = UnifiApi.Network.Resources.list_dpi_categories(client) + {:ok, applications} = UnifiApi.Network.Resources.list_dpi_applications(client) + + # Then on every poll: + {:ok, dpi} = UnifiApi.Network.DPI.by_site(authed, "default") + + named = + UnifiApi.Network.DPI.with_names(dpi, + categories: categories, + applications: applications + ) + + # Top 10 apps by tx_bytes + named + |> Enum.flat_map(& &1["by_app"]) + |> Enum.sort_by(& &1["tx_bytes"], :desc) + |> Enum.take(10) + |> Enum.map(&{&1["application_name"], &1["tx_bytes"]}) + + ## Options + + * `:categories` — list of `%{"id" => int, "name" => str}` from + `Resources.list_dpi_categories/1`. Required. + * `:applications` — list of `%{"id" => int, "name" => str}` from + `Resources.list_dpi_applications/1`. Required. + """ + @spec with_names(list(map()), keyword()) :: list(map()) + def with_names(dpi_data, opts) when is_list(dpi_data) do + categories = Keyword.fetch!(opts, :categories) + applications = Keyword.fetch!(opts, :applications) + + cat_index = index_by_id(categories) + app_index = index_by_id(applications) + + Enum.map(dpi_data, &annotate_entry(&1, cat_index, app_index)) + end + + defp annotate_entry(entry, cat_index, app_index) do + by_cat = + entry + |> Map.get("by_cat", []) + |> Enum.map(&add_category_name(&1, cat_index)) + + by_app = + entry + |> Map.get("by_app", []) + |> Enum.map(&(&1 |> add_category_name(cat_index) |> add_application_name(app_index))) + + entry + |> Map.put("by_cat", by_cat) + |> Map.put("by_app", by_app) + end + + defp index_by_id(list) do + Map.new(list, fn item -> {item["id"], item["name"]} end) + end + + defp add_category_name(entry, index) do + case Map.get(entry, "cat") do + nil -> entry + id -> Map.put(entry, "category_name", Map.get(index, id)) + end + end + + defp add_application_name(entry, index) do + case Map.get(entry, "app") do + nil -> entry + id -> Map.put(entry, "application_name", Map.get(index, id)) + end + end + + defp prefix, do: Client.v1_prefix() +end diff --git a/lib/unifi_api/network/events.ex b/lib/unifi_api/network/events.ex new file mode 100644 index 0000000..b17075e --- /dev/null +++ b/lib/unifi_api/network/events.ex @@ -0,0 +1,101 @@ +defmodule UnifiApi.Network.Events do + @moduledoc """ + UniFi Network API (v1) — site events. + + Returns the controller's event log: client connect/disconnect, AP + adopt/disconnect, firmware updates, IPS events bubbled up, etc. + + Requires cookie + CSRF authentication. See `UnifiApi.Auth.Cookie`. + + ## Event fields + + Each event is a map with at least: + + * `_id`, `time` (unix ms), `datetime` (ISO 8601), `site_id` + * `key` — the event class, e.g. `"EVT_AP_Connected"`, + `"EVT_WU_Connected"`, `"EVT_LU_Disconnected"`, + `"EVT_FW_Restarted"`, `"EVT_IPS_IpsAlert"` + * `subsystem` — `"wlan"`, `"lan"`, `"wan"`, `"vpn"`, `"system"` + * `msg` — human-readable description + + Wireless events additionally have: `ap`, `ap_name`, `ap_displayName`, + `radio`, `essid`, `channel`, `bytes`, `duration`. + + Wired events have: `sw`, `sw_name`, `network`, `gw`, `gw_name`. + + Client-related events have: `user`, `hostname`. + + > **Note:** Endpoint shapes are based on community-documented behaviour + > (see `unpoller/unpoller`). They have not been exercised end-to-end + > against live hardware in this release — file an issue if a field is + > missing or shaped differently on your controller. + """ + + alias UnifiApi.Client + + @doc """ + Lists site events. + + ## Options + + * `:within_hours` — return events within the last N hours + (server-side, sent as `within=N`) + * `:limit` — max number of events to return (sent as `_limit=N`) + * `:start` — pagination offset within the result set (sent as + `_start=N`) + + ## Examples + + # Last 24 hours, up to 1000 events + {:ok, events} = UnifiApi.Network.Events.list(client, "default", + within_hours: 24, limit: 1000) + + # Filter to wireless connection events client-side + events + |> Enum.filter(&(&1["subsystem"] == "wlan")) + """ + @spec list(Req.Request.t(), String.t(), keyword()) :: {:ok, term()} | {:error, term()} + def list(client, site_id, opts \\ []) do + params = + [] + |> maybe_param(:within, opts[:within_hours]) + |> maybe_param(:_limit, opts[:limit]) + |> maybe_param(:_start, opts[:start]) + + Client.get_v1(client, "#{prefix()}/api/s/#{site_id}/stat/event", params: params) + end + + @doc """ + Returns a lazy stream that auto-paginates events via `_start` / `_limit`. + + ## Options + + * `:within_hours` — passed through to every page request as + `within=N`. + * `:limit` — page size (default 500). + + ## Examples + + # Stream every event in the last 24 hours + UnifiApi.Network.Events.stream(authed, "default", within_hours: 24) + |> Enum.to_list() + + # Stop early — only fetches one page + UnifiApi.Network.Events.stream(authed, "default") + |> Enum.take(50) + """ + @spec stream(Req.Request.t(), String.t(), keyword()) :: Enumerable.t() + def stream(client, site_id, opts \\ []) do + base_params = maybe_param([], :within, opts[:within_hours]) + + Client.stream_v1(client, "#{prefix()}/api/s/#{site_id}/stat/event", + limit: opts[:limit] || 500, + params: base_params + ) + end + + defp maybe_param(params, _key, nil), do: params + defp maybe_param(params, key, value), do: [{key, value} | params] + + defp prefix, do: Client.v1_prefix() +end diff --git a/lib/unifi_api/network/ids.ex b/lib/unifi_api/network/ids.ex new file mode 100644 index 0000000..70ef7d7 --- /dev/null +++ b/lib/unifi_api/network/ids.ex @@ -0,0 +1,73 @@ +defmodule UnifiApi.Network.IDS do + @moduledoc """ + UniFi Network API (v1) — IDS / IPS detections. + + Returns events flagged by the gateway's intrusion detection / prevention + engine: signature matches, blocked outbound, suspect inbound, etc. + + Requires cookie + CSRF authentication. See `UnifiApi.Auth.Cookie`. + + ## Detection fields + + * `_id`, `time`, `datetime`, `site_id` + * `key` — `"EVT_IPS_IpsAlert"`, `"EVT_IPS_IpsBlocked"`, ... + * `subsystem` — `"ips"` + * `app_proto` — application-layer protocol detected + * `catname` — Suricata category, e.g. `"Misc Attack"` + * `signature` — Suricata signature text + * `src_ip`, `dest_ip`, `src_port`, `dst_port`, `proto` + * `usgip` — gateway IP that observed the event + * `inner_alert_action` — `"allowed"`, `"blocked"` + * `host`, `srcMAC`, `dstMAC` + + > **Note:** Shape mirrors the legacy controller console; field + > availability depends on which IPS engine is active and the firmware + > revision. + """ + + alias UnifiApi.Client + + @doc """ + Lists IDS / IPS events. + + ## Options + + * `:within_hours` — `within=N`. + * `:limit` — `_limit=N`. + * `:start` — `_start=N`. + """ + @spec list(Req.Request.t(), String.t(), keyword()) :: {:ok, term()} | {:error, term()} + def list(client, site_id, opts \\ []) do + params = + [] + |> maybe_param(:within, opts[:within_hours]) + |> maybe_param(:_limit, opts[:limit]) + |> maybe_param(:_start, opts[:start]) + + Client.get_v1(client, "#{prefix()}/api/s/#{site_id}/stat/ips/event", params: params) + end + + @doc """ + Returns a lazy stream that auto-paginates IDS / IPS events via + `_start` / `_limit`. + + ## Options + + * `:within_hours` — passed through to every page as `within=N`. + * `:limit` — page size (default 500). + """ + @spec stream(Req.Request.t(), String.t(), keyword()) :: Enumerable.t() + def stream(client, site_id, opts \\ []) do + base_params = maybe_param([], :within, opts[:within_hours]) + + Client.stream_v1(client, "#{prefix()}/api/s/#{site_id}/stat/ips/event", + limit: opts[:limit] || 500, + params: base_params + ) + end + + defp maybe_param(params, _key, nil), do: params + defp maybe_param(params, key, value), do: [{key, value} | params] + + defp prefix, do: Client.v1_prefix() +end diff --git a/lib/unifi_api/network/port_anomalies.ex b/lib/unifi_api/network/port_anomalies.ex new file mode 100644 index 0000000..4c83e14 --- /dev/null +++ b/lib/unifi_api/network/port_anomalies.ex @@ -0,0 +1,30 @@ +defmodule UnifiApi.Network.PortAnomalies do + @moduledoc """ + UniFi Network API (v2) — switch port anomalies. + + Reports ports flagged by the controller for unusual behaviour: high + error rate, half-duplex on a presumed gigabit link, PoE faults, + flapping, etc. + + Requires cookie + CSRF authentication. See `UnifiApi.Auth.Cookie`. + + ## Anomaly fields + + * `device_id`, `device_name`, `port_idx` + * `anomaly` — the class label + * `count` — occurrences in the reporting window + * `last_seen` + """ + + alias UnifiApi.Client + + @doc """ + Lists port anomalies for the site. + """ + @spec list(Req.Request.t(), String.t()) :: {:ok, term()} | {:error, term()} + def list(client, site_id) do + Client.get_v1(client, "#{prefix()}/v2/api/site/#{site_id}/ports/port-anomalies") + end + + defp prefix, do: Client.v1_prefix() +end diff --git a/lib/unifi_api/network/port_forward.ex b/lib/unifi_api/network/port_forward.ex new file mode 100644 index 0000000..8ab6228 --- /dev/null +++ b/lib/unifi_api/network/port_forward.ex @@ -0,0 +1,76 @@ +defmodule UnifiApi.Network.PortForward do + @moduledoc """ + UniFi Network API (v1) — port-forward rules. + + Manages NAT port-forward rules on the gateway. + + Requires cookie + CSRF authentication. See `UnifiApi.Auth.Cookie`. + + ## Rule fields + + * `_id`, `name`, `enabled` + * `proto` — `"tcp"`, `"udp"`, `"tcp_udp"` + * `src` — source CIDR / `"any"` + * `dst_port`, `fwd_port`, `fwd` (forward IP) + * `pfwd_interface` — `"wan"`, `"wan2"`, `"both"` + * `log` — boolean, log packets matching this rule + """ + + alias UnifiApi.Client + + @doc """ + Lists all port-forward rules on a site. + """ + @spec list(Req.Request.t(), String.t()) :: {:ok, term()} | {:error, term()} + def list(client, site_id) do + Client.get_v1(client, "#{prefix()}/api/s/#{site_id}/rest/portforward") + end + + @doc """ + Returns a specific port-forward rule by id. + """ + @spec get(Req.Request.t(), String.t(), String.t()) :: {:ok, term()} | {:error, term()} + def get(client, site_id, rule_id) do + Client.get_v1(client, "#{prefix()}/api/s/#{site_id}/rest/portforward/#{rule_id}") + end + + @doc """ + Creates a new port-forward rule. + + ## Examples + + {:ok, rule} = UnifiApi.Network.PortForward.create(client, "default", %{ + name: "Game server", + enabled: true, + proto: "tcp_udp", + src: "any", + dst_port: "25565", + fwd: "192.168.1.50", + fwd_port: "25565", + pfwd_interface: "wan" + }) + """ + @spec create(Req.Request.t(), String.t(), map()) :: {:ok, term()} | {:error, term()} + def create(client, site_id, body) do + Client.post(client, "#{prefix()}/api/s/#{site_id}/rest/portforward", body) + end + + @doc """ + Updates an existing port-forward rule. + """ + @spec update(Req.Request.t(), String.t(), String.t(), map()) :: + {:ok, term()} | {:error, term()} + def update(client, site_id, rule_id, body) do + Client.put(client, "#{prefix()}/api/s/#{site_id}/rest/portforward/#{rule_id}", body) + end + + @doc """ + Deletes a port-forward rule. + """ + @spec delete(Req.Request.t(), String.t(), String.t()) :: {:ok, term()} | {:error, term()} + def delete(client, site_id, rule_id) do + Client.delete(client, "#{prefix()}/api/s/#{site_id}/rest/portforward/#{rule_id}") + end + + defp prefix, do: Client.v1_prefix() +end diff --git a/lib/unifi_api/network/rogue_ap.ex b/lib/unifi_api/network/rogue_ap.ex new file mode 100644 index 0000000..057d9c1 --- /dev/null +++ b/lib/unifi_api/network/rogue_ap.ex @@ -0,0 +1,40 @@ +defmodule UnifiApi.Network.RogueAP do + @moduledoc """ + UniFi Network API (v1) — neighbouring / rogue access points. + + Returns APs the site's UniFi APs have observed broadcasting nearby — + possibly your own (cooperating multi-controller setups), unmanaged + networks, or genuinely rogue APs. + + Requires cookie + CSRF authentication. See `UnifiApi.Auth.Cookie`. + + ## Rogue AP fields + + * `_id`, `bssid`, `essid`, `band`, `channel` + * `freq`, `signal`, `noise`, `rssi`, `security` + * `is_rogue` — boolean (operator override) + * `is_adhoc`, `is_ubnt` + * `last_seen`, `report_time` + * `ap_mac`, `ap_name` — the local AP that saw it + """ + + alias UnifiApi.Client + + @doc """ + Lists active (currently visible) rogue / neighbouring APs. + """ + @spec list(Req.Request.t(), String.t()) :: {:ok, term()} | {:error, term()} + def list(client, site_id) do + Client.get_v1(client, "#{prefix()}/api/s/#{site_id}/stat/rogueap") + end + + @doc """ + Lists APs the operator has explicitly acknowledged / known-listed. + """ + @spec list_known(Req.Request.t(), String.t()) :: {:ok, term()} | {:error, term()} + def list_known(client, site_id) do + Client.get_v1(client, "#{prefix()}/api/s/#{site_id}/rest/rogueknown") + end + + defp prefix, do: Client.v1_prefix() +end diff --git a/lib/unifi_api/network/sites.ex b/lib/unifi_api/network/sites.ex index b7c4e4f..5a63812 100644 --- a/lib/unifi_api/network/sites.ex +++ b/lib/unifi_api/network/sites.ex @@ -47,5 +47,48 @@ defmodule UnifiApi.Network.Sites do Client.stream(client, "#{prefix()}/v1/sites", opts) end + @doc """ + Looks up a site by display name and returns the full site map. + + Saves the boilerplate `Sites.list(client) |> Enum.find(...)` that + every script wanting a site ID by human-readable name ends up + writing. + + Matching is exact and case-sensitive. To match the controller's + internal slug (`internalReference`, e.g. `"default"`) use + `find_by_internal_reference/2` instead. + + ## Examples + + {:ok, site} = UnifiApi.Network.Sites.find_by_name(client, "HQ") + site_id = site["id"] + + # When you only care about the id: + {:ok, %{"id" => site_id}} = UnifiApi.Network.Sites.find_by_name(client, "HQ") + """ + @spec find_by_name(Req.Request.t(), String.t()) :: {:ok, map()} | {:error, term()} + def find_by_name(client, name) when is_binary(name) do + find_by(client, "name", name) + end + + @doc """ + Like `find_by_name/2` but matches against the `internalReference` + field — the controller's internal slug, e.g. `"default"`. + """ + @spec find_by_internal_reference(Req.Request.t(), String.t()) :: + {:ok, map()} | {:error, term()} + def find_by_internal_reference(client, ref) when is_binary(ref) do + find_by(client, "internalReference", ref) + end + + defp find_by(client, field, value) do + with {:ok, sites} <- list(client) do + case Enum.find(sites, &(Map.get(&1, field) == value)) do + nil -> {:error, :not_found} + site -> {:ok, site} + end + end + end + defp prefix, do: Client.network_prefix() end diff --git a/lib/unifi_api/network/system_log.ex b/lib/unifi_api/network/system_log.ex new file mode 100644 index 0000000..8515d7b --- /dev/null +++ b/lib/unifi_api/network/system_log.ex @@ -0,0 +1,65 @@ +defmodule UnifiApi.Network.SystemLog do + @moduledoc """ + UniFi Network API (v2) — system log. + + Returns the controller's structured system log: device adoption, + firmware updates, gateway events, threat-management actions, etc. This + is more focused than `UnifiApi.Network.Events` — events covers all + client/AP activity, system log covers controller-level operations. + + Requires cookie + CSRF authentication. See `UnifiApi.Auth.Cookie`. + + ## Common entry fields + + * `id`, `timestamp`, `category`, `level` + * `key`, `subsystem` + * `description` — human-readable message + * `device` — when the log line is device-attributed + """ + + alias UnifiApi.Client + + @doc """ + Returns all system log entries within the supported window. + + ## Options + + * `:limit` — server-side cap (`pageSize` query param). + * `:start` — bucket start (`pageNumber` query param). + """ + @spec list_all(Req.Request.t(), String.t(), keyword()) :: {:ok, term()} | {:error, term()} + def list_all(client, site_id, opts \\ []) do + params = + [] + |> maybe_param(:pageSize, opts[:limit]) + |> maybe_param(:pageNumber, opts[:start]) + + Client.get_v1(client, "#{prefix()}/v2/api/site/#{site_id}/system-log/all", params: params) + end + + @doc """ + Returns a lazy stream that auto-paginates the system log via + `pageSize` / `pageNumber`. + + ## Options + + * `:limit` — page size (default 500). + """ + @spec stream(Req.Request.t(), String.t(), keyword()) :: Enumerable.t() + def stream(client, site_id, opts \\ []) do + page_size = opts[:limit] || 500 + path = "#{prefix()}/v2/api/site/#{site_id}/system-log/all" + + Client.stream_paged( + fn page -> + Client.get_v1(client, path, params: [pageSize: page_size, pageNumber: page]) + end, + limit: page_size + ) + end + + defp maybe_param(params, _key, nil), do: params + defp maybe_param(params, key, value), do: [{key, value} | params] + + defp prefix, do: Client.v1_prefix() +end diff --git a/lib/unifi_api/network/topology.ex b/lib/unifi_api/network/topology.ex new file mode 100644 index 0000000..461979f --- /dev/null +++ b/lib/unifi_api/network/topology.ex @@ -0,0 +1,48 @@ +defmodule UnifiApi.Network.Topology do + @moduledoc """ + UniFi Network API (v2) — site topology graph. + + Returns the physical and logical connections the controller has + inferred between gateway, switches, APs, and uplink-attached clients — + the same data behind the dashboard's topology map. + + Requires cookie + CSRF authentication. See `UnifiApi.Auth.Cookie`. + + ## Response shape + + An array of nodes, each with at least: + + * `id`, `name`, `mac`, `model` + * `type` — `"gateway"`, `"switch"`, `"ap"`, `"client"` + * `state` — `"connected"`, `"disconnected"`, `"pending"` + * `parent` — id of the upstream node (gateway has none) + * `parentMac`, `parentPort` — for switch- and AP-attached nodes + * `uplinkType` — `"wire"`, `"wireless"` + + > **Note:** This endpoint is available on UniFi Network 7.x+. Field + > shapes track the v2 schema and may shift across firmware revisions. + """ + + alias UnifiApi.Client + + @doc """ + Returns the site topology graph. + + ## Examples + + {:ok, nodes} = UnifiApi.Network.Topology.get(client, "default") + + # Find the gateway + Enum.find(nodes, &(&1["type"] == "gateway")) + + # Group by parent + nodes + |> Enum.group_by(& &1["parent"]) + """ + @spec get(Req.Request.t(), String.t()) :: {:ok, term()} | {:error, term()} + def get(client, site_id) do + Client.get_v1(client, "#{prefix()}/v2/api/site/#{site_id}/topology") + end + + defp prefix, do: Client.v1_prefix() +end diff --git a/lib/unifi_api/network/traffic.ex b/lib/unifi_api/network/traffic.ex new file mode 100644 index 0000000..ee9995e --- /dev/null +++ b/lib/unifi_api/network/traffic.ex @@ -0,0 +1,57 @@ +defmodule UnifiApi.Network.Traffic do + @moduledoc """ + UniFi Network API (v2) — traffic time series. + + Returns aggregated traffic counters over a configurable time window — + per client and per destination country. + + Requires cookie + CSRF authentication. See `UnifiApi.Auth.Cookie`. + + ## Common params + + * `:start` — window start, unix milliseconds. + * `:end` — window end, unix milliseconds (defaults to now if omitted). + * `:interval` — bucket size: `"hour"`, `"day"`, `"week"`. + + ## Response shape + + An array of buckets, each with `time` and the relevant counters + (`tx_bytes`, `rx_bytes`, plus `country`/`mac` for the variant). + """ + + alias UnifiApi.Client + + @doc """ + Returns per-client traffic breakdown over the time window. + """ + @spec by_client(Req.Request.t(), String.t(), keyword()) :: + {:ok, term()} | {:error, term()} + def by_client(client, site_id, opts \\ []) do + Client.get_v1(client, "#{prefix()}/v2/api/site/#{site_id}/traffic", + params: build_params(opts) + ) + end + + @doc """ + Returns destination-country traffic breakdown over the time window. + """ + @spec by_country(Req.Request.t(), String.t(), keyword()) :: + {:ok, term()} | {:error, term()} + def by_country(client, site_id, opts \\ []) do + Client.get_v1(client, "#{prefix()}/v2/api/site/#{site_id}/country-traffic", + params: build_params(opts) + ) + end + + defp build_params(opts) do + [] + |> maybe_param(:start, opts[:start]) + |> maybe_param(:end, opts[:end]) + |> maybe_param(:interval, opts[:interval]) + end + + defp maybe_param(params, _key, nil), do: params + defp maybe_param(params, key, value), do: [{key, value} | params] + + defp prefix, do: Client.v1_prefix() +end diff --git a/lib/unifi_api/network/ups.ex b/lib/unifi_api/network/ups.ex new file mode 100644 index 0000000..b7ce6ae --- /dev/null +++ b/lib/unifi_api/network/ups.ex @@ -0,0 +1,31 @@ +defmodule UnifiApi.Network.UPS do + @moduledoc """ + UniFi Network API (v1) — UPS devices. + + Returns connected UPS units (typically attached to a UDM Pro via USB) + with battery, runtime, and load telemetry. + + Requires cookie + CSRF authentication. See `UnifiApi.Auth.Cookie`. + + ## UPS fields + + * `name`, `model`, `serial` + * `ups_status` — `"OL"` (online), `"OB"` (on battery), `"LB"` (low + battery) + * `battery_charge` (0..100 percent), `battery_runtime` (seconds) + * `input_voltage`, `output_voltage` + * `load_pct` + """ + + alias UnifiApi.Client + + @doc """ + Lists connected UPS devices on a site. + """ + @spec list(Req.Request.t(), String.t()) :: {:ok, term()} | {:error, term()} + def list(client, site_id) do + Client.get_v1(client, "#{prefix()}/api/s/#{site_id}/stat/ups-devices") + end + + defp prefix, do: Client.v1_prefix() +end diff --git a/lib/unifi_api/network/wan.ex b/lib/unifi_api/network/wan.ex new file mode 100644 index 0000000..92d77b4 --- /dev/null +++ b/lib/unifi_api/network/wan.ex @@ -0,0 +1,56 @@ +defmodule UnifiApi.Network.WAN do + @moduledoc """ + UniFi Network API (v2) — WAN / ISP health. + + Reports gateway WAN status, ISP up/down state, load-balancing config, + and SLA monitoring data. + + Requires cookie + CSRF authentication. See `UnifiApi.Auth.Cookie`. + + ## Endpoints exposed + + * `enriched_config/2` — full WAN configuration with derived state. + * `isp_status/3` — ISP reachability and latency for one WAN id. + * `load_balancing/2` — multi-WAN load-balance configuration / status. + * `slas/2` — Internet SLA monitoring history. + """ + + alias UnifiApi.Client + + @doc """ + Returns the enriched WAN configuration: per-WAN type, identifiers, + and derived state. + """ + @spec enriched_config(Req.Request.t(), String.t()) :: {:ok, term()} | {:error, term()} + def enriched_config(client, site_id) do + Client.get_v1(client, "#{prefix()}/v2/api/site/#{site_id}/wan/enriched-configuration") + end + + @doc """ + Returns ISP reachability and latency status for a specific WAN. + """ + @spec isp_status(Req.Request.t(), String.t(), String.t()) :: + {:ok, term()} | {:error, term()} + def isp_status(client, site_id, wan_id) do + Client.get_v1(client, "#{prefix()}/v2/api/site/#{site_id}/wan/#{wan_id}/isp-status") + end + + @doc """ + Returns the multi-WAN load-balancing configuration and current + per-WAN weights. + """ + @spec load_balancing(Req.Request.t(), String.t()) :: {:ok, term()} | {:error, term()} + def load_balancing(client, site_id) do + Client.get_v1(client, "#{prefix()}/v2/api/site/#{site_id}/wan/load-balancing") + end + + @doc """ + Returns SLA monitoring data for the site's WAN connections. + """ + @spec slas(Req.Request.t(), String.t()) :: {:ok, term()} | {:error, term()} + def slas(client, site_id) do + Client.get_v1(client, "#{prefix()}/v2/api/site/#{site_id}/wan-slas") + end + + defp prefix, do: Client.v1_prefix() +end diff --git a/lib/unifi_api/protect/events.ex b/lib/unifi_api/protect/events.ex new file mode 100644 index 0000000..8ae45cf --- /dev/null +++ b/lib/unifi_api/protect/events.ex @@ -0,0 +1,103 @@ +defmodule UnifiApi.Protect.Events do + @moduledoc """ + UniFi Protect API — event log. + + Returns motion, ring, smartDetect, and recording events emitted by + Protect cameras and other devices. Use this for security automation + ("when the front door rings, do X") or for ad-hoc forensics ("what + motion was detected last Tuesday around midnight?"). + + Requires cookie + CSRF authentication. See `UnifiApi.Auth.Cookie`. + + ## Event fields + + * `id`, `type` — `"motion"`, `"ring"`, `"smartDetectZone"`, + `"smartAudioDetect"`, `"recording"`, `"package"` + * `start`, `end` — unix milliseconds + * `score` — confidence (0..100) for smart detections + * `camera`, `cameraId` — source device + * `smartDetectTypes` — list of `"person"`, `"vehicle"`, `"animal"`, + `"package"`, `"licensePlate"` for smart events + * `thumbnail`, `heatmap` — opaque IDs to fetch via + `thumbnail/3` (binary JPEG) + """ + + alias UnifiApi.Client + + @prefix "/proxy/protect" + + @doc """ + Lists events. + + ## Options + + * `:start` — window start, unix milliseconds. + * `:end` — window end, unix milliseconds. + * `:types` — filter to specific event types, list of strings. + * `:cameras` — filter to specific camera ids, list of strings. + * `:limit` — server-side cap. + + ## Examples + + # Last hour, motion only + hour_ago = (System.os_time(:millisecond) - 60 * 60 * 1000) + + {:ok, events} = UnifiApi.Protect.Events.list(client, + start: hour_ago, types: ["motion", "smartDetectZone"], limit: 100) + """ + @spec list(Req.Request.t(), keyword()) :: {:ok, term()} | {:error, term()} + def list(client, opts \\ []) do + params = + [] + |> maybe_param(:start, opts[:start]) + |> maybe_param(:end, opts[:end]) + |> maybe_param(:limit, opts[:limit]) + |> maybe_csv(:types, opts[:types]) + |> maybe_csv(:cameras, opts[:cameras]) + + Client.get(client, "#{@prefix}/api/events", params: params) + end + + @doc """ + Returns a single event by id. + """ + @spec get(Req.Request.t(), String.t()) :: {:ok, term()} | {:error, term()} + def get(client, event_id) do + Client.get(client, "#{@prefix}/api/events/#{event_id}") + end + + @doc """ + Returns the event thumbnail as a JPEG binary. + + ## Examples + + {:ok, jpeg} = UnifiApi.Protect.Events.thumbnail(client, event_id) + File.write!("event.jpg", jpeg) + """ + @spec thumbnail(Req.Request.t(), String.t(), keyword()) :: + {:ok, binary()} | {:error, term()} + def thumbnail(client, event_id, opts \\ []) do + params = + [] + |> maybe_param(:w, opts[:width]) + |> maybe_param(:h, opts[:height]) + + Client.get_raw(client, "#{@prefix}/api/events/#{event_id}/thumbnail", params: params) + end + + @doc """ + Returns Protect's system event log (firmware updates, NVR events, + device adoption, etc. — distinct from camera events). + """ + @spec system_logs(Req.Request.t()) :: {:ok, term()} | {:error, term()} + def system_logs(client) do + Client.get(client, "#{@prefix}/api/events/system-logs") + end + + defp maybe_param(params, _key, nil), do: params + defp maybe_param(params, key, value), do: [{key, value} | params] + + defp maybe_csv(params, _key, nil), do: params + defp maybe_csv(params, _key, []), do: params + defp maybe_csv(params, key, list), do: [{key, Enum.join(list, ",")} | params] +end diff --git a/lib/unifi_api/time.ex b/lib/unifi_api/time.ex new file mode 100644 index 0000000..b2765d3 --- /dev/null +++ b/lib/unifi_api/time.ex @@ -0,0 +1,38 @@ +defmodule UnifiApi.Time do + @moduledoc """ + Tiny time helpers for the v1 / v2 endpoints that take unix-millisecond + timestamps (`UnifiApi.Protect.Events.list/2` `:start` / `:end`, + `UnifiApi.Network.Traffic.by_client/3` `:start` / `:end`, etc.). + + Saves the boilerplate `System.os_time(:millisecond) - 60 * 60 * 1000` + that every time-window query ends up needing. + + ## Examples + + import UnifiApi.Time + + # Last hour of motion events + {:ok, events} = + UnifiApi.Protect.Events.list(authed, + start: hours_ago(1), + end: now_ms(), + types: ["motion"] + ) + """ + + @doc "Current time in unix milliseconds." + @spec now_ms() :: integer() + def now_ms, do: System.os_time(:millisecond) + + @doc "Unix milliseconds for `n` minutes ago. Accepts integers or floats." + @spec minutes_ago(number()) :: integer() + def minutes_ago(n) when is_number(n), do: now_ms() - trunc(n * 60_000) + + @doc "Unix milliseconds for `n` hours ago." + @spec hours_ago(number()) :: integer() + def hours_ago(n) when is_number(n), do: now_ms() - trunc(n * 3_600_000) + + @doc "Unix milliseconds for `n` days ago." + @spec days_ago(number()) :: integer() + def days_ago(n) when is_number(n), do: now_ms() - trunc(n * 86_400_000) +end diff --git a/mix.exs b/mix.exs index d9bdb4c..919be30 100644 --- a/mix.exs +++ b/mix.exs @@ -1,7 +1,7 @@ defmodule UnifiApi.MixProject do use Mix.Project - @version "0.2.0" + @version "0.3.0" def project do [ @@ -34,15 +34,20 @@ defmodule UnifiApi.MixProject do # Run "mix help deps" to learn about dependencies. defp package do [ + maintainers: ["Niko Maroulis"], licenses: ["Apache-2.0"], - links: %{"GitHub" => "https://github.com/nyo16/unifi_api"} + links: %{ + "GitHub" => "https://github.com/nyo16/unifi_api", + "Changelog" => "https://github.com/nyo16/unifi_api/blob/master/CHANGELOG.md", + "Upgrading" => "https://github.com/nyo16/unifi_api/blob/master/UPGRADING.md" + } ] end defp docs do [ main: "readme", - extras: ["README.md", "LICENSE"], + extras: ["README.md", "CHANGELOG.md", "UPGRADING.md", "LICENSE"], groups_for_modules: [ "Network API": [ UnifiApi.Network.Info, @@ -58,9 +63,37 @@ defmodule UnifiApi.MixProject do UnifiApi.Network.TrafficMatching, UnifiApi.Network.Resources ], + "Network API (Operational, v1)": [ + UnifiApi.Network.ActiveLeases, + UnifiApi.Network.Alarms, + UnifiApi.Network.Anomalies, + UnifiApi.Network.ClientsHistory, + UnifiApi.Network.ClientsLive, + UnifiApi.Network.Dashboard, + UnifiApi.Network.DPI, + UnifiApi.Network.Events, + UnifiApi.Network.IDS, + UnifiApi.Network.PortAnomalies, + UnifiApi.Network.PortForward, + UnifiApi.Network.RogueAP, + UnifiApi.Network.SystemLog, + UnifiApi.Network.Topology, + UnifiApi.Network.Traffic, + UnifiApi.Network.UPS, + UnifiApi.Network.WAN + ], Utilities: [ UnifiApi.Formatter ], + Errors: [ + UnifiApi.AuthError, + UnifiApi.RateLimitError, + UnifiApi.StreamError + ], + Authentication: [ + UnifiApi.Auth.Cookie, + UnifiApi.Auth.Session + ], "Protect API": [ UnifiApi.Protect.Cameras, UnifiApi.Protect.NVR, @@ -69,6 +102,9 @@ defmodule UnifiApi.MixProject do UnifiApi.Protect.Sensors, UnifiApi.Protect.Lights, UnifiApi.Protect.Chimes + ], + "Protect API (v1)": [ + UnifiApi.Protect.Events ] ] ] diff --git a/test/unifi_api/auth/cookie_test.exs b/test/unifi_api/auth/cookie_test.exs new file mode 100644 index 0000000..96fa87f --- /dev/null +++ b/test/unifi_api/auth/cookie_test.exs @@ -0,0 +1,174 @@ +defmodule UnifiApi.Auth.CookieTest do + use ExUnit.Case, async: true + + alias UnifiApi.Auth.Cookie + alias UnifiApi.AuthError + + defp test_client(plug) do + Req.new(base_url: "http://localhost", plug: plug, retry: false) + end + + describe "login/4 — UDM style" do + test "POSTs username/password to /api/auth/login" do + client = + test_client(fn conn -> + assert conn.method == "POST" + assert conn.request_path == "/api/auth/login" + + {:ok, raw, conn} = Plug.Conn.read_body(conn) + assert JSON.decode!(raw) == %{"username" => "admin", "password" => "secret"} + + conn + |> Plug.Conn.put_resp_header("x-csrf-token", "token-123") + |> Plug.Conn.put_resp_header("set-cookie", "TOKEN=abc123; Path=/; HttpOnly") + |> Plug.Conn.put_resp_content_type("application/json") + |> Plug.Conn.send_resp(200, JSON.encode!(%{"unique_id" => "user-1"})) + end) + + assert {:ok, %Req.Request{} = authed} = Cookie.login(client, "admin", "secret") + assert Req.Request.get_header(authed, "cookie") == ["TOKEN=abc123"] + assert Req.Request.get_header(authed, "x-csrf-token") == ["token-123"] + assert Cookie.csrf_token(authed) == "token-123" + end + + test "merges multiple cookies into one Cookie header" do + client = + test_client(fn conn -> + conn + |> Plug.Conn.prepend_resp_headers([ + {"set-cookie", "TOKEN=abc; Path=/"}, + {"set-cookie", "session=xyz; Path=/"} + ]) + |> Plug.Conn.put_resp_header("x-csrf-token", "t") + |> Plug.Conn.send_resp(200, "{}") + end) + + assert {:ok, authed} = Cookie.login(client, "u", "p") + [cookie] = Req.Request.get_header(authed, "cookie") + assert cookie =~ "TOKEN=abc" + assert cookie =~ "session=xyz" + end + end + + describe "login/4 — Cloud Key style" do + test "POSTs to /api/login with remember flag" do + client = + test_client(fn conn -> + assert conn.request_path == "/api/login" + {:ok, raw, conn} = Plug.Conn.read_body(conn) + body = JSON.decode!(raw) + assert body == %{"username" => "u", "password" => "p", "remember" => false} + + conn + |> Plug.Conn.prepend_resp_headers([ + {"set-cookie", "unifises=abc; Path=/"}, + {"set-cookie", "csrf_token=tok-456; Path=/"} + ]) + |> Plug.Conn.send_resp(200, "{}") + end) + + assert {:ok, authed} = Cookie.login(client, "u", "p", style: :cloud_key) + assert Cookie.csrf_token(authed) == "tok-456" + [cookie] = Req.Request.get_header(authed, "cookie") + assert cookie =~ "unifises=abc" + assert cookie =~ "csrf_token=tok-456" + end + + test "honours :remember option" do + client = + test_client(fn conn -> + {:ok, raw, conn} = Plug.Conn.read_body(conn) + assert JSON.decode!(raw) |> Map.get("remember") == true + Plug.Conn.send_resp(conn, 200, "{}") + end) + + assert {:ok, _} = Cookie.login(client, "u", "p", style: :cloud_key, remember: true) + end + end + + describe "login/4 — error paths" do + test "401 returns %AuthError{reason: :unauthorized}" do + client = + test_client(fn conn -> + conn + |> Plug.Conn.put_resp_content_type("application/json") + |> Plug.Conn.send_resp(401, JSON.encode!(%{"error" => "bad creds"})) + end) + + assert {:error, %AuthError{status: 401, reason: :unauthorized}} = + Cookie.login(client, "u", "wrong") + end + + test "403 returns %AuthError{reason: :forbidden}" do + client = + test_client(fn conn -> + Plug.Conn.send_resp(conn, 403, "{}") + end) + + assert {:error, %AuthError{status: 403, reason: :forbidden}} = + Cookie.login(client, "u", "p") + end + + test "500 returns generic {:error, {status, body}}" do + client = + test_client(fn conn -> + conn + |> Plug.Conn.put_resp_content_type("application/json") + |> Plug.Conn.send_resp(500, JSON.encode!(%{"error" => "boom"})) + end) + + assert {:error, {500, %{"error" => "boom"}}} = Cookie.login(client, "u", "p") + end + end + + describe "refresh_csrf/2" do + test "captures rotated CSRF from probe response" do + client = + test_client(fn conn -> + conn + |> Plug.Conn.put_resp_header("x-csrf-token", "rotated-789") + |> Plug.Conn.send_resp(200, "") + end) + + assert {:ok, refreshed} = Cookie.refresh_csrf(client) + assert Cookie.csrf_token(refreshed) == "rotated-789" + assert Req.Request.get_header(refreshed, "x-csrf-token") == ["rotated-789"] + end + + test "returns the original client unchanged when probe has no CSRF" do + client = test_client(fn conn -> Plug.Conn.send_resp(conn, 200, "") end) + assert {:ok, ^client} = Cookie.refresh_csrf(client) + end + end + + describe "csrf_token/1" do + test "returns nil when no token has been installed" do + client = test_client(fn _ -> raise "unused" end) + assert Cookie.csrf_token(client) == nil + end + end + + describe "logout/2" do + test "POSTs to /api/auth/logout for :udm style (default)" do + client = + test_client(fn conn -> + assert conn.method == "POST" + assert conn.request_path == "/api/auth/logout" + Plug.Conn.send_resp(conn, 200, "") + end) + + assert :ok = Cookie.logout(client) + end + + test "POSTs to /api/logout for :cloud_key style" do + client = + test_client(fn conn -> + assert conn.method == "POST" + assert conn.request_path == "/api/logout" + Plug.Conn.send_resp(conn, 200, "") + end) + + assert :ok = Cookie.logout(client, style: :cloud_key) + end + end +end diff --git a/test/unifi_api/auth/session_test.exs b/test/unifi_api/auth/session_test.exs new file mode 100644 index 0000000..cc3b8fb --- /dev/null +++ b/test/unifi_api/auth/session_test.exs @@ -0,0 +1,184 @@ +defmodule UnifiApi.Auth.SessionTest do + use ExUnit.Case, async: true + + alias UnifiApi.Auth.Session + + defp make_client(plug) do + Req.new(base_url: "http://localhost", plug: plug, retry: false) + end + + defp counter_plug(state_pid, fun) do + fn conn -> + n = Agent.get_and_update(state_pid, fn n -> {n, n + 1} end) + fun.(conn, n) + end + end + + describe "start_link/1" do + test "logs in synchronously and stores the authed request" do + base = + make_client(fn conn -> + assert conn.request_path == "/api/auth/login" + + conn + |> Plug.Conn.put_resp_header("x-csrf-token", "tok-1") + |> Plug.Conn.prepend_resp_headers([{"set-cookie", "TOKEN=abc; Path=/"}]) + |> Plug.Conn.send_resp(200, "{}") + end) + + {:ok, session} = + Session.start_link(client: base, username: "admin", password: "secret", style: :udm) + + assert Session.csrf_token(session) == "tok-1" + end + + test "stops with the AuthError when credentials are rejected" do + base = + make_client(fn conn -> Plug.Conn.send_resp(conn, 401, "{}") end) + + Process.flag(:trap_exit, true) + + assert {:error, %UnifiApi.AuthError{status: 401}} = + Session.start_link(client: base, username: "admin", password: "wrong") + end + + test "returns error when :client is missing" do + Process.flag(:trap_exit, true) + + assert {:error, {%ArgumentError{message: msg}, _stack}} = + Session.start_link(username: "u", password: "p") + + assert msg =~ ":client" + end + end + + describe "client/1" do + test "returned request reads CURRENT auth state from the session" do + {:ok, agent} = Agent.start_link(fn -> 0 end) + + base = + make_client( + counter_plug(agent, fn conn, 0 -> + assert conn.request_path == "/api/auth/login" + + conn + |> Plug.Conn.put_resp_header("x-csrf-token", "tok-1") + |> Plug.Conn.prepend_resp_headers([{"set-cookie", "TOKEN=abc; Path=/"}]) + |> Plug.Conn.send_resp(200, "{}") + end) + ) + + {:ok, session} = + Session.start_link(client: base, username: "admin", password: "secret") + + # Now swap in a plug that asserts on the headers + rotates CSRF + authed_plug = fn conn -> + # Each request should carry the latest CSRF + token = Plug.Conn.get_req_header(conn, "x-csrf-token") |> List.first() + cookie = Plug.Conn.get_req_header(conn, "cookie") |> List.first() + send(self(), {:got_request, token, cookie}) + + next_token = + case token do + "tok-1" -> "tok-2" + "tok-2" -> "tok-3" + _ -> "tok-x" + end + + conn + |> Plug.Conn.put_resp_header("x-csrf-token", next_token) + |> Plug.Conn.put_resp_content_type("application/json") + |> Plug.Conn.send_resp(200, ~s|{"meta":{"rc":"ok"},"data":[]}|) + end + + authed = Session.client(session) |> Req.merge(plug: authed_plug) + {:ok, _} = UnifiApi.Network.Events.list(authed, "default") + # Wait for cast to be processed + assert "tok-2" = Session.csrf_token(session) + + # Second call: rebind authed (same wrapping) and confirm tok-2 is sent + authed2 = Session.client(session) |> Req.merge(plug: authed_plug) + {:ok, _} = UnifiApi.Network.Events.list(authed2, "default") + assert "tok-3" = Session.csrf_token(session) + end + + test "handle_cast ignores empty x-csrf-token" do + base = + make_client(fn conn -> + conn + |> Plug.Conn.put_resp_header("x-csrf-token", "tok-1") + |> Plug.Conn.prepend_resp_headers([{"set-cookie", "TOKEN=abc; Path=/"}]) + |> Plug.Conn.send_resp(200, "{}") + end) + + {:ok, session} = Session.start_link(client: base, username: "u", password: "p") + + authed_plug = fn conn -> + # Don't set x-csrf-token at all + conn + |> Plug.Conn.put_resp_content_type("application/json") + |> Plug.Conn.send_resp(200, ~s|{"meta":{"rc":"ok"},"data":[]}|) + end + + authed = Session.client(session) |> Req.merge(plug: authed_plug) + {:ok, _} = UnifiApi.Network.Events.list(authed, "default") + assert "tok-1" = Session.csrf_token(session) + end + end + + describe "refresh/1" do + test "issues a probe and updates the stored token" do + {:ok, counter} = Agent.start_link(fn -> 0 end) + + base = + make_client( + counter_plug(counter, fn + conn, 0 -> + # login + conn + |> Plug.Conn.put_resp_header("x-csrf-token", "tok-original") + |> Plug.Conn.prepend_resp_headers([{"set-cookie", "TOKEN=abc; Path=/"}]) + |> Plug.Conn.send_resp(200, "{}") + + conn, 1 -> + # refresh probe + assert conn.request_path == "/" + + conn + |> Plug.Conn.put_resp_header("x-csrf-token", "tok-refreshed") + |> Plug.Conn.send_resp(200, "") + end) + ) + + {:ok, session} = Session.start_link(client: base, username: "u", password: "p") + assert "tok-original" = Session.csrf_token(session) + + assert :ok = Session.refresh(session) + assert "tok-refreshed" = Session.csrf_token(session) + end + end + + describe "relogin/1" do + test "re-runs the login flow and replaces the authed struct" do + {:ok, counter} = Agent.start_link(fn -> 0 end) + + base = + make_client( + counter_plug(counter, fn conn, n -> + assert conn.request_path == "/api/auth/login" + + conn + |> Plug.Conn.put_resp_header("x-csrf-token", "tok-#{n}") + |> Plug.Conn.prepend_resp_headers([{"set-cookie", "TOKEN=cookie-#{n}; Path=/"}]) + |> Plug.Conn.send_resp(200, "{}") + end) + ) + + {:ok, session} = Session.start_link(client: base, username: "u", password: "p") + assert "tok-0" = Session.csrf_token(session) + + assert :ok = Session.relogin(session) + assert "tok-1" = Session.csrf_token(session) + end + end +end diff --git a/test/unifi_api/client_test.exs b/test/unifi_api/client_test.exs index f7cb012..c1ab121 100644 --- a/test/unifi_api/client_test.exs +++ b/test/unifi_api/client_test.exs @@ -49,6 +49,82 @@ defmodule UnifiApi.ClientTest do # When verify_ssl is true, connect_options should be empty (no verify_none) assert client.options.connect_options == [] end + + test "defaults to verify_none transport opts" do + client = Client.new(base_url: "https://10.0.0.1", api_key: "abc") + assert client.options.connect_options == [transport_opts: [verify: :verify_none]] + end + + test "cert_fingerprints installs verify_peer with custom verify_fun" do + fp = String.duplicate("ab", 32) + client = Client.new(base_url: "https://10.0.0.1", api_key: "k", cert_fingerprints: [fp]) + + [transport_opts: tls] = client.options.connect_options + assert tls[:verify] == :verify_peer + assert is_list(tls[:cacerts]) + assert {fun, nil} = tls[:verify_fun] + assert is_function(fun, 3) + end + + test "cert_fingerprints overrides verify_ssl" do + fp = String.duplicate("cd", 32) + + client = + Client.new( + base_url: "https://10.0.0.1", + api_key: "k", + verify_ssl: false, + cert_fingerprints: [fp] + ) + + [transport_opts: tls] = client.options.connect_options + assert tls[:verify] == :verify_peer + end + + test "cert_fingerprints raises on invalid input" do + assert_raise ArgumentError, ~r/invalid SHA-256 cert fingerprint/, fn -> + Client.new(base_url: "https://10.0.0.1", api_key: "k", cert_fingerprints: ["nope"]) + end + end + end + + describe "decode_fingerprint!/1" do + test "decodes plain hex" do + hex = String.duplicate("ab", 32) + bin = String.duplicate(<<0xAB>>, 32) + assert Client.decode_fingerprint!(hex) == bin + end + + test "decodes uppercase hex" do + hex = String.duplicate("AB", 32) + bin = String.duplicate(<<0xAB>>, 32) + assert Client.decode_fingerprint!(hex) == bin + end + + test "strips sha256: prefix" do + hex = String.duplicate("ab", 32) + assert Client.decode_fingerprint!("sha256:" <> hex) == String.duplicate(<<0xAB>>, 32) + end + + test "strips colons" do + with_colons = "AB:" |> String.duplicate(31) |> Kernel.<>("AB") + assert Client.decode_fingerprint!(with_colons) == String.duplicate(<<0xAB>>, 32) + end + + test "accepts sha256: prefix with colons (ssh-keygen style)" do + with_colons = "AB:" |> String.duplicate(31) |> Kernel.<>("AB") + + assert Client.decode_fingerprint!("sha256:" <> with_colons) == + String.duplicate(<<0xAB>>, 32) + end + + test "rejects too-short input" do + assert_raise ArgumentError, fn -> Client.decode_fingerprint!("abcd") end + end + + test "rejects non-hex input" do + assert_raise ArgumentError, fn -> Client.decode_fingerprint!(String.duplicate("zz", 32)) end + end end describe "get/3" do @@ -95,7 +171,7 @@ defmodule UnifiApi.ClientTest do Client.get(client, "/v1/test", offset: 10, limit: 50, filter: "name.eq(foo)") end - test "returns {:error, {401, body}} on unauthorized" do + test "returns {:error, %AuthError{}} on 401" do client = test_client(fn conn -> conn @@ -103,7 +179,12 @@ defmodule UnifiApi.ClientTest do |> Plug.Conn.send_resp(401, JSON.encode!(%{"error" => "unauthorized"})) end) - assert {:error, {401, %{"error" => "unauthorized"}}} = Client.get(client, "/v1/test") + assert {:error, + %UnifiApi.AuthError{ + status: 401, + reason: :unauthorized, + body: %{"error" => "unauthorized"} + }} = Client.get(client, "/v1/test") end end @@ -190,7 +271,7 @@ defmodule UnifiApi.ClientTest do assert {:ok, <<0xFF, 0xD8, 0xFF>>} = Client.get_raw(client, "/v1/snapshot") end - test "returns {:error, {status, body}} on failure" do + test "returns {:error, %AuthError{reason: :forbidden}} on 403" do client = test_client(fn conn -> conn @@ -198,7 +279,8 @@ defmodule UnifiApi.ClientTest do |> Plug.Conn.send_resp(403, JSON.encode!(%{"error" => "forbidden"})) end) - assert {:error, {403, _}} = Client.get_raw(client, "/v1/snapshot") + assert {:error, %UnifiApi.AuthError{status: 403, reason: :forbidden}} = + Client.get_raw(client, "/v1/snapshot") end test "passes highQuality param" do diff --git a/test/unifi_api/error_test.exs b/test/unifi_api/error_test.exs new file mode 100644 index 0000000..d499699 --- /dev/null +++ b/test/unifi_api/error_test.exs @@ -0,0 +1,127 @@ +defmodule UnifiApi.ErrorTest do + use ExUnit.Case, async: true + + alias UnifiApi.{AuthError, Client, RateLimitError} + + defp test_client(plug) do + Req.new( + base_url: "http://localhost", + headers: [{"x-api-key", "test-key"}], + plug: plug, + retry: false + ) + end + + describe "RateLimitError" do + test "is returned for 429 with Retry-After in seconds" do + client = + test_client(fn conn -> + conn + |> Plug.Conn.put_resp_header("retry-after", "42") + |> Plug.Conn.put_resp_content_type("application/json") + |> Plug.Conn.send_resp(429, JSON.encode!(%{"error" => "slow down"})) + end) + + assert {:error, + %RateLimitError{ + retry_after: 42, + status: 429, + body: %{"error" => "slow down"} + }} = Client.get(client, "/v1/test") + end + + test "defaults to 60 seconds when Retry-After is missing" do + client = + test_client(fn conn -> + conn + |> Plug.Conn.put_resp_content_type("application/json") + |> Plug.Conn.send_resp(429, JSON.encode!(%{})) + end) + + assert {:error, %RateLimitError{retry_after: 60}} = Client.get(client, "/v1/test") + end + + test "clamps retry_after below 1 second up to 1" do + client = + test_client(fn conn -> + conn + |> Plug.Conn.put_resp_header("retry-after", "0") + |> Plug.Conn.send_resp(429, "{}") + end) + + assert {:error, %RateLimitError{retry_after: 1}} = Client.get(client, "/v1/test") + end + + test "clamps retry_after above 300 seconds down to 300" do + client = + test_client(fn conn -> + conn + |> Plug.Conn.put_resp_header("retry-after", "9999") + |> Plug.Conn.send_resp(429, "{}") + end) + + assert {:error, %RateLimitError{retry_after: 300}} = Client.get(client, "/v1/test") + end + + test "falls back to 60 when Retry-After is unparseable" do + client = + test_client(fn conn -> + conn + |> Plug.Conn.put_resp_header("retry-after", "garbage") + |> Plug.Conn.send_resp(429, "{}") + end) + + assert {:error, %RateLimitError{retry_after: 60}} = Client.get(client, "/v1/test") + end + + test "Exception.message/1 mentions the retry interval" do + err = %RateLimitError{retry_after: 90, status: 429, body: nil} + assert Exception.message(err) =~ "90s" + end + end + + describe "AuthError" do + test "is returned for 401 with reason :unauthorized" do + client = + test_client(fn conn -> + conn + |> Plug.Conn.put_resp_content_type("application/json") + |> Plug.Conn.send_resp(401, JSON.encode!(%{"error" => "bad key"})) + end) + + assert {:error, + %AuthError{status: 401, reason: :unauthorized, body: %{"error" => "bad key"}}} = + Client.get(client, "/v1/test") + end + + test "is returned for 403 with reason :forbidden" do + client = + test_client(fn conn -> + conn + |> Plug.Conn.put_resp_content_type("application/json") + |> Plug.Conn.send_resp(403, JSON.encode!(%{"error" => "no access"})) + end) + + assert {:error, %AuthError{status: 403, reason: :forbidden}} = + Client.get(client, "/v1/test") + end + + test "Exception.message/1 distinguishes the two reasons" do + assert Exception.message(%AuthError{status: 401, reason: :unauthorized}) =~ "401" + assert Exception.message(%AuthError{status: 403, reason: :forbidden}) =~ "403" + end + end + + describe "other non-2xx responses" do + test "still return {:error, {status, body}}" do + client = + test_client(fn conn -> + conn + |> Plug.Conn.put_resp_content_type("application/json") + |> Plug.Conn.send_resp(404, JSON.encode!(%{"error" => "missing"})) + end) + + assert {:error, {404, %{"error" => "missing"}}} = Client.get(client, "/v1/test") + end + end +end diff --git a/test/unifi_api/network/active_leases_test.exs b/test/unifi_api/network/active_leases_test.exs new file mode 100644 index 0000000..2896f90 --- /dev/null +++ b/test/unifi_api/network/active_leases_test.exs @@ -0,0 +1,19 @@ +defmodule UnifiApi.Network.ActiveLeasesTest do + use ExUnit.Case, async: true + + alias UnifiApi.Network.ActiveLeases + + defp test_client(plug) do + Req.new(base_url: "http://localhost", plug: plug, retry: false) + end + + test "list/2 hits /v2/api/site/{site}/active-leases" do + client = + test_client(fn conn -> + assert conn.request_path == "/proxy/network/v2/api/site/default/active-leases" + Req.Test.json(conn, %{"meta" => %{"rc" => "ok"}, "data" => [%{"mac" => "aa:bb"}]}) + end) + + assert {:ok, [%{"mac" => "aa:bb"}]} = ActiveLeases.list(client, "default") + end +end diff --git a/test/unifi_api/network/alarms_test.exs b/test/unifi_api/network/alarms_test.exs new file mode 100644 index 0000000..63cacf4 --- /dev/null +++ b/test/unifi_api/network/alarms_test.exs @@ -0,0 +1,50 @@ +defmodule UnifiApi.Network.AlarmsTest do + use ExUnit.Case, async: true + + alias UnifiApi.Network.Alarms + + defp test_client(plug) do + Req.new(base_url: "http://localhost", plug: plug, retry: false) + end + + test "list/3 hits /proxy/network/api/s/{site}/list/alarm" do + client = + test_client(fn conn -> + assert conn.request_path == "/proxy/network/api/s/default/list/alarm" + + Req.Test.json(conn, %{ + "meta" => %{"rc" => "ok"}, + "data" => [%{"_id" => "a1", "archived" => false}] + }) + end) + + assert {:ok, [%{"_id" => "a1"}]} = Alarms.list(client, "default") + end + + test "list/3 forwards :archived filter" do + client = + test_client(fn conn -> + params = Plug.Conn.fetch_query_params(conn).query_params + assert params["archived"] == "false" + Req.Test.json(conn, %{"meta" => %{"rc" => "ok"}, "data" => []}) + end) + + assert {:ok, []} = Alarms.list(client, "default", archived: false) + end + + test "archive/3 POSTs to /cmd/evtmgr with archive-alarm cmd" do + client = + test_client(fn conn -> + assert conn.method == "POST" + assert conn.request_path == "/proxy/network/api/s/default/cmd/evtmgr" + + {:ok, raw, conn} = Plug.Conn.read_body(conn) + body = JSON.decode!(raw) + assert body == %{"cmd" => "archive-alarm", "_id" => "alarm-1"} + + Req.Test.json(conn, %{"meta" => %{"rc" => "ok"}, "data" => []}) + end) + + assert {:ok, _} = Alarms.archive(client, "default", "alarm-1") + end +end diff --git a/test/unifi_api/network/anomalies_test.exs b/test/unifi_api/network/anomalies_test.exs new file mode 100644 index 0000000..b230a7f --- /dev/null +++ b/test/unifi_api/network/anomalies_test.exs @@ -0,0 +1,26 @@ +defmodule UnifiApi.Network.AnomaliesTest do + use ExUnit.Case, async: true + + alias UnifiApi.Network.Anomalies + + defp test_client(plug) do + Req.new(base_url: "http://localhost", plug: plug, retry: false) + end + + test "list/3 hits /stat/anomalies and forwards :within_hours" do + client = + test_client(fn conn -> + assert conn.request_path == "/proxy/network/api/s/default/stat/anomalies" + params = Plug.Conn.fetch_query_params(conn).query_params + assert params["within"] == "24" + + Req.Test.json(conn, %{ + "meta" => %{"rc" => "ok"}, + "data" => [%{"_id" => "a1", "anomaly" => "poor_signal"}] + }) + end) + + assert {:ok, [%{"anomaly" => "poor_signal"}]} = + Anomalies.list(client, "default", within_hours: 24) + end +end diff --git a/test/unifi_api/network/clients_history_stream_test.exs b/test/unifi_api/network/clients_history_stream_test.exs new file mode 100644 index 0000000..b393c92 --- /dev/null +++ b/test/unifi_api/network/clients_history_stream_test.exs @@ -0,0 +1,34 @@ +defmodule UnifiApi.Network.ClientsHistoryStreamTest do + use ExUnit.Case, async: true + + alias UnifiApi.Network.ClientsHistory + + defp test_client(plug) do + Req.new(base_url: "http://localhost", plug: plug, retry: false) + end + + test "stream/3 paginates via pageSize / pageNumber until short page" do + pages = [ + Enum.map(1..10, &%{"id" => "c#{&1}"}), + Enum.map(11..20, &%{"id" => "c#{&1}"}), + Enum.map(21..23, &%{"id" => "c#{&1}"}) + ] + + {:ok, agent} = Agent.start_link(fn -> {0, pages} end) + + client = + test_client(fn conn -> + params = Plug.Conn.fetch_query_params(conn).query_params + assert params["pageSize"] == "10" + + {expected_page, page} = + Agent.get_and_update(agent, fn {n, [p | rest]} -> {{n, p}, {n + 1, rest}} end) + + assert params["pageNumber"] == to_string(expected_page) + Req.Test.json(conn, %{"meta" => %{"rc" => "ok"}, "data" => page}) + end) + + assert clients = ClientsHistory.stream(client, "default", limit: 10) |> Enum.to_list() + assert length(clients) == 23 + end +end diff --git a/test/unifi_api/network/clients_history_test.exs b/test/unifi_api/network/clients_history_test.exs new file mode 100644 index 0000000..42cb3a8 --- /dev/null +++ b/test/unifi_api/network/clients_history_test.exs @@ -0,0 +1,28 @@ +defmodule UnifiApi.Network.ClientsHistoryTest do + use ExUnit.Case, async: true + + alias UnifiApi.Network.ClientsHistory + + defp test_client(plug) do + Req.new(base_url: "http://localhost", plug: plug, retry: false) + end + + test "list/3 hits /v2/api/site/{site}/clients/history with v2 paging params" do + client = + test_client(fn conn -> + assert conn.request_path == "/proxy/network/v2/api/site/default/clients/history" + params = Plug.Conn.fetch_query_params(conn).query_params + assert params["withinHours"] == "168" + assert params["type"] == "WIRELESS" + assert params["searchString"] == "iphone" + Req.Test.json(conn, %{"meta" => %{"rc" => "ok"}, "data" => []}) + end) + + assert {:ok, []} = + ClientsHistory.list(client, "default", + within_hours: 168, + type: "WIRELESS", + search: "iphone" + ) + end +end diff --git a/test/unifi_api/network/clients_live_test.exs b/test/unifi_api/network/clients_live_test.exs new file mode 100644 index 0000000..419d00a --- /dev/null +++ b/test/unifi_api/network/clients_live_test.exs @@ -0,0 +1,44 @@ +defmodule UnifiApi.Network.ClientsLiveTest do + use ExUnit.Case, async: true + + alias UnifiApi.Network.ClientsLive + + defp test_client(plug) do + Req.new(base_url: "http://localhost", plug: plug, retry: false) + end + + test "list/2 hits /stat/sta and unwraps data" do + client = + test_client(fn conn -> + assert conn.request_path == "/proxy/network/api/s/default/stat/sta" + + Req.Test.json(conn, %{ + "meta" => %{"rc" => "ok"}, + "data" => [ + %{ + "_id" => "c1", + "mac" => "aa:bb:cc:dd:ee:ff", + "signal" => -55, + "noise" => -95, + "satisfaction" => 92, + "is_wired" => false + } + ] + }) + end) + + assert {:ok, [%{"signal" => -55, "satisfaction" => 92}]} = ClientsLive.list(client, "default") + end + + test "list_all/3 hits /stat/alluser and forwards :within_hours" do + client = + test_client(fn conn -> + assert conn.request_path == "/proxy/network/api/s/default/stat/alluser" + params = Plug.Conn.fetch_query_params(conn).query_params + assert params["within"] == "168" + Req.Test.json(conn, %{"meta" => %{"rc" => "ok"}, "data" => []}) + end) + + assert {:ok, []} = ClientsLive.list_all(client, "default", within_hours: 168) + end +end diff --git a/test/unifi_api/network/dashboard_test.exs b/test/unifi_api/network/dashboard_test.exs new file mode 100644 index 0000000..45eaeeb --- /dev/null +++ b/test/unifi_api/network/dashboard_test.exs @@ -0,0 +1,32 @@ +defmodule UnifiApi.Network.DashboardTest do + use ExUnit.Case, async: true + + alias UnifiApi.Network.Dashboard + + defp test_client(plug) do + Req.new(base_url: "http://localhost", plug: plug, retry: false) + end + + test "get/3 hits /aggregated-dashboard with default historySeconds=3600" do + client = + test_client(fn conn -> + assert conn.request_path == "/proxy/network/v2/api/site/default/aggregated-dashboard" + params = Plug.Conn.fetch_query_params(conn).query_params + assert params["historySeconds"] == "3600" + Req.Test.json(conn, %{"meta" => %{"rc" => "ok"}, "data" => %{"clients" => 42}}) + end) + + assert {:ok, %{"clients" => 42}} = Dashboard.get(client, "default") + end + + test "get/3 honours :history_seconds option" do + client = + test_client(fn conn -> + params = Plug.Conn.fetch_query_params(conn).query_params + assert params["historySeconds"] == "604800" + Req.Test.json(conn, %{"meta" => %{"rc" => "ok"}, "data" => %{}}) + end) + + assert {:ok, %{}} = Dashboard.get(client, "default", history_seconds: 604_800) + end +end diff --git a/test/unifi_api/network/dpi_test.exs b/test/unifi_api/network/dpi_test.exs new file mode 100644 index 0000000..23d841b --- /dev/null +++ b/test/unifi_api/network/dpi_test.exs @@ -0,0 +1,96 @@ +defmodule UnifiApi.Network.DPITest do + use ExUnit.Case, async: true + + alias UnifiApi.Network.DPI + + defp test_client(plug) do + Req.new(base_url: "http://localhost", plug: plug, retry: false) + end + + test "by_site/2 hits /stat/sitedpi" do + client = + test_client(fn conn -> + assert conn.request_path == "/proxy/network/api/s/default/stat/sitedpi" + Req.Test.json(conn, %{"meta" => %{"rc" => "ok"}, "data" => [%{"by_app" => []}]}) + end) + + assert {:ok, [%{"by_app" => []}]} = DPI.by_site(client, "default") + end + + test "by_client/2 hits /stat/stadpi" do + client = + test_client(fn conn -> + assert conn.request_path == "/proxy/network/api/s/default/stat/stadpi" + Req.Test.json(conn, %{"meta" => %{"rc" => "ok"}, "data" => [%{"mac" => "ab:cd"}]}) + end) + + assert {:ok, [%{"mac" => "ab:cd"}]} = DPI.by_client(client, "default") + end + + describe "with_names/2" do + @categories [ + %{"id" => 1, "name" => "Web"}, + %{"id" => 2, "name" => "Streaming"} + ] + + @applications [ + %{"id" => 100, "name" => "YouTube"}, + %{"id" => 101, "name" => "Gmail"} + ] + + test "annotates by_cat entries with category_name" do + dpi = [%{"by_cat" => [%{"cat" => 1, "tx_bytes" => 1000}], "by_app" => []}] + + assert [%{"by_cat" => [%{"category_name" => "Web", "tx_bytes" => 1000}]}] = + DPI.with_names(dpi, categories: @categories, applications: @applications) + end + + test "annotates by_app entries with both category_name and application_name" do + dpi = [ + %{ + "by_app" => [%{"app" => 100, "cat" => 2, "tx_bytes" => 500}], + "by_cat" => [] + } + ] + + assert [ + %{ + "by_app" => [ + %{ + "application_name" => "YouTube", + "category_name" => "Streaming", + "tx_bytes" => 500 + } + ] + } + ] = DPI.with_names(dpi, categories: @categories, applications: @applications) + end + + test "leaves unknown IDs with nil names" do + dpi = [%{"by_cat" => [%{"cat" => 999}], "by_app" => [%{"app" => 999, "cat" => 999}]}] + + assert [ + %{ + "by_cat" => [%{"category_name" => nil}], + "by_app" => [%{"application_name" => nil, "category_name" => nil}] + } + ] = DPI.with_names(dpi, categories: @categories, applications: @applications) + end + + test "preserves entries without cat/app keys" do + dpi = [%{"by_cat" => [%{"tx_bytes" => 1}], "by_app" => [%{"tx_bytes" => 2}]}] + + assert [%{"by_cat" => [bc], "by_app" => [ba]}] = + DPI.with_names(dpi, categories: @categories, applications: @applications) + + refute Map.has_key?(bc, "category_name") + refute Map.has_key?(ba, "category_name") + refute Map.has_key?(ba, "application_name") + end + + test "raises if :categories or :applications opts are missing" do + assert_raise KeyError, fn -> DPI.with_names([], applications: []) end + assert_raise KeyError, fn -> DPI.with_names([], categories: []) end + end + end +end diff --git a/test/unifi_api/network/events_test.exs b/test/unifi_api/network/events_test.exs new file mode 100644 index 0000000..9aed940 --- /dev/null +++ b/test/unifi_api/network/events_test.exs @@ -0,0 +1,90 @@ +defmodule UnifiApi.Network.EventsTest do + use ExUnit.Case, async: true + + alias UnifiApi.Network.Events + + defp test_client(plug) do + Req.new(base_url: "http://localhost", plug: plug, retry: false) + end + + test "list/3 hits /proxy/network/api/s/{site}/stat/event and unwraps data" do + client = + test_client(fn conn -> + assert conn.request_path == "/proxy/network/api/s/default/stat/event" + + Req.Test.json(conn, %{ + "meta" => %{"rc" => "ok"}, + "data" => [ + %{"_id" => "e1", "key" => "EVT_AP_Connected", "subsystem" => "wlan"}, + %{"_id" => "e2", "key" => "EVT_LU_Disconnected", "subsystem" => "lan"} + ] + }) + end) + + assert {:ok, [%{"_id" => "e1"}, %{"_id" => "e2"}]} = Events.list(client, "default") + end + + test "list/3 forwards within_hours, limit, start as v1 query params" do + client = + test_client(fn conn -> + params = Plug.Conn.fetch_query_params(conn).query_params + assert params["within"] == "24" + assert params["_limit"] == "100" + assert params["_start"] == "0" + Req.Test.json(conn, %{"meta" => %{"rc" => "ok"}, "data" => []}) + end) + + assert {:ok, []} = Events.list(client, "default", within_hours: 24, limit: 100, start: 0) + end + + test "list/3 surfaces meta.rc == error" do + client = + test_client(fn conn -> + Req.Test.json(conn, %{"meta" => %{"rc" => "error", "msg" => "api.err.LoginRequired"}}) + end) + + assert {:error, {:unifi_error, "api.err.LoginRequired"}} = Events.list(client, "default") + end + + describe "stream/3" do + test "auto-paginates via _start / _limit until a short page" do + pages = [ + Enum.map(1..10, &%{"_id" => "e#{&1}"}), + Enum.map(11..20, &%{"_id" => "e#{&1}"}), + Enum.map(21..23, &%{"_id" => "e#{&1}"}) + ] + + {:ok, agent} = Agent.start_link(fn -> pages end) + + client = + test_client(fn conn -> + params = Plug.Conn.fetch_query_params(conn).query_params + assert params["_limit"] == "10" + assert params["within"] == "1" + + page = Agent.get_and_update(agent, fn [h | t] -> {h, t} end) + Req.Test.json(conn, %{"meta" => %{"rc" => "ok"}, "data" => page}) + end) + + assert events = + Events.stream(client, "default", within_hours: 1, limit: 10) + |> Enum.to_list() + + assert length(events) == 23 + end + + test "halts early when consumer takes a fixed amount" do + client = + test_client(fn conn -> + Req.Test.json(conn, %{ + "meta" => %{"rc" => "ok"}, + "data" => Enum.map(1..500, &%{"_id" => "e#{&1}"}) + }) + end) + + # Limit takes only 5 — single page fetched, stream halts + assert events = Events.stream(client, "default", limit: 500) |> Enum.take(5) + assert length(events) == 5 + end + end +end diff --git a/test/unifi_api/network/ids_test.exs b/test/unifi_api/network/ids_test.exs new file mode 100644 index 0000000..94a78b3 --- /dev/null +++ b/test/unifi_api/network/ids_test.exs @@ -0,0 +1,28 @@ +defmodule UnifiApi.Network.IDSTest do + use ExUnit.Case, async: true + + alias UnifiApi.Network.IDS + + defp test_client(plug) do + Req.new(base_url: "http://localhost", plug: plug, retry: false) + end + + test "list/3 hits /stat/ips/event with v1 paging params" do + client = + test_client(fn conn -> + assert conn.request_path == "/proxy/network/api/s/default/stat/ips/event" + params = Plug.Conn.fetch_query_params(conn).query_params + assert params["within"] == "12" + assert params["_limit"] == "50" + assert params["_start"] == "0" + + Req.Test.json(conn, %{ + "meta" => %{"rc" => "ok"}, + "data" => [%{"_id" => "i1", "key" => "EVT_IPS_IpsAlert"}] + }) + end) + + assert {:ok, [%{"key" => "EVT_IPS_IpsAlert"}]} = + IDS.list(client, "default", within_hours: 12, limit: 50, start: 0) + end +end diff --git a/test/unifi_api/network/port_anomalies_test.exs b/test/unifi_api/network/port_anomalies_test.exs new file mode 100644 index 0000000..27b8f13 --- /dev/null +++ b/test/unifi_api/network/port_anomalies_test.exs @@ -0,0 +1,19 @@ +defmodule UnifiApi.Network.PortAnomaliesTest do + use ExUnit.Case, async: true + + alias UnifiApi.Network.PortAnomalies + + defp test_client(plug) do + Req.new(base_url: "http://localhost", plug: plug, retry: false) + end + + test "list/2 hits /v2/api/site/{site}/ports/port-anomalies" do + client = + test_client(fn conn -> + assert conn.request_path == "/proxy/network/v2/api/site/default/ports/port-anomalies" + Req.Test.json(conn, %{"meta" => %{"rc" => "ok"}, "data" => []}) + end) + + assert {:ok, []} = PortAnomalies.list(client, "default") + end +end diff --git a/test/unifi_api/network/port_forward_test.exs b/test/unifi_api/network/port_forward_test.exs new file mode 100644 index 0000000..9f2d571 --- /dev/null +++ b/test/unifi_api/network/port_forward_test.exs @@ -0,0 +1,50 @@ +defmodule UnifiApi.Network.PortForwardTest do + use ExUnit.Case, async: true + + alias UnifiApi.Network.PortForward + + defp test_client(plug) do + Req.new(base_url: "http://localhost", plug: plug, retry: false) + end + + test "list/2 hits /rest/portforward" do + client = + test_client(fn conn -> + assert conn.method == "GET" + assert conn.request_path == "/proxy/network/api/s/default/rest/portforward" + + Req.Test.json(conn, %{ + "meta" => %{"rc" => "ok"}, + "data" => [%{"_id" => "r1", "name" => "Game"}] + }) + end) + + assert {:ok, [%{"name" => "Game"}]} = PortForward.list(client, "default") + end + + test "create/3 POSTs body" do + client = + test_client(fn conn -> + assert conn.method == "POST" + assert conn.request_path == "/proxy/network/api/s/default/rest/portforward" + + {:ok, raw, conn} = Plug.Conn.read_body(conn) + assert JSON.decode!(raw)["name"] == "Game" + + Req.Test.json(conn, %{"meta" => %{"rc" => "ok"}, "data" => []}) + end) + + assert {:ok, _} = PortForward.create(client, "default", %{name: "Game", proto: "tcp"}) + end + + test "delete/3 DELETEs the rule" do + client = + test_client(fn conn -> + assert conn.method == "DELETE" + assert conn.request_path == "/proxy/network/api/s/default/rest/portforward/r1" + Req.Test.json(conn, %{"meta" => %{"rc" => "ok"}, "data" => []}) + end) + + assert {:ok, _} = PortForward.delete(client, "default", "r1") + end +end diff --git a/test/unifi_api/network/rogue_ap_test.exs b/test/unifi_api/network/rogue_ap_test.exs new file mode 100644 index 0000000..b184cad --- /dev/null +++ b/test/unifi_api/network/rogue_ap_test.exs @@ -0,0 +1,29 @@ +defmodule UnifiApi.Network.RogueAPTest do + use ExUnit.Case, async: true + + alias UnifiApi.Network.RogueAP + + defp test_client(plug) do + Req.new(base_url: "http://localhost", plug: plug, retry: false) + end + + test "list/2 hits /stat/rogueap" do + client = + test_client(fn conn -> + assert conn.request_path == "/proxy/network/api/s/default/stat/rogueap" + Req.Test.json(conn, %{"meta" => %{"rc" => "ok"}, "data" => [%{"bssid" => "ab:cd"}]}) + end) + + assert {:ok, [%{"bssid" => "ab:cd"}]} = RogueAP.list(client, "default") + end + + test "list_known/2 hits /rest/rogueknown" do + client = + test_client(fn conn -> + assert conn.request_path == "/proxy/network/api/s/default/rest/rogueknown" + Req.Test.json(conn, %{"meta" => %{"rc" => "ok"}, "data" => []}) + end) + + assert {:ok, []} = RogueAP.list_known(client, "default") + end +end diff --git a/test/unifi_api/network/sites_find_test.exs b/test/unifi_api/network/sites_find_test.exs new file mode 100644 index 0000000..2aea4b3 --- /dev/null +++ b/test/unifi_api/network/sites_find_test.exs @@ -0,0 +1,44 @@ +defmodule UnifiApi.Network.SitesFindTest do + use ExUnit.Case, async: true + + alias UnifiApi.Network.Sites + + defp test_client(sites) do + Req.new( + base_url: "http://localhost", + plug: fn conn -> Req.Test.json(conn, sites) end, + retry: false + ) + end + + @sites [ + %{"id" => "id-default", "name" => "Default", "internalReference" => "default"}, + %{"id" => "id-hq", "name" => "HQ", "internalReference" => "hq01"} + ] + + describe "find_by_name/2" do + test "returns the matching site" do + assert {:ok, %{"id" => "id-hq"}} = Sites.find_by_name(test_client(@sites), "HQ") + end + + test "returns :not_found when no site matches" do + assert {:error, :not_found} = Sites.find_by_name(test_client(@sites), "Nope") + end + + test "is case-sensitive" do + assert {:error, :not_found} = Sites.find_by_name(test_client(@sites), "hq") + end + end + + describe "find_by_internal_reference/2" do + test "matches against internalReference field" do + assert {:ok, %{"id" => "id-hq"}} = + Sites.find_by_internal_reference(test_client(@sites), "hq01") + end + + test "returns :not_found when no site matches" do + assert {:error, :not_found} = + Sites.find_by_internal_reference(test_client(@sites), "missing") + end + end +end diff --git a/test/unifi_api/network/system_log_stream_test.exs b/test/unifi_api/network/system_log_stream_test.exs new file mode 100644 index 0000000..e7a97bf --- /dev/null +++ b/test/unifi_api/network/system_log_stream_test.exs @@ -0,0 +1,27 @@ +defmodule UnifiApi.Network.SystemLogStreamTest do + use ExUnit.Case, async: true + + alias UnifiApi.Network.SystemLog + + defp test_client(plug) do + Req.new(base_url: "http://localhost", plug: plug, retry: false) + end + + test "stream/3 paginates the system log via pageSize / pageNumber" do + pages = [ + Enum.map(1..50, &%{"id" => "log#{&1}"}), + Enum.map(51..52, &%{"id" => "log#{&1}"}) + ] + + {:ok, agent} = Agent.start_link(fn -> pages end) + + client = + test_client(fn conn -> + page = Agent.get_and_update(agent, fn [h | t] -> {h, t} end) + Req.Test.json(conn, %{"meta" => %{"rc" => "ok"}, "data" => page}) + end) + + assert logs = SystemLog.stream(client, "default", limit: 50) |> Enum.to_list() + assert length(logs) == 52 + end +end diff --git a/test/unifi_api/network/system_log_test.exs b/test/unifi_api/network/system_log_test.exs new file mode 100644 index 0000000..ab0937f --- /dev/null +++ b/test/unifi_api/network/system_log_test.exs @@ -0,0 +1,21 @@ +defmodule UnifiApi.Network.SystemLogTest do + use ExUnit.Case, async: true + + alias UnifiApi.Network.SystemLog + + defp test_client(plug) do + Req.new(base_url: "http://localhost", plug: plug, retry: false) + end + + test "list_all/3 hits /v2/api/site/{site}/system-log/all with paging" do + client = + test_client(fn conn -> + assert conn.request_path == "/proxy/network/v2/api/site/default/system-log/all" + params = Plug.Conn.fetch_query_params(conn).query_params + assert params["pageSize"] == "100" + Req.Test.json(conn, %{"meta" => %{"rc" => "ok"}, "data" => []}) + end) + + assert {:ok, []} = SystemLog.list_all(client, "default", limit: 100) + end +end diff --git a/test/unifi_api/network/topology_test.exs b/test/unifi_api/network/topology_test.exs new file mode 100644 index 0000000..48fdc3e --- /dev/null +++ b/test/unifi_api/network/topology_test.exs @@ -0,0 +1,24 @@ +defmodule UnifiApi.Network.TopologyTest do + use ExUnit.Case, async: true + + alias UnifiApi.Network.Topology + + defp test_client(plug) do + Req.new(base_url: "http://localhost", plug: plug, retry: false) + end + + test "get/2 hits /v2/api/site/{site}/topology" do + client = + test_client(fn conn -> + assert conn.request_path == "/proxy/network/v2/api/site/default/topology" + + Req.Test.json(conn, [ + %{"id" => "n1", "type" => "gateway", "name" => "UDM", "parent" => nil}, + %{"id" => "n2", "type" => "switch", "name" => "USW", "parent" => "n1"} + ]) + end) + + assert {:ok, [%{"type" => "gateway"}, %{"type" => "switch"}]} = + Topology.get(client, "default") + end +end diff --git a/test/unifi_api/network/traffic_test.exs b/test/unifi_api/network/traffic_test.exs new file mode 100644 index 0000000..3c3568e --- /dev/null +++ b/test/unifi_api/network/traffic_test.exs @@ -0,0 +1,33 @@ +defmodule UnifiApi.Network.TrafficTest do + use ExUnit.Case, async: true + + alias UnifiApi.Network.Traffic + + defp test_client(plug) do + Req.new(base_url: "http://localhost", plug: plug, retry: false) + end + + test "by_client/3 hits /v2/api/site/{site}/traffic with start/end/interval" do + client = + test_client(fn conn -> + assert conn.request_path == "/proxy/network/v2/api/site/default/traffic" + params = Plug.Conn.fetch_query_params(conn).query_params + assert params["start"] == "1700000000000" + assert params["interval"] == "hour" + Req.Test.json(conn, %{"meta" => %{"rc" => "ok"}, "data" => []}) + end) + + assert {:ok, []} = + Traffic.by_client(client, "default", start: 1_700_000_000_000, interval: "hour") + end + + test "by_country/3 hits /country-traffic" do + client = + test_client(fn conn -> + assert conn.request_path == "/proxy/network/v2/api/site/default/country-traffic" + Req.Test.json(conn, %{"meta" => %{"rc" => "ok"}, "data" => []}) + end) + + assert {:ok, []} = Traffic.by_country(client, "default") + end +end diff --git a/test/unifi_api/network/ups_test.exs b/test/unifi_api/network/ups_test.exs new file mode 100644 index 0000000..d1b3e85 --- /dev/null +++ b/test/unifi_api/network/ups_test.exs @@ -0,0 +1,23 @@ +defmodule UnifiApi.Network.UPSTest do + use ExUnit.Case, async: true + + alias UnifiApi.Network.UPS + + defp test_client(plug) do + Req.new(base_url: "http://localhost", plug: plug, retry: false) + end + + test "list/2 hits /stat/ups-devices" do + client = + test_client(fn conn -> + assert conn.request_path == "/proxy/network/api/s/default/stat/ups-devices" + + Req.Test.json(conn, %{ + "meta" => %{"rc" => "ok"}, + "data" => [%{"name" => "UPS1", "battery_charge" => 100}] + }) + end) + + assert {:ok, [%{"battery_charge" => 100}]} = UPS.list(client, "default") + end +end diff --git a/test/unifi_api/network/wan_test.exs b/test/unifi_api/network/wan_test.exs new file mode 100644 index 0000000..9696a59 --- /dev/null +++ b/test/unifi_api/network/wan_test.exs @@ -0,0 +1,51 @@ +defmodule UnifiApi.Network.WANTest do + use ExUnit.Case, async: true + + alias UnifiApi.Network.WAN + + defp test_client(plug) do + Req.new(base_url: "http://localhost", plug: plug, retry: false) + end + + test "enriched_config/2 hits /v2/api/site/{site}/wan/enriched-configuration" do + client = + test_client(fn conn -> + assert conn.request_path == + "/proxy/network/v2/api/site/default/wan/enriched-configuration" + + Req.Test.json(conn, %{"meta" => %{"rc" => "ok"}, "data" => []}) + end) + + assert {:ok, []} = WAN.enriched_config(client, "default") + end + + test "isp_status/3 hits /wan/{wan_id}/isp-status" do + client = + test_client(fn conn -> + assert conn.request_path == "/proxy/network/v2/api/site/default/wan/wan1/isp-status" + Req.Test.json(conn, %{"meta" => %{"rc" => "ok"}, "data" => %{"up" => true}}) + end) + + assert {:ok, %{"up" => true}} = WAN.isp_status(client, "default", "wan1") + end + + test "load_balancing/2 hits /wan/load-balancing" do + client = + test_client(fn conn -> + assert conn.request_path == "/proxy/network/v2/api/site/default/wan/load-balancing" + Req.Test.json(conn, %{"meta" => %{"rc" => "ok"}, "data" => %{}}) + end) + + assert {:ok, %{}} = WAN.load_balancing(client, "default") + end + + test "slas/2 hits /wan-slas" do + client = + test_client(fn conn -> + assert conn.request_path == "/proxy/network/v2/api/site/default/wan-slas" + Req.Test.json(conn, %{"meta" => %{"rc" => "ok"}, "data" => []}) + end) + + assert {:ok, []} = WAN.slas(client, "default") + end +end diff --git a/test/unifi_api/protect/events_test.exs b/test/unifi_api/protect/events_test.exs new file mode 100644 index 0000000..d8dc268 --- /dev/null +++ b/test/unifi_api/protect/events_test.exs @@ -0,0 +1,65 @@ +defmodule UnifiApi.Protect.EventsTest do + use ExUnit.Case, async: true + + alias UnifiApi.Protect.Events + + defp test_client(plug) do + Req.new(base_url: "http://localhost", plug: plug, retry: false) + end + + test "list/2 hits /proxy/protect/api/events with filters" do + client = + test_client(fn conn -> + assert conn.request_path == "/proxy/protect/api/events" + params = Plug.Conn.fetch_query_params(conn).query_params + assert params["start"] == "1700000000000" + assert params["types"] == "motion,smartDetectZone" + assert params["cameras"] == "cam-1,cam-2" + Req.Test.json(conn, [%{"id" => "ev-1", "type" => "motion"}]) + end) + + assert {:ok, [%{"type" => "motion"}]} = + Events.list(client, + start: 1_700_000_000_000, + types: ["motion", "smartDetectZone"], + cameras: ["cam-1", "cam-2"] + ) + end + + test "get/2 hits /api/events/{id}" do + client = + test_client(fn conn -> + assert conn.request_path == "/proxy/protect/api/events/ev-1" + Req.Test.json(conn, %{"id" => "ev-1"}) + end) + + assert {:ok, %{"id" => "ev-1"}} = Events.get(client, "ev-1") + end + + test "thumbnail/3 returns binary JPEG via get_raw" do + jpeg = <<0xFF, 0xD8, 0xFF, 0x00, 0x01, 0x02>> + + client = + test_client(fn conn -> + assert conn.request_path == "/proxy/protect/api/events/ev-1/thumbnail" + params = Plug.Conn.fetch_query_params(conn).query_params + assert params["w"] == "640" + + conn + |> Plug.Conn.put_resp_content_type("image/jpeg") + |> Plug.Conn.send_resp(200, jpeg) + end) + + assert {:ok, ^jpeg} = Events.thumbnail(client, "ev-1", width: 640) + end + + test "system_logs/1 hits /api/events/system-logs" do + client = + test_client(fn conn -> + assert conn.request_path == "/proxy/protect/api/events/system-logs" + Req.Test.json(conn, []) + end) + + assert {:ok, []} = Events.system_logs(client) + end +end diff --git a/test/unifi_api/time_test.exs b/test/unifi_api/time_test.exs new file mode 100644 index 0000000..a61463c --- /dev/null +++ b/test/unifi_api/time_test.exs @@ -0,0 +1,37 @@ +defmodule UnifiApi.TimeTest do + use ExUnit.Case, async: true + + alias UnifiApi.Time, as: T + + test "now_ms/0 returns the current time in milliseconds" do + before = System.os_time(:millisecond) + now = T.now_ms() + diff = abs(now - before) + assert diff < 1000 + end + + test "minutes_ago/1 subtracts minutes" do + now = System.os_time(:millisecond) + diff = now - T.minutes_ago(5) + # ~5 minutes ago, allow ±1s of clock jitter + assert_in_delta diff, 5 * 60_000, 1000 + end + + test "hours_ago/1 subtracts hours" do + now = System.os_time(:millisecond) + diff = now - T.hours_ago(2) + assert_in_delta diff, 2 * 3_600_000, 1000 + end + + test "days_ago/1 subtracts days" do + now = System.os_time(:millisecond) + diff = now - T.days_ago(1) + assert_in_delta diff, 86_400_000, 1000 + end + + test "accepts floats" do + now = System.os_time(:millisecond) + diff = now - T.hours_ago(0.5) + assert_in_delta diff, 1_800_000, 1000 + end +end diff --git a/test/unifi_api_test.exs b/test/unifi_api_test.exs index 92c19ce..a7f1111 100644 --- a/test/unifi_api_test.exs +++ b/test/unifi_api_test.exs @@ -1,13 +1,96 @@ defmodule UnifiApiTest do use ExUnit.Case, async: true - test "new/0 returns a Req.Request struct" do - client = UnifiApi.new() - assert %Req.Request{} = client + defp test_client(plug) do + Req.new(base_url: "http://localhost", plug: plug, retry: false) end - test "new/1 accepts custom options" do - client = UnifiApi.new(base_url: "https://10.0.0.1", api_key: "test-key") - assert %Req.Request{} = client + describe "new/0" do + test "returns a Req.Request struct" do + client = UnifiApi.new() + assert %Req.Request{} = client + end + end + + describe "new/1" do + test "accepts custom options" do + client = UnifiApi.new(base_url: "https://10.0.0.1", api_key: "test-key") + assert %Req.Request{} = client + end + end + + describe "detect/1" do + test "returns :udm on 200" do + client = test_client(fn conn -> Plug.Conn.send_resp(conn, 200, "...") end) + + assert {:ok, + %{ + style: :udm, + network_prefix: "/proxy/network/integration", + protect_prefix: "/proxy/protect/integration", + v1_prefix: "/proxy/network", + auth_path: "/api/auth/login" + }} = UnifiApi.detect(client) + end + + test "returns :cloud_key on 302" do + client = + test_client(fn conn -> + conn + |> Plug.Conn.put_resp_header("location", "/manage/account/login") + |> Plug.Conn.send_resp(302, "") + end) + + assert {:ok, + %{ + style: :cloud_key, + network_prefix: "/integration", + protect_prefix: "/integration", + v1_prefix: "", + auth_path: "/api/login" + }} = UnifiApi.detect(client) + end + + test "returns :cloud_key on 301 and 303 too" do + for status <- [301, 303] do + client = test_client(fn conn -> Plug.Conn.send_resp(conn, status, "") end) + assert {:ok, %{style: :cloud_key}} = UnifiApi.detect(client) + end + end + + test "does not follow redirects when probing" do + client = + test_client(fn conn -> + conn + |> Plug.Conn.put_resp_header("location", "/login") + |> Plug.Conn.send_resp(302, "") + end) + + assert {:ok, %{style: :cloud_key}} = UnifiApi.detect(client) + end + + test "returns {:error, {:unexpected_status, _, _}} on other statuses" do + client = test_client(fn conn -> Plug.Conn.send_resp(conn, 500, "boom") end) + assert {:error, {:unexpected_status, 500, _}} = UnifiApi.detect(client) + end + end + + describe "ping/1" do + test "returns :ok for 2xx" do + client = test_client(fn conn -> Plug.Conn.send_resp(conn, 200, "") end) + assert :ok = UnifiApi.ping(client) + end + + test "returns :ok for 302 / 303 (Cloud Key style redirect)" do + for status <- [301, 302, 303] do + client = test_client(fn conn -> Plug.Conn.send_resp(conn, status, "") end) + assert :ok = UnifiApi.ping(client) + end + end + + test "returns {:error, {status, body}} for 4xx / 5xx" do + client = test_client(fn conn -> Plug.Conn.send_resp(conn, 503, "down") end) + assert {:error, {503, _}} = UnifiApi.ping(client) + end end end