From ecc2f456718147c7381620ffd115701cc6c42732 Mon Sep 17 00:00:00 2001 From: Chrison Simtian Date: Sun, 16 Aug 2026 13:26:46 +1200 Subject: [PATCH 1/2] Add API-key auth and rest/user to the legacy adapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The legacy write adapter only knew session auth, so it needed UNIFI_USERNAME/UNIFI_PASSWORD. Those are not part of the homelab's generated secrets.env, which means every caller configured from it — including `converge-unifi`, the path that owns the public :443 port-forward — could not authenticate at all. A UniFi OS gateway accepts the same X-API-KEY the integration API uses, so this adds it as a second mode. - UnifiLegacyOptions: optional ApiKey; Username/Password become optional; Validate() rejects a half-configured pair up front; TryFromEnvironment() prefers UNIFI_API_KEY and can derive the site URL from UNIFI_LOCAL_HOST. Session auth stays — it is the only mode .containers/unifi supports. - UnifiLegacySession: API-key mode attaches X-API-KEY and skips login entirely. A 401 is no longer retried in that mode; there is no session to refresh, so a retry would just repeat the same rejection. - UnifiLegacyClient: rest/user (list, partial update, create) — the object a DHCP reservation actually lives on. Deliberately no delete: dropping a known client discards its name and history along with the reservation, whereas UpdateUserAsync with UseFixedIp=false retires one reversibly. Two things found while proving it against real hardware rather than the container: UnifiNetwork.Vlan was typed string, but a real gateway sends a number and only the test container sends a string — so ListNetworksAsync threw on every real controller while the container suite stayed green. Reads now accept either form; write behaviour is unchanged. The destructive live tests are now gated on session auth specifically. They create and delete a port-forward, a firewall group and a VLAN, and once TryFromEnvironment started succeeding on an API key alone, a plain secrets.env would have aimed them at the live home gateway. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 19 ++- src/UnifiSharp/Legacy/UnifiLegacyClient.cs | 35 ++++++ src/UnifiSharp/Legacy/UnifiLegacyModels.cs | 81 +++++++++++- src/UnifiSharp/Legacy/UnifiLegacyOptions.cs | 96 +++++++++++--- src/UnifiSharp/Legacy/UnifiLegacySession.cs | 39 ++++-- src/UnifiSharp/UnifiSharp.csproj | 2 +- .../UnifiLegacyClientTests.cs | 119 ++++++++++++++++++ .../UnifiSharp.Tests/UnifiLegacyLiveTests.cs | 58 ++++++++- 8 files changed, 414 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index 847473e..07d7298 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ flowchart LR SPEC["📜 UniFi OpenAPI 3.1
(console / beezly mirror)"] --> KIOTA["⚙️ Kiota (pinned tool)
generate C# client"] KIOTA --> API["📦 UnifiSharp.Api
generated · tracks UniFi release"] API --> RT["✍️ UnifiSharp
hand-written runtime (X-API-KEY)"] - RT --> LEG["🧩 UnifiSharp.Legacy
session-auth write adapter
(port-forwards · firewall · networks)"] + RT --> LEG["🧩 UnifiSharp.Legacy
write adapter · API-key or session
(port-forwards · firewall · networks · clients)"] classDef gen fill:#e0e7ff,stroke:#4f46e5; class API gen; ``` @@ -111,6 +111,23 @@ unifisharp devices # adopted devices per site (name, model, ip, mac, firmwar unifisharp clients # connected clients per site (name, type, ip, connectedAt) ``` +## Legacy adapter auth + +`UnifiLegacyOptions` takes either credential, and `TryFromEnvironment()` prefers the key: + +| Mode | Set | Notes | +|---|---|---| +| **API key** | `UNIFI_API_KEY` + (`UNIFI_LEGACY_BASE_URL` or `UNIFI_LOCAL_HOST`) | `X-API-KEY` on every request — no login, cookie or CSRF token. Preferred against a real gateway; the same key the integration API uses. | +| **Session** | `UNIFI_USERNAME` + `UNIFI_PASSWORD` + base URL | `POST /api/auth/login`. The only mode the `.containers/unifi` test container supports, since it can't mint API keys. | + +With `UNIFI_LOCAL_HOST` alone the site URL is derived as +`https:///proxy/network/api/s/default`. + +> The **destructive** live tests (`UnifiLegacyLiveTests` — they create and delete a +> port-forward, a firewall group and a VLAN) are gated on *session* auth on purpose, +> so a shell holding only an API key can never point them at a production gateway. +> Read-only live checks (`UnifiLegacyReadOnlyLiveTests`) run in either mode. + ## Status Read client + `discover` + `unifisharp` CLI building from the 10.4.57 spec. diff --git a/src/UnifiSharp/Legacy/UnifiLegacyClient.cs b/src/UnifiSharp/Legacy/UnifiLegacyClient.cs index b97a1cf..dbe4563 100644 --- a/src/UnifiSharp/Legacy/UnifiLegacyClient.cs +++ b/src/UnifiSharp/Legacy/UnifiLegacyClient.cs @@ -66,6 +66,29 @@ public Task CreateNetworkAsync(UnifiNetwork spec, CancellationToke public Task DeleteNetworkAsync(string id, CancellationToken ct = default) => DeleteAsync("networkconf", id, ct); + // ── Known clients / DHCP reservations (rest/user) ───────────────────────── + // NOTE there is no Delete here, deliberately. Removing a known client discards + // its name and history along with the reservation; retiring a reservation is + // UpdateUserAsync with UseFixedIp=false, which is reversible. + + public Task> ListUsersAsync(CancellationToken ct = default) + => ListAsync("user", ct); + + /// + /// Partial-update a known client. Only the non-null properties of + /// are sent, so untouched fields keep their server values. + /// + public Task UpdateUserAsync(string id, UnifiUser spec, CancellationToken ct = default) + => UpdateAsync("user", id, spec, ct); + + /// + /// Register a client the controller has never seen. Rarely needed — a guest that + /// has ever taken a lease already has a row, and is + /// the path for it. + /// + public Task CreateUserAsync(UnifiUser spec, CancellationToken ct = default) + => CreateAsync("user", spec, ct); + // ── Generic REST verbs over the {meta,data} envelope ────────────────────── private async Task> ListAsync(string resource, CancellationToken ct) @@ -85,6 +108,18 @@ private async Task CreateAsync(string resource, T spec, CancellationToken : throw new UnifiLegacyException($"create {resource}: ok but no object returned"); } + private async Task UpdateAsync(string resource, string id, T spec, CancellationToken ct) + { + ArgumentException.ThrowIfNullOrEmpty(id); + var json = JsonSerializer.Serialize(spec, SerializerOptions); + using var resp = await _session.SendAsync( + HttpMethod.Put, $"rest/{resource}/{id}", UnifiLegacySession.JsonBody(json), ct).ConfigureAwait(false); + var env = await ReadEnvelopeAsync(resp, resource, ct).ConfigureAwait(false); + // A PUT is ok-with-empty-data on some resources; echo the request back so + // callers always get an object rather than having to null-check a success. + return env.Data.Count > 0 ? env.Data[0] : spec; + } + private async Task DeleteAsync(string resource, string id, CancellationToken ct) { ArgumentException.ThrowIfNullOrEmpty(id); diff --git a/src/UnifiSharp/Legacy/UnifiLegacyModels.cs b/src/UnifiSharp/Legacy/UnifiLegacyModels.cs index 833c116..b50178d 100644 --- a/src/UnifiSharp/Legacy/UnifiLegacyModels.cs +++ b/src/UnifiSharp/Legacy/UnifiLegacyModels.cs @@ -1,7 +1,44 @@ +using System.Text.Json; using System.Text.Json.Serialization; namespace UnifiSharp.Legacy; +/// +/// Reads a JSON value that the controller types inconsistently — a string on some +/// builds, a bare number on others — into a . Writes always emit +/// a string, matching what create calls have historically sent. +/// Needed because a real UniFi OS gateway returns "vlan": 1010 while the +/// .containers/unifi test container returns "vlan": "1010". Typing the +/// property as string alone made ListNetworksAsync throw against real +/// hardware while passing every container test — exactly the version-brittleness +/// ADR-0003 warns about. +/// +internal sealed class FlexibleStringConverter : JsonConverter +{ + public override string? Read(ref Utf8JsonReader reader, Type type, JsonSerializerOptions options) => + reader.TokenType switch + { + JsonTokenType.String => reader.GetString(), + JsonTokenType.Number => reader.TryGetInt64(out var l) + ? l.ToString(System.Globalization.CultureInfo.InvariantCulture) + : reader.GetDouble().ToString(System.Globalization.CultureInfo.InvariantCulture), + JsonTokenType.Null => null, + _ => throw new JsonException($"expected string or number, got {reader.TokenType}"), + }; + + public override void Write(Utf8JsonWriter writer, string? value, JsonSerializerOptions options) + { + if (value is null) + { + writer.WriteNullValue(); + } + else + { + writer.WriteStringValue(value); + } + } +} + // DTOs for the legacy controller REST API. Each record doubles as the create // request body and the response row: null properties are omitted on serialize // (so a create sends only the fields you set), while the server echoes back @@ -64,6 +101,41 @@ public sealed record UnifiFirewallGroup [JsonPropertyName("group_members")] public IReadOnlyList? GroupMembers { get; init; } } +/// +/// A known client (rest/user) — the object a DHCP reservation lives on. +/// The controller keeps one entry per MAC it has ever seen; a reservation is not a +/// separate resource but these fields set on that entry, so "creating" one is a +/// PUT onto an existing row far more often than a POST. +/// Only null properties are omitted on serialize, so a PUT built from a +/// fresh instance with two fields set is a genuine partial update and leaves the +/// controller's fingerprinting, naming and history columns alone. +/// +public sealed record UnifiUser +{ + [JsonPropertyName("_id")] public string? Id { get; init; } + [JsonPropertyName("site_id")] public string? SiteId { get; init; } + /// MAC address, lowercase colon-separated — the client's identity. + [JsonPropertyName("mac")] public string? Mac { get; init; } + /// Operator-assigned alias, e.g. shell (CT 3003). Distinct from hostname. + [JsonPropertyName("name")] public string? Name { get; init; } + /// DHCP-reported hostname; read-only in practice. + [JsonPropertyName("hostname")] public string? Hostname { get; init; } + /// Whether the fixed IP is in force. Setting this false retires a reservation without losing the entry. + [JsonPropertyName("use_fixedip")] public bool? UseFixedIp { get; init; } + /// The reserved address. Ignored by the controller unless is true. + [JsonPropertyName("fixed_ip")] public string? FixedIp { get; init; } + /// _id of the network the reservation belongs to (see ). + [JsonPropertyName("network_id")] public string? NetworkId { get; init; } + /// + /// Per-client local DNS name, e.g. shell.devops.chrison.internal — how a name + /// resolves outside its own network's domain. Requires . + /// + [JsonPropertyName("local_dns_record")] public string? LocalDnsRecord { get; init; } + [JsonPropertyName("local_dns_record_enabled")] public bool? LocalDnsRecordEnabled { get; init; } + /// Last address the controller saw this client on — diagnostic, not the reservation. + [JsonPropertyName("last_ip")] public string? LastIp { get; init; } +} + /// /// A network / VLAN (rest/networkconf). Only the commonly-managed fields /// are typed; the server fills the rest with defaults on create. @@ -76,8 +148,13 @@ public sealed record UnifiNetwork /// Network role — typically corporate (a standard L3 network) or guest. [JsonPropertyName("purpose")] public string? Purpose { get; init; } [JsonPropertyName("vlan_enabled")] public bool? VlanEnabled { get; init; } - /// VLAN id, as a string (e.g. 1010). - [JsonPropertyName("vlan")] public string? Vlan { get; init; } + /// + /// VLAN id, as a string (e.g. 1010). Read leniently: a real gateway sends a + /// number here, the test container a string — see . + /// + [JsonPropertyName("vlan")] + [JsonConverter(typeof(FlexibleStringConverter))] + public string? Vlan { get; init; } /// Gateway/CIDR, e.g. 10.10.0.1/16. [JsonPropertyName("ip_subnet")] public string? IpSubnet { get; init; } [JsonPropertyName("dhcpd_enabled")] public bool? DhcpdEnabled { get; init; } diff --git a/src/UnifiSharp/Legacy/UnifiLegacyOptions.cs b/src/UnifiSharp/Legacy/UnifiLegacyOptions.cs index ab2a147..0af34c6 100644 --- a/src/UnifiSharp/Legacy/UnifiLegacyOptions.cs +++ b/src/UnifiSharp/Legacy/UnifiLegacyOptions.cs @@ -2,13 +2,19 @@ namespace UnifiSharp.Legacy; /// /// Connection options for the legacy UniFi controller API -/// (/proxy/network/api/s/<site>/rest/…). Unlike the official -/// integration API (X-API-KEY), the legacy API uses the classic controller -/// session: POST /api/auth/login with a username + password yields -/// a TOKEN cookie + X-CSRF-Token, reused on subsequent calls. -/// This is intentionally session-based: it works against the -/// .containers/unifi UniFi OS Server test container (which exposes no -/// scriptable API-key mint) and any UniFi OS gateway. See ADR-0003. +/// (/proxy/network/api/s/<site>/rest/…), in one of two auth modes: +/// +/// API key — set ; every request carries +/// X-API-KEY and no login round-trip happens. A UniFi OS gateway accepts +/// the same key the official integration API uses, so one credential covers both. +/// Session — set + ; +/// POST /api/auth/login yields a TOKEN cookie + X-CSRF-Token, +/// reused on subsequent calls. +/// +/// Session auth stays because the .containers/unifi UniFi OS Server test +/// container exposes no scriptable API-key mint, so it is the only mode that works +/// there. API-key auth is preferred against a real gateway: it needs no admin +/// username/password and survives password rotation. See ADR-0003. /// public sealed record UnifiLegacyOptions { @@ -19,11 +25,17 @@ public sealed record UnifiLegacyOptions /// public required Uri BaseUrl { get; init; } - /// Local admin username for the session login. - public required string Username { get; init; } + /// + /// API key for X-API-KEY auth. When set, and + /// are ignored and no session login is performed. + /// + public string? ApiKey { get; init; } + + /// Local admin username for the session login. Unused in API-key mode. + public string? Username { get; init; } - /// Local admin password for the session login. - public required string Password { get; init; } + /// Local admin password for the session login. Unused in API-key mode. + public string? Password { get; init; } /// /// Verify the console's TLS certificate. UniFi OS consoles commonly use a @@ -31,20 +43,61 @@ public sealed record UnifiLegacyOptions /// public bool VerifyTls { get; init; } = true; + /// True when is set — no login round-trip is needed. + public bool UsesApiKey => !string.IsNullOrEmpty(ApiKey); + /// The controller root (scheme + authority), where /api/auth/login lives. public Uri ControllerRoot => new(BaseUrl.GetLeftPart(UriPartial.Authority)); /// - /// Build options from UNIFI_LEGACY_BASE_URL / UNIFI_USERNAME / - /// UNIFI_PASSWORD / UNIFI_VERIFY_TLS; null if any required value is missing. - /// Matches the env emitted by .containers/unifi/bootstrap.sh. + /// The legacy site-API URL for a host, e.g. host 192.168.1.1 → + /// https://192.168.1.1/proxy/network/api/s/default. + /// + public static Uri SiteUrlFor(string host, string site = "default") => + new($"https://{host}/proxy/network/api/s/{site}"); + + /// + /// Throw unless one auth mode is fully configured. The session calls this on + /// construction so a half-configured options object fails loudly and early + /// rather than as an opaque 401 on the first request. + /// + public void Validate() + { + if (UsesApiKey || (!string.IsNullOrEmpty(Username) && !string.IsNullOrEmpty(Password))) + { + return; + } + + throw new UnifiLegacyException( + "UnifiLegacyOptions needs either ApiKey (X-API-KEY auth) or Username + Password (session auth)."); + } + + /// + /// Build options from the environment, preferring API-key auth: + /// + /// Base URL from UNIFI_LEGACY_BASE_URL, else derived from + /// UNIFI_LOCAL_HOST as https://<host>/proxy/network/api/s/default. + /// Auth from UNIFI_API_KEY, else UNIFI_USERNAME + UNIFI_PASSWORD + /// (the pair emitted by .containers/unifi/bootstrap.sh). + /// + /// UNIFI_VERIFY_TLS=false disables cert validation. Null when there is no + /// base URL, or no usable credential. /// public static UnifiLegacyOptions? TryFromEnvironment() { var baseUrl = Environment.GetEnvironmentVariable("UNIFI_LEGACY_BASE_URL"); + var localHost = Environment.GetEnvironmentVariable("UNIFI_LOCAL_HOST"); + if (string.IsNullOrEmpty(baseUrl) && string.IsNullOrEmpty(localHost)) + { + return null; + } + + var apiKey = Environment.GetEnvironmentVariable("UNIFI_API_KEY"); var username = Environment.GetEnvironmentVariable("UNIFI_USERNAME"); var password = Environment.GetEnvironmentVariable("UNIFI_PASSWORD"); - if (string.IsNullOrEmpty(baseUrl) || string.IsNullOrEmpty(username) || string.IsNullOrEmpty(password)) + + var hasApiKey = !string.IsNullOrEmpty(apiKey); + if (!hasApiKey && (string.IsNullOrEmpty(username) || string.IsNullOrEmpty(password))) { return null; } @@ -54,14 +107,17 @@ public sealed record UnifiLegacyOptions return new UnifiLegacyOptions { - BaseUrl = new Uri(baseUrl), - Username = username, - Password = password, + BaseUrl = string.IsNullOrEmpty(baseUrl) ? SiteUrlFor(localHost!) : new Uri(baseUrl), + ApiKey = hasApiKey ? apiKey : null, + Username = hasApiKey ? null : username, + Password = hasApiKey ? null : password, VerifyTls = verifyTls, }; } - /// Redacted representation — never emits the password. + /// Redacted representation — never emits the API key or the password. public override string ToString() => - $"UnifiLegacyOptions {{ BaseUrl = {BaseUrl}, Username = {Username}, Password = ***, VerifyTls = {VerifyTls} }}"; + $"UnifiLegacyOptions {{ BaseUrl = {BaseUrl}, " + + $"Auth = {(UsesApiKey ? "ApiKey ***" : $"Username = {Username}, Password = ***")}, " + + $"VerifyTls = {VerifyTls} }}"; } diff --git a/src/UnifiSharp/Legacy/UnifiLegacySession.cs b/src/UnifiSharp/Legacy/UnifiLegacySession.cs index 0fc9607..f2bd248 100644 --- a/src/UnifiSharp/Legacy/UnifiLegacySession.cs +++ b/src/UnifiSharp/Legacy/UnifiLegacySession.cs @@ -6,11 +6,15 @@ namespace UnifiSharp.Legacy; /// -/// Holds a classic UniFi controller session for the legacy API: logs in via -/// POST /api/auth/login (capturing the TOKEN cookie + CSRF token) -/// and sends authenticated requests, attaching the CSRF token to mutating calls -/// and transparently re-authenticating once on a 401. Disposable — owns its -/// . +/// Authenticates legacy-API requests in whichever mode +/// specifies. +/// API-key mode ( set): every request +/// carries X-API-KEY. There is no login, no cookie and no CSRF token, so a 401 is +/// a real authorization failure and is surfaced rather than retried. +/// Session mode: logs in via POST /api/auth/login (capturing the +/// TOKEN cookie + CSRF token), attaches the CSRF token to mutating calls, and +/// transparently re-authenticates once on a 401. +/// Disposable — owns its . /// public sealed class UnifiLegacySession : IDisposable { @@ -23,6 +27,7 @@ public sealed class UnifiLegacySession : IDisposable public UnifiLegacySession(UnifiLegacyOptions options) { ArgumentNullException.ThrowIfNull(options); + options.Validate(); _options = options; _loginUrl = new Uri(_options.ControllerRoot, "/api/auth/login"); @@ -36,9 +41,18 @@ public UnifiLegacySession(UnifiLegacyOptions options) _http = new HttpClient(handler); } - /// Ensure a valid session, logging in if needed. + /// + /// Ensure a valid session, logging in if needed. A no-op in API-key mode — there + /// is no session to establish. + /// public async Task LoginAsync(CancellationToken ct = default) { + if (_options.UsesApiKey) + { + _loggedIn = true; + return; + } + using var req = new HttpRequestMessage(HttpMethod.Post, _loginUrl) { Content = JsonContent.Create(new { username = _options.Username, password = _options.Password }), @@ -68,9 +82,12 @@ public async Task SendAsync( } var resp = await SendOnceAsync(method, relativePath, content, ct).ConfigureAwait(false); - if (resp.StatusCode == HttpStatusCode.Unauthorized) + + // Re-auth-and-retry only makes sense for a session that can expire. In API-key + // mode a 401 means the key is wrong or unprivileged, and retrying would just + // repeat it — let the caller see the failure. + if (resp.StatusCode == HttpStatusCode.Unauthorized && !_options.UsesApiKey) { - // Session expired — re-auth once and retry. resp.Dispose(); await LoginAsync(ct).ConfigureAwait(false); resp = await SendOnceAsync(method, relativePath, content, ct).ConfigureAwait(false); @@ -91,7 +108,11 @@ private async Task SendOnceAsync( req.Content = content; } - if (_csrfToken is not null) + if (_options.UsesApiKey) + { + req.Headers.TryAddWithoutValidation("X-API-KEY", _options.ApiKey); + } + else if (_csrfToken is not null) { req.Headers.TryAddWithoutValidation("X-CSRF-Token", _csrfToken); } diff --git a/src/UnifiSharp/UnifiSharp.csproj b/src/UnifiSharp/UnifiSharp.csproj index ee7ef78..1c44312 100644 --- a/src/UnifiSharp/UnifiSharp.csproj +++ b/src/UnifiSharp/UnifiSharp.csproj @@ -6,7 +6,7 @@ enable - 0.1.1 + 0.2.0