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..019892c 100644
--- a/src/UnifiSharp/Legacy/UnifiLegacyClient.cs
+++ b/src/UnifiSharp/Legacy/UnifiLegacyClient.cs
@@ -43,6 +43,13 @@ public Task> ListPortForwardsAsync(CancellationT
public Task CreatePortForwardAsync(UnifiPortForward spec, CancellationToken ct = default)
=> CreateAsync("portforward", spec, ct);
+ ///
+ /// Partial-update a port-forward. Only non-null properties are sent, so a caller
+ /// can correct one drifted field without restating the rule.
+ ///
+ public Task UpdatePortForwardAsync(string id, UnifiPortForward spec, CancellationToken ct = default)
+ => UpdateAsync("portforward", id, spec, ct);
+
public Task DeletePortForwardAsync(string id, CancellationToken ct = default)
=> DeleteAsync("portforward", id, ct);
@@ -66,6 +73,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 +115,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