Skip to content

Commit af05455

Browse files
Add API-key auth, rest/user, and port-forward update to the legacy adapter (#17)
The legacy write adapter only knew session auth, so it required UNIFI_USERNAME/UNIFI_PASSWORD. Callers configured from an API key alone — which is what the Homelab secrets file carries — 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; session auth stays, since the .containers/unifi test container cannot mint API keys. - UnifiLegacyOptions: optional ApiKey, optional Username/Password, Validate() to reject a half-configured pair up front, and TryFromEnvironment() preferring UNIFI_API_KEY and deriving the site URL from UNIFI_LOCAL_HOST. - UnifiLegacySession: API-key mode attaches X-API-KEY and skips login; a 401 is not retried there, because there is no session to refresh. - UnifiLegacyClient: rest/user (list, partial update, create) — the object a DHCP reservation lives on — plus UpdatePortForwardAsync, so a drifted rule can be corrected instead of deleted and recreated. Two things surfaced by testing against real hardware rather than the container: UnifiNetwork.Vlan was typed string, but a real gateway sends a number and only the container sends a string, so ListNetworksAsync threw on every real controller while the container suite stayed green. Reads accept either form now; writes are unchanged. The destructive live tests are 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 they would otherwise have aimed at a live gateway. Bumps Chrison.UnifiSharp to 0.2.0.
1 parent c409be0 commit af05455

8 files changed

Lines changed: 421 additions & 35 deletions

File tree

‎README.md‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ flowchart LR
2323
SPEC["📜 UniFi OpenAPI 3.1<br/>(console / beezly mirror)"] --> KIOTA["⚙️ Kiota (pinned tool)<br/>generate C# client"]
2424
KIOTA --> API["📦 UnifiSharp.Api<br/>generated · tracks UniFi release"]
2525
API --> RT["✍️ UnifiSharp<br/>hand-written runtime (X-API-KEY)"]
26-
RT --> LEG["🧩 UnifiSharp.Legacy<br/>session-auth write adapter<br/>(port-forwards · firewall · networks)"]
26+
RT --> LEG["🧩 UnifiSharp.Legacy<br/>write adapter · API-key or session<br/>(port-forwards · firewall · networks · clients)"]
2727
classDef gen fill:#e0e7ff,stroke:#4f46e5;
2828
class API gen;
2929
```
@@ -111,6 +111,23 @@ unifisharp devices # adopted devices per site (name, model, ip, mac, firmwar
111111
unifisharp clients # connected clients per site (name, type, ip, connectedAt)
112112
```
113113

114+
## Legacy adapter auth
115+
116+
`UnifiLegacyOptions` takes either credential, and `TryFromEnvironment()` prefers the key:
117+
118+
| Mode | Set | Notes |
119+
|---|---|---|
120+
| **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. |
121+
| **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. |
122+
123+
With `UNIFI_LOCAL_HOST` alone the site URL is derived as
124+
`https://<host>/proxy/network/api/s/default`.
125+
126+
> The **destructive** live tests (`UnifiLegacyLiveTests` — they create and delete a
127+
> port-forward, a firewall group and a VLAN) are gated on *session* auth on purpose,
128+
> so a shell holding only an API key can never point them at a production gateway.
129+
> Read-only live checks (`UnifiLegacyReadOnlyLiveTests`) run in either mode.
130+
114131
## Status
115132

116133
Read client + `discover` + `unifisharp` CLI building from the 10.4.57 spec.

‎src/UnifiSharp/Legacy/UnifiLegacyClient.cs‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,13 @@ public Task<IReadOnlyList<UnifiPortForward>> ListPortForwardsAsync(CancellationT
4343
public Task<UnifiPortForward> CreatePortForwardAsync(UnifiPortForward spec, CancellationToken ct = default)
4444
=> CreateAsync("portforward", spec, ct);
4545

46+
/// <summary>
47+
/// Partial-update a port-forward. Only non-null properties are sent, so a caller
48+
/// can correct one drifted field without restating the rule.
49+
/// </summary>
50+
public Task<UnifiPortForward> UpdatePortForwardAsync(string id, UnifiPortForward spec, CancellationToken ct = default)
51+
=> UpdateAsync("portforward", id, spec, ct);
52+
4653
public Task DeletePortForwardAsync(string id, CancellationToken ct = default)
4754
=> DeleteAsync("portforward", id, ct);
4855

@@ -66,6 +73,29 @@ public Task<UnifiNetwork> CreateNetworkAsync(UnifiNetwork spec, CancellationToke
6673
public Task DeleteNetworkAsync(string id, CancellationToken ct = default)
6774
=> DeleteAsync("networkconf", id, ct);
6875

76+
// ── Known clients / DHCP reservations (rest/user) ─────────────────────────
77+
// NOTE there is no Delete here, deliberately. Removing a known client discards
78+
// its name and history along with the reservation; retiring a reservation is
79+
// UpdateUserAsync with UseFixedIp=false, which is reversible.
80+
81+
public Task<IReadOnlyList<UnifiUser>> ListUsersAsync(CancellationToken ct = default)
82+
=> ListAsync<UnifiUser>("user", ct);
83+
84+
/// <summary>
85+
/// Partial-update a known client. Only the non-null properties of
86+
/// <paramref name="spec"/> are sent, so untouched fields keep their server values.
87+
/// </summary>
88+
public Task<UnifiUser> UpdateUserAsync(string id, UnifiUser spec, CancellationToken ct = default)
89+
=> UpdateAsync("user", id, spec, ct);
90+
91+
/// <summary>
92+
/// Register a client the controller has never seen. Rarely needed — a guest that
93+
/// has ever taken a lease already has a row, and <see cref="UpdateUserAsync"/> is
94+
/// the path for it.
95+
/// </summary>
96+
public Task<UnifiUser> CreateUserAsync(UnifiUser spec, CancellationToken ct = default)
97+
=> CreateAsync("user", spec, ct);
98+
6999
// ── Generic REST verbs over the {meta,data} envelope ──────────────────────
70100

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

118+
private async Task<T> UpdateAsync<T>(string resource, string id, T spec, CancellationToken ct)
119+
{
120+
ArgumentException.ThrowIfNullOrEmpty(id);
121+
var json = JsonSerializer.Serialize(spec, SerializerOptions);
122+
using var resp = await _session.SendAsync(
123+
HttpMethod.Put, $"rest/{resource}/{id}", UnifiLegacySession.JsonBody(json), ct).ConfigureAwait(false);
124+
var env = await ReadEnvelopeAsync<T>(resp, resource, ct).ConfigureAwait(false);
125+
// A PUT is ok-with-empty-data on some resources; echo the request back so
126+
// callers always get an object rather than having to null-check a success.
127+
return env.Data.Count > 0 ? env.Data[0] : spec;
128+
}
129+
88130
private async Task DeleteAsync(string resource, string id, CancellationToken ct)
89131
{
90132
ArgumentException.ThrowIfNullOrEmpty(id);

‎src/UnifiSharp/Legacy/UnifiLegacyModels.cs‎

Lines changed: 79 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,44 @@
1+
using System.Text.Json;
12
using System.Text.Json.Serialization;
23

34
namespace UnifiSharp.Legacy;
45

6+
/// <summary>
7+
/// Reads a JSON value that the controller types inconsistently — a string on some
8+
/// builds, a bare number on others — into a <see cref="string"/>. Writes always emit
9+
/// a string, matching what create calls have historically sent.
10+
/// <para>Needed because a real UniFi OS gateway returns <c>"vlan": 1010</c> while the
11+
/// <c>.containers/unifi</c> test container returns <c>"vlan": "1010"</c>. Typing the
12+
/// property as <c>string</c> alone made <c>ListNetworksAsync</c> throw against real
13+
/// hardware while passing every container test — exactly the version-brittleness
14+
/// ADR-0003 warns about.</para>
15+
/// </summary>
16+
internal sealed class FlexibleStringConverter : JsonConverter<string?>
17+
{
18+
public override string? Read(ref Utf8JsonReader reader, Type type, JsonSerializerOptions options) =>
19+
reader.TokenType switch
20+
{
21+
JsonTokenType.String => reader.GetString(),
22+
JsonTokenType.Number => reader.TryGetInt64(out var l)
23+
? l.ToString(System.Globalization.CultureInfo.InvariantCulture)
24+
: reader.GetDouble().ToString(System.Globalization.CultureInfo.InvariantCulture),
25+
JsonTokenType.Null => null,
26+
_ => throw new JsonException($"expected string or number, got {reader.TokenType}"),
27+
};
28+
29+
public override void Write(Utf8JsonWriter writer, string? value, JsonSerializerOptions options)
30+
{
31+
if (value is null)
32+
{
33+
writer.WriteNullValue();
34+
}
35+
else
36+
{
37+
writer.WriteStringValue(value);
38+
}
39+
}
40+
}
41+
542
// DTOs for the legacy controller REST API. Each record doubles as the create
643
// request body and the response row: null properties are omitted on serialize
744
// (so a create sends only the fields you set), while the server echoes back
@@ -64,6 +101,41 @@ public sealed record UnifiFirewallGroup
64101
[JsonPropertyName("group_members")] public IReadOnlyList<string>? GroupMembers { get; init; }
65102
}
66103

104+
/// <summary>
105+
/// A known client (<c>rest/user</c>) — the object a <b>DHCP reservation</b> lives on.
106+
/// The controller keeps one entry per MAC it has ever seen; a reservation is not a
107+
/// separate resource but these fields set on that entry, so "creating" one is a
108+
/// <c>PUT</c> onto an existing row far more often than a <c>POST</c>.
109+
/// <para>Only null properties are omitted on serialize, so a <c>PUT</c> built from a
110+
/// fresh instance with two fields set is a genuine partial update and leaves the
111+
/// controller's fingerprinting, naming and history columns alone.</para>
112+
/// </summary>
113+
public sealed record UnifiUser
114+
{
115+
[JsonPropertyName("_id")] public string? Id { get; init; }
116+
[JsonPropertyName("site_id")] public string? SiteId { get; init; }
117+
/// <summary>MAC address, lowercase colon-separated — the client's identity.</summary>
118+
[JsonPropertyName("mac")] public string? Mac { get; init; }
119+
/// <summary>Operator-assigned alias, e.g. <c>shell (CT 3003)</c>. Distinct from <c>hostname</c>.</summary>
120+
[JsonPropertyName("name")] public string? Name { get; init; }
121+
/// <summary>DHCP-reported hostname; read-only in practice.</summary>
122+
[JsonPropertyName("hostname")] public string? Hostname { get; init; }
123+
/// <summary>Whether the fixed IP is in force. Setting this false retires a reservation without losing the entry.</summary>
124+
[JsonPropertyName("use_fixedip")] public bool? UseFixedIp { get; init; }
125+
/// <summary>The reserved address. Ignored by the controller unless <see cref="UseFixedIp"/> is true.</summary>
126+
[JsonPropertyName("fixed_ip")] public string? FixedIp { get; init; }
127+
/// <summary>_id of the network the reservation belongs to (see <see cref="UnifiNetwork"/>).</summary>
128+
[JsonPropertyName("network_id")] public string? NetworkId { get; init; }
129+
/// <summary>
130+
/// Per-client local DNS name, e.g. <c>shell.devops.chrison.internal</c> — how a name
131+
/// resolves outside its own network's domain. Requires <see cref="LocalDnsRecordEnabled"/>.
132+
/// </summary>
133+
[JsonPropertyName("local_dns_record")] public string? LocalDnsRecord { get; init; }
134+
[JsonPropertyName("local_dns_record_enabled")] public bool? LocalDnsRecordEnabled { get; init; }
135+
/// <summary>Last address the controller saw this client on — diagnostic, not the reservation.</summary>
136+
[JsonPropertyName("last_ip")] public string? LastIp { get; init; }
137+
}
138+
67139
/// <summary>
68140
/// A network / VLAN (<c>rest/networkconf</c>). Only the commonly-managed fields
69141
/// are typed; the server fills the rest with defaults on create.
@@ -76,8 +148,13 @@ public sealed record UnifiNetwork
76148
/// <summary>Network role — typically <c>corporate</c> (a standard L3 network) or <c>guest</c>.</summary>
77149
[JsonPropertyName("purpose")] public string? Purpose { get; init; }
78150
[JsonPropertyName("vlan_enabled")] public bool? VlanEnabled { get; init; }
79-
/// <summary>VLAN id, as a string (e.g. <c>1010</c>).</summary>
80-
[JsonPropertyName("vlan")] public string? Vlan { get; init; }
151+
/// <summary>
152+
/// VLAN id, as a string (e.g. <c>1010</c>). Read leniently: a real gateway sends a
153+
/// number here, the test container a string — see <see cref="FlexibleStringConverter"/>.
154+
/// </summary>
155+
[JsonPropertyName("vlan")]
156+
[JsonConverter(typeof(FlexibleStringConverter))]
157+
public string? Vlan { get; init; }
81158
/// <summary>Gateway/CIDR, e.g. <c>10.10.0.1/16</c>.</summary>
82159
[JsonPropertyName("ip_subnet")] public string? IpSubnet { get; init; }
83160
[JsonPropertyName("dhcpd_enabled")] public bool? DhcpdEnabled { get; init; }

‎src/UnifiSharp/Legacy/UnifiLegacyOptions.cs‎

Lines changed: 76 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,19 @@ namespace UnifiSharp.Legacy;
22

33
/// <summary>
44
/// Connection options for the <b>legacy</b> UniFi controller API
5-
/// (<c>/proxy/network/api/s/&lt;site&gt;/rest/…</c>). Unlike the official
6-
/// integration API (X-API-KEY), the legacy API uses the classic controller
7-
/// <b>session</b>: <c>POST /api/auth/login</c> with a username + password yields
8-
/// a <c>TOKEN</c> cookie + <c>X-CSRF-Token</c>, reused on subsequent calls.
9-
/// <para>This is intentionally session-based: it works against the
10-
/// <c>.containers/unifi</c> UniFi OS Server test container (which exposes no
11-
/// scriptable API-key mint) and any UniFi OS gateway. See ADR-0003.</para>
5+
/// (<c>/proxy/network/api/s/&lt;site&gt;/rest/…</c>), in one of two auth modes:
6+
/// <list type="bullet">
7+
/// <item><b>API key</b> — set <see cref="ApiKey"/>; every request carries
8+
/// <c>X-API-KEY</c> and no login round-trip happens. A UniFi OS gateway accepts
9+
/// the same key the official integration API uses, so one credential covers both.</item>
10+
/// <item><b>Session</b> — set <see cref="Username"/> + <see cref="Password"/>;
11+
/// <c>POST /api/auth/login</c> yields a <c>TOKEN</c> cookie + <c>X-CSRF-Token</c>,
12+
/// reused on subsequent calls.</item>
13+
/// </list>
14+
/// <para>Session auth stays because the <c>.containers/unifi</c> UniFi OS Server test
15+
/// container exposes no scriptable API-key mint, so it is the only mode that works
16+
/// there. API-key auth is preferred against a real gateway: it needs no admin
17+
/// username/password and survives password rotation. See ADR-0003.</para>
1218
/// </summary>
1319
public sealed record UnifiLegacyOptions
1420
{
@@ -19,32 +25,79 @@ public sealed record UnifiLegacyOptions
1925
/// </summary>
2026
public required Uri BaseUrl { get; init; }
2127

22-
/// <summary>Local admin username for the session login.</summary>
23-
public required string Username { get; init; }
28+
/// <summary>
29+
/// API key for <c>X-API-KEY</c> auth. When set, <see cref="Username"/> and
30+
/// <see cref="Password"/> are ignored and no session login is performed.
31+
/// </summary>
32+
public string? ApiKey { get; init; }
33+
34+
/// <summary>Local admin username for the session login. Unused in API-key mode.</summary>
35+
public string? Username { get; init; }
2436

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

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

46+
/// <summary>True when <see cref="ApiKey"/> is set — no login round-trip is needed.</summary>
47+
public bool UsesApiKey => !string.IsNullOrEmpty(ApiKey);
48+
3449
/// <summary>The controller root (scheme + authority), where <c>/api/auth/login</c> lives.</summary>
3550
public Uri ControllerRoot => new(BaseUrl.GetLeftPart(UriPartial.Authority));
3651

3752
/// <summary>
38-
/// Build options from <c>UNIFI_LEGACY_BASE_URL</c> / <c>UNIFI_USERNAME</c> /
39-
/// <c>UNIFI_PASSWORD</c> / <c>UNIFI_VERIFY_TLS</c>; null if any required value is missing.
40-
/// Matches the env emitted by <c>.containers/unifi/bootstrap.sh</c>.
53+
/// The legacy site-API URL for a host, e.g. host <c>192.168.1.1</c> →
54+
/// <c>https://192.168.1.1/proxy/network/api/s/default</c>.
55+
/// </summary>
56+
public static Uri SiteUrlFor(string host, string site = "default") =>
57+
new($"https://{host}/proxy/network/api/s/{site}");
58+
59+
/// <summary>
60+
/// Throw unless one auth mode is fully configured. The session calls this on
61+
/// construction so a half-configured options object fails loudly and early
62+
/// rather than as an opaque 401 on the first request.
63+
/// </summary>
64+
public void Validate()
65+
{
66+
if (UsesApiKey || (!string.IsNullOrEmpty(Username) && !string.IsNullOrEmpty(Password)))
67+
{
68+
return;
69+
}
70+
71+
throw new UnifiLegacyException(
72+
"UnifiLegacyOptions needs either ApiKey (X-API-KEY auth) or Username + Password (session auth).");
73+
}
74+
75+
/// <summary>
76+
/// Build options from the environment, preferring API-key auth:
77+
/// <list type="number">
78+
/// <item>Base URL from <c>UNIFI_LEGACY_BASE_URL</c>, else derived from
79+
/// <c>UNIFI_LOCAL_HOST</c> as <c>https://&lt;host&gt;/proxy/network/api/s/default</c>.</item>
80+
/// <item>Auth from <c>UNIFI_API_KEY</c>, else <c>UNIFI_USERNAME</c> + <c>UNIFI_PASSWORD</c>
81+
/// (the pair emitted by <c>.containers/unifi/bootstrap.sh</c>).</item>
82+
/// </list>
83+
/// <c>UNIFI_VERIFY_TLS=false</c> disables cert validation. Null when there is no
84+
/// base URL, or no usable credential.
4185
/// </summary>
4286
public static UnifiLegacyOptions? TryFromEnvironment()
4387
{
4488
var baseUrl = Environment.GetEnvironmentVariable("UNIFI_LEGACY_BASE_URL");
89+
var localHost = Environment.GetEnvironmentVariable("UNIFI_LOCAL_HOST");
90+
if (string.IsNullOrEmpty(baseUrl) && string.IsNullOrEmpty(localHost))
91+
{
92+
return null;
93+
}
94+
95+
var apiKey = Environment.GetEnvironmentVariable("UNIFI_API_KEY");
4596
var username = Environment.GetEnvironmentVariable("UNIFI_USERNAME");
4697
var password = Environment.GetEnvironmentVariable("UNIFI_PASSWORD");
47-
if (string.IsNullOrEmpty(baseUrl) || string.IsNullOrEmpty(username) || string.IsNullOrEmpty(password))
98+
99+
var hasApiKey = !string.IsNullOrEmpty(apiKey);
100+
if (!hasApiKey && (string.IsNullOrEmpty(username) || string.IsNullOrEmpty(password)))
48101
{
49102
return null;
50103
}
@@ -54,14 +107,17 @@ public sealed record UnifiLegacyOptions
54107

55108
return new UnifiLegacyOptions
56109
{
57-
BaseUrl = new Uri(baseUrl),
58-
Username = username,
59-
Password = password,
110+
BaseUrl = string.IsNullOrEmpty(baseUrl) ? SiteUrlFor(localHost!) : new Uri(baseUrl),
111+
ApiKey = hasApiKey ? apiKey : null,
112+
Username = hasApiKey ? null : username,
113+
Password = hasApiKey ? null : password,
60114
VerifyTls = verifyTls,
61115
};
62116
}
63117

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

0 commit comments

Comments
 (0)