Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ flowchart LR
SPEC["📜 UniFi OpenAPI 3.1<br/>(console / beezly mirror)"] --> KIOTA["⚙️ Kiota (pinned tool)<br/>generate C# client"]
KIOTA --> API["📦 UnifiSharp.Api<br/>generated · tracks UniFi release"]
API --> RT["✍️ UnifiSharp<br/>hand-written runtime (X-API-KEY)"]
RT --> LEG["🧩 UnifiSharp.Legacy<br/>session-auth write adapter<br/>(port-forwards · firewall · networks)"]
RT --> LEG["🧩 UnifiSharp.Legacy<br/>write adapter · API-key or session<br/>(port-forwards · firewall · networks · clients)"]
classDef gen fill:#e0e7ff,stroke:#4f46e5;
class API gen;
```
Expand Down Expand Up @@ -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://<host>/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.
Expand Down
42 changes: 42 additions & 0 deletions src/UnifiSharp/Legacy/UnifiLegacyClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,13 @@ public Task<IReadOnlyList<UnifiPortForward>> ListPortForwardsAsync(CancellationT
public Task<UnifiPortForward> CreatePortForwardAsync(UnifiPortForward spec, CancellationToken ct = default)
=> CreateAsync("portforward", spec, ct);

/// <summary>
/// Partial-update a port-forward. Only non-null properties are sent, so a caller
/// can correct one drifted field without restating the rule.
/// </summary>
public Task<UnifiPortForward> 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);

Expand All @@ -66,6 +73,29 @@ public Task<UnifiNetwork> 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<IReadOnlyList<UnifiUser>> ListUsersAsync(CancellationToken ct = default)
=> ListAsync<UnifiUser>("user", ct);

/// <summary>
/// Partial-update a known client. Only the non-null properties of
/// <paramref name="spec"/> are sent, so untouched fields keep their server values.
/// </summary>
public Task<UnifiUser> UpdateUserAsync(string id, UnifiUser spec, CancellationToken ct = default)
=> UpdateAsync("user", id, spec, ct);

/// <summary>
/// Register a client the controller has never seen. Rarely needed — a guest that
/// has ever taken a lease already has a row, and <see cref="UpdateUserAsync"/> is
/// the path for it.
/// </summary>
public Task<UnifiUser> CreateUserAsync(UnifiUser spec, CancellationToken ct = default)
=> CreateAsync("user", spec, ct);

// ── Generic REST verbs over the {meta,data} envelope ──────────────────────

private async Task<IReadOnlyList<T>> ListAsync<T>(string resource, CancellationToken ct)
Expand All @@ -85,6 +115,18 @@ private async Task<T> CreateAsync<T>(string resource, T spec, CancellationToken
: throw new UnifiLegacyException($"create {resource}: ok but no object returned");
}

private async Task<T> UpdateAsync<T>(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<T>(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);
Expand Down
81 changes: 79 additions & 2 deletions src/UnifiSharp/Legacy/UnifiLegacyModels.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,44 @@
using System.Text.Json;
using System.Text.Json.Serialization;

namespace UnifiSharp.Legacy;

/// <summary>
/// Reads a JSON value that the controller types inconsistently — a string on some
/// builds, a bare number on others — into a <see cref="string"/>. Writes always emit
/// a string, matching what create calls have historically sent.
/// <para>Needed because a real UniFi OS gateway returns <c>"vlan": 1010</c> while the
/// <c>.containers/unifi</c> test container returns <c>"vlan": "1010"</c>. Typing the
/// property as <c>string</c> alone made <c>ListNetworksAsync</c> throw against real
/// hardware while passing every container test — exactly the version-brittleness
/// ADR-0003 warns about.</para>
/// </summary>
internal sealed class FlexibleStringConverter : JsonConverter<string?>
{
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
Expand Down Expand Up @@ -64,6 +101,41 @@ public sealed record UnifiFirewallGroup
[JsonPropertyName("group_members")] public IReadOnlyList<string>? GroupMembers { get; init; }
}

/// <summary>
/// A known client (<c>rest/user</c>) — the object a <b>DHCP reservation</b> 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
/// <c>PUT</c> onto an existing row far more often than a <c>POST</c>.
/// <para>Only null properties are omitted on serialize, so a <c>PUT</c> 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.</para>
/// </summary>
public sealed record UnifiUser
{
[JsonPropertyName("_id")] public string? Id { get; init; }
[JsonPropertyName("site_id")] public string? SiteId { get; init; }
/// <summary>MAC address, lowercase colon-separated — the client's identity.</summary>
[JsonPropertyName("mac")] public string? Mac { get; init; }
/// <summary>Operator-assigned alias, e.g. <c>shell (CT 3003)</c>. Distinct from <c>hostname</c>.</summary>
[JsonPropertyName("name")] public string? Name { get; init; }
/// <summary>DHCP-reported hostname; read-only in practice.</summary>
[JsonPropertyName("hostname")] public string? Hostname { get; init; }
/// <summary>Whether the fixed IP is in force. Setting this false retires a reservation without losing the entry.</summary>
[JsonPropertyName("use_fixedip")] public bool? UseFixedIp { get; init; }
/// <summary>The reserved address. Ignored by the controller unless <see cref="UseFixedIp"/> is true.</summary>
[JsonPropertyName("fixed_ip")] public string? FixedIp { get; init; }
/// <summary>_id of the network the reservation belongs to (see <see cref="UnifiNetwork"/>).</summary>
[JsonPropertyName("network_id")] public string? NetworkId { get; init; }
/// <summary>
/// Per-client local DNS name, e.g. <c>shell.devops.chrison.internal</c> — how a name
/// resolves outside its own network's domain. Requires <see cref="LocalDnsRecordEnabled"/>.
/// </summary>
[JsonPropertyName("local_dns_record")] public string? LocalDnsRecord { get; init; }
[JsonPropertyName("local_dns_record_enabled")] public bool? LocalDnsRecordEnabled { get; init; }
/// <summary>Last address the controller saw this client on — diagnostic, not the reservation.</summary>
[JsonPropertyName("last_ip")] public string? LastIp { get; init; }
}

/// <summary>
/// A network / VLAN (<c>rest/networkconf</c>). Only the commonly-managed fields
/// are typed; the server fills the rest with defaults on create.
Expand All @@ -76,8 +148,13 @@ public sealed record UnifiNetwork
/// <summary>Network role — typically <c>corporate</c> (a standard L3 network) or <c>guest</c>.</summary>
[JsonPropertyName("purpose")] public string? Purpose { get; init; }
[JsonPropertyName("vlan_enabled")] public bool? VlanEnabled { get; init; }
/// <summary>VLAN id, as a string (e.g. <c>1010</c>).</summary>
[JsonPropertyName("vlan")] public string? Vlan { get; init; }
/// <summary>
/// VLAN id, as a string (e.g. <c>1010</c>). Read leniently: a real gateway sends a
/// number here, the test container a string — see <see cref="FlexibleStringConverter"/>.
/// </summary>
[JsonPropertyName("vlan")]
[JsonConverter(typeof(FlexibleStringConverter))]
public string? Vlan { get; init; }
/// <summary>Gateway/CIDR, e.g. <c>10.10.0.1/16</c>.</summary>
[JsonPropertyName("ip_subnet")] public string? IpSubnet { get; init; }
[JsonPropertyName("dhcpd_enabled")] public bool? DhcpdEnabled { get; init; }
Expand Down
96 changes: 76 additions & 20 deletions src/UnifiSharp/Legacy/UnifiLegacyOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,19 @@ namespace UnifiSharp.Legacy;

/// <summary>
/// Connection options for the <b>legacy</b> UniFi controller API
/// (<c>/proxy/network/api/s/&lt;site&gt;/rest/…</c>). Unlike the official
/// integration API (X-API-KEY), the legacy API uses the classic controller
/// <b>session</b>: <c>POST /api/auth/login</c> with a username + password yields
/// a <c>TOKEN</c> cookie + <c>X-CSRF-Token</c>, reused on subsequent calls.
/// <para>This is intentionally session-based: it works against the
/// <c>.containers/unifi</c> UniFi OS Server test container (which exposes no
/// scriptable API-key mint) and any UniFi OS gateway. See ADR-0003.</para>
/// (<c>/proxy/network/api/s/&lt;site&gt;/rest/…</c>), in one of two auth modes:
/// <list type="bullet">
/// <item><b>API key</b> — set <see cref="ApiKey"/>; every request carries
/// <c>X-API-KEY</c> and no login round-trip happens. A UniFi OS gateway accepts
/// the same key the official integration API uses, so one credential covers both.</item>
/// <item><b>Session</b> — set <see cref="Username"/> + <see cref="Password"/>;
/// <c>POST /api/auth/login</c> yields a <c>TOKEN</c> cookie + <c>X-CSRF-Token</c>,
/// reused on subsequent calls.</item>
/// </list>
/// <para>Session auth stays because the <c>.containers/unifi</c> 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.</para>
/// </summary>
public sealed record UnifiLegacyOptions
{
Expand All @@ -19,32 +25,79 @@ public sealed record UnifiLegacyOptions
/// </summary>
public required Uri BaseUrl { get; init; }

/// <summary>Local admin username for the session login.</summary>
public required string Username { get; init; }
/// <summary>
/// API key for <c>X-API-KEY</c> auth. When set, <see cref="Username"/> and
/// <see cref="Password"/> are ignored and no session login is performed.
/// </summary>
public string? ApiKey { get; init; }

/// <summary>Local admin username for the session login. Unused in API-key mode.</summary>
public string? Username { get; init; }

/// <summary>Local admin password for the session login.</summary>
public required string Password { get; init; }
/// <summary>Local admin password for the session login. Unused in API-key mode.</summary>
public string? Password { get; init; }

/// <summary>
/// Verify the console's TLS certificate. UniFi OS consoles commonly use a
/// self-signed cert on the LAN, so this can be turned off — defaults to on.
/// </summary>
public bool VerifyTls { get; init; } = true;

/// <summary>True when <see cref="ApiKey"/> is set — no login round-trip is needed.</summary>
public bool UsesApiKey => !string.IsNullOrEmpty(ApiKey);

/// <summary>The controller root (scheme + authority), where <c>/api/auth/login</c> lives.</summary>
public Uri ControllerRoot => new(BaseUrl.GetLeftPart(UriPartial.Authority));

/// <summary>
/// Build options from <c>UNIFI_LEGACY_BASE_URL</c> / <c>UNIFI_USERNAME</c> /
/// <c>UNIFI_PASSWORD</c> / <c>UNIFI_VERIFY_TLS</c>; null if any required value is missing.
/// Matches the env emitted by <c>.containers/unifi/bootstrap.sh</c>.
/// The legacy site-API URL for a host, e.g. host <c>192.168.1.1</c> →
/// <c>https://192.168.1.1/proxy/network/api/s/default</c>.
/// </summary>
public static Uri SiteUrlFor(string host, string site = "default") =>
new($"https://{host}/proxy/network/api/s/{site}");

/// <summary>
/// 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.
/// </summary>
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).");
}

/// <summary>
/// Build options from the environment, preferring API-key auth:
/// <list type="number">
/// <item>Base URL from <c>UNIFI_LEGACY_BASE_URL</c>, else derived from
/// <c>UNIFI_LOCAL_HOST</c> as <c>https://&lt;host&gt;/proxy/network/api/s/default</c>.</item>
/// <item>Auth from <c>UNIFI_API_KEY</c>, else <c>UNIFI_USERNAME</c> + <c>UNIFI_PASSWORD</c>
/// (the pair emitted by <c>.containers/unifi/bootstrap.sh</c>).</item>
/// </list>
/// <c>UNIFI_VERIFY_TLS=false</c> disables cert validation. Null when there is no
/// base URL, or no usable credential.
/// </summary>
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;
}
Expand All @@ -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,
};
}

/// <summary>Redacted representation — never emits the password.</summary>
/// <summary>Redacted representation — never emits the API key or the password.</summary>
public override string ToString() =>
$"UnifiLegacyOptions {{ BaseUrl = {BaseUrl}, Username = {Username}, Password = ***, VerifyTls = {VerifyTls} }}";
$"UnifiLegacyOptions {{ BaseUrl = {BaseUrl}, " +
$"Auth = {(UsesApiKey ? "ApiKey ***" : $"Username = {Username}, Password = ***")}, " +
$"VerifyTls = {VerifyTls} }}";
}
Loading
Loading