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
18 changes: 17 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/>write adapter · API-key or session<br/>(port-forwards · firewall · networks · clients)"]
RT --> LEG["🧩 UnifiSharp.Legacy<br/>write adapter · API-key or session<br/>(port-forwards · firewall · networks<br/>clients · static DNS)"]
classDef gen fill:#e0e7ff,stroke:#4f46e5;
class API gen;
```
Expand Down Expand Up @@ -128,6 +128,22 @@ With `UNIFI_LOCAL_HOST` alone the site URL is derived as
> 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.

### Two surfaces, two sets of rules

The adapter spans the legacy site API **and** the v2 site API, and they do not behave
alike. Assuming otherwise fails only at runtime, against a real controller:

| | legacy `…/api/s/<site>` | v2 `…/v2/api/site/<site>` |
|---|---|---|
| response | `{ "meta": {...}, "data": [...] }` | bare JSON array / object |
| update | **partial** — send only changed fields | **full replacement** — a partial PUT is `400 Validation failed` |
| covers | port-forwards, firewall groups, networks, clients | static DNS |

That is why `UnifiStaticDnsRecord` is non-nullable throughout while the legacy DTOs are
nullable: on v2 a partial is not something you can send, so the type should not suggest it.
Static-DNS **wildcards are supported** and match arbitrary labels
(`*.lab.example.com` answers `anything.lab.example.com`).

## Status

Read client + `discover` + `unifisharp` CLI building from the 10.4.57 spec.
Expand Down
67 changes: 67 additions & 0 deletions src/UnifiSharp/Legacy/UnifiLegacyClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,73 @@ public Task<UnifiUser> UpdateUserAsync(string id, UnifiUser spec, CancellationTo
public Task<UnifiUser> CreateUserAsync(UnifiUser spec, CancellationToken ct = default)
=> CreateAsync("user", spec, ct);

// ── Static DNS (v2 site API: static-dns) ──────────────────────────────────
// A DIFFERENT surface from everything above: bare JSON arrays instead of the
// {meta,data} envelope, and updates are full replacements — a PUT carrying only the
// changed field returns 400 Validation failed, where rest/user accepts exactly that.
// Hence UnifiStaticDnsRecord is non-nullable throughout and Update takes a whole record.

public async Task<IReadOnlyList<UnifiStaticDnsRecord>> ListStaticDnsAsync(CancellationToken ct = default)
{
using var resp = await _session.SendAbsoluteAsync(HttpMethod.Get, StaticDnsUrl(), null, ct).ConfigureAwait(false);
return await ReadV2Async<IReadOnlyList<UnifiStaticDnsRecord>>(resp, "static-dns", ct).ConfigureAwait(false) ?? [];
}

public async Task<UnifiStaticDnsRecord> CreateStaticDnsAsync(UnifiStaticDnsRecord spec, CancellationToken ct = default)
{
var body = UnifiLegacySession.JsonBody(JsonSerializer.Serialize(spec with { Id = null }, SerializerOptions));
using var resp = await _session.SendAbsoluteAsync(HttpMethod.Post, StaticDnsUrl(), body, ct).ConfigureAwait(false);
return await ReadV2Async<UnifiStaticDnsRecord>(resp, "static-dns", ct).ConfigureAwait(false)
?? throw new UnifiLegacyException("create static-dns: ok but no object returned");
}

/// <summary>Replace a record wholesale. Pass the full desired state — partials are rejected.</summary>
public async Task<UnifiStaticDnsRecord> UpdateStaticDnsAsync(string id, UnifiStaticDnsRecord spec, CancellationToken ct = default)
{
ArgumentException.ThrowIfNullOrEmpty(id);
var body = UnifiLegacySession.JsonBody(JsonSerializer.Serialize(spec with { Id = id }, SerializerOptions));
using var resp = await _session.SendAbsoluteAsync(HttpMethod.Put, StaticDnsUrl(id), body, ct).ConfigureAwait(false);
return await ReadV2Async<UnifiStaticDnsRecord>(resp, "static-dns", ct).ConfigureAwait(false) ?? spec;
}

public async Task DeleteStaticDnsAsync(string id, CancellationToken ct = default)
{
ArgumentException.ThrowIfNullOrEmpty(id);
using var resp = await _session.SendAbsoluteAsync(HttpMethod.Delete, StaticDnsUrl(id), null, ct).ConfigureAwait(false);
await ReadV2Async<JsonElement?>(resp, "static-dns", ct).ConfigureAwait(false); // throws on failure
}

private Uri StaticDnsUrl(string? id = null)
{
var b = _session.Options.SiteV2Url.AbsoluteUri.TrimEnd('/');
return new Uri(id is null ? $"{b}/static-dns" : $"{b}/static-dns/{id}");
}

// v2 has no envelope, so success is the status code and the body is the payload
// (empty on DELETE). Surface the server's message on failure rather than a bare code.
private static async Task<T?> ReadV2Async<T>(HttpResponseMessage resp, string resource, CancellationToken ct)
{
var body = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
if (!resp.IsSuccessStatusCode)
{
throw new UnifiLegacyException($"{resource}: HTTP {(int)resp.StatusCode} — {Truncate(body)}");
}

if (string.IsNullOrWhiteSpace(body))
{
return default;
}

try
{
return JsonSerializer.Deserialize<T>(body, SerializerOptions);
}
catch (JsonException ex)
{
throw new UnifiLegacyException($"{resource}: unparseable response — {ex.Message}: {Truncate(body)}");
}
}

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

private async Task<IReadOnlyList<T>> ListAsync<T>(string resource, CancellationToken ct)
Expand Down
29 changes: 29 additions & 0 deletions src/UnifiSharp/Legacy/UnifiLegacyModels.cs
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,35 @@ public sealed record UnifiUser
[JsonPropertyName("last_ip")] public string? LastIp { get; init; }
}

/// <summary>
/// A controller-local DNS record (<b>v2</b> site API, <c>static-dns</c>) — how a name
/// resolves on the LAN without any public zone being involved.
/// <para><b>The v2 surface does not behave like the legacy one.</b> It returns a bare JSON
/// array rather than a <c>{meta,data}</c> envelope, and an update is a FULL REPLACEMENT —
/// a PUT carrying only the changed field is rejected with
/// <c>400 Validation failed</c>, where the legacy <c>rest/user</c> path accepts exactly
/// that. So every property here is non-nullable with a sane default: a partial is not a
/// thing you can send, and the type should not imply otherwise.</para>
/// <para>Wildcards are supported and work for arbitrary labels — <c>*.lab.chrison.dev</c>
/// resolves <c>anything.lab.chrison.dev</c>.</para>
/// </summary>
public sealed record UnifiStaticDnsRecord
{
[JsonPropertyName("_id")] public string? Id { get; init; }
/// <summary>The name, e.g. <c>pulse.lab.chrison.dev</c> or <c>*.lab.chrison.dev</c>.</summary>
[JsonPropertyName("key")] public string Key { get; init; } = "";
/// <summary><c>A</c>, <c>AAAA</c>, <c>CNAME</c>, <c>TXT</c>, <c>MX</c>, <c>SRV</c>.</summary>
[JsonPropertyName("record_type")] public string RecordType { get; init; } = "A";
/// <summary>The answer — an address for A/AAAA, a target for CNAME.</summary>
[JsonPropertyName("value")] public string Value { get; init; } = "";
[JsonPropertyName("enabled")] public bool Enabled { get; init; } = true;
[JsonPropertyName("ttl")] public int Ttl { get; init; } = 300;
/// <summary>SRV only; the controller stores 0 for every other type.</summary>
[JsonPropertyName("port")] public int Port { get; init; }
[JsonPropertyName("priority")] public int Priority { get; init; }
[JsonPropertyName("weight")] public int Weight { 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 Down
22 changes: 22 additions & 0 deletions src/UnifiSharp/Legacy/UnifiLegacyOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,28 @@ public sealed record UnifiLegacyOptions
public static Uri SiteUrlFor(string host, string site = "default") =>
new($"https://{host}/proxy/network/api/s/{site}");

/// <summary>
/// The site name parsed out of <see cref="BaseUrl"/> — the last path segment of
/// <c>…/api/s/&lt;site&gt;</c>. Falls back to <c>default</c>.
/// </summary>
public string Site
{
get
{
var seg = BaseUrl.Segments.LastOrDefault()?.Trim('/');
return string.IsNullOrEmpty(seg) ? "default" : seg;
}
}

/// <summary>
/// Base URL of the controller's <b>v2 site API</b>
/// (<c>…/proxy/network/v2/api/site/&lt;site&gt;</c>) — a different surface from the
/// legacy <c>/api/s/&lt;site&gt;</c> one, with different conventions: it returns bare
/// JSON arrays rather than the <c>{meta,data}</c> envelope, and its updates are full
/// replacements rather than partials. Static DNS lives here.
/// </summary>
public Uri SiteV2Url => new(ControllerRoot, $"/proxy/network/v2/api/site/{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
Expand Down
37 changes: 35 additions & 2 deletions src/UnifiSharp/Legacy/UnifiLegacySession.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ namespace UnifiSharp.Legacy;
public sealed class UnifiLegacySession : IDisposable
{
private readonly UnifiLegacyOptions _options;

/// <summary>The options this session was built from — callers need <see cref="UnifiLegacyOptions.SiteV2Url"/>.</summary>
public UnifiLegacyOptions Options => _options;
private readonly HttpClient _http;
private readonly Uri _loginUrl;
private string? _csrfToken;
Expand Down Expand Up @@ -96,13 +99,43 @@ public async Task<HttpResponseMessage> SendAsync(
return resp;
}

private async Task<HttpResponseMessage> SendOnceAsync(
/// <summary>
/// Send to an ABSOLUTE url on the same controller, reusing this session's auth. The
/// v2 site API (<c>…/proxy/network/v2/api/site/&lt;site&gt;</c>) is not under the legacy
/// site base, so it cannot be reached with a relative path.
/// </summary>
public async Task<HttpResponseMessage> SendAbsoluteAsync(
HttpMethod method, Uri url, HttpContent? content, CancellationToken ct = default)
{
if (!_loggedIn)
{
await LoginAsync(ct).ConfigureAwait(false);
}

var resp = await SendOnceAsync(method, url, content, ct).ConfigureAwait(false);
if (resp.StatusCode == HttpStatusCode.Unauthorized && !_options.UsesApiKey)
{
resp.Dispose();
await LoginAsync(ct).ConfigureAwait(false);
resp = await SendOnceAsync(method, url, content, ct).ConfigureAwait(false);
}

return resp;
}

private Task<HttpResponseMessage> SendOnceAsync(
HttpMethod method, string relativePath, HttpContent? content, CancellationToken ct)
{
// BaseUrl is the site base (…/api/s/<site>); ensure a trailing slash so the
// relative path appends rather than replaces the last segment.
var baseWithSlash = _options.BaseUrl.AbsoluteUri.TrimEnd('/') + "/";
using var req = new HttpRequestMessage(method, new Uri(new Uri(baseWithSlash), relativePath));
return SendOnceAsync(method, new Uri(new Uri(baseWithSlash), relativePath), content, ct);
}

private async Task<HttpResponseMessage> SendOnceAsync(
HttpMethod method, Uri url, HttpContent? content, CancellationToken ct)
{
using var req = new HttpRequestMessage(method, url);
if (content is not null)
{
req.Content = content;
Expand Down
2 changes: 1 addition & 1 deletion src/UnifiSharp/UnifiSharp.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
<Nullable>enable</Nullable>
<!-- Independent SemVer for the hand-written surface; decoupled from the
UniFi API version (UnifiSharp.Api). -->
<VersionPrefix>0.2.0</VersionPrefix>
<VersionPrefix>0.3.0</VersionPrefix>
<!-- Public nuget.org ID uses the reserved Chrison.* prefix (the bare
'UnifiSharp' ID is taken on nuget.org by an unrelated project).
AssemblyName/RootNamespace stay 'UnifiSharp' (project name) so consumer
Expand Down
65 changes: 65 additions & 0 deletions tests/UnifiSharp.Tests/UnifiLegacyClientTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,71 @@ public void Network_vlan_still_serializes_as_a_string()
Assert.Contains("\"vlan\":\"3990\"", JsonSerializer.Serialize(new UnifiNetwork { Vlan = "3990" }, Json));
}

// ---- v2 site API / static DNS ----

[Fact]
public void SiteV2Url_is_derived_from_the_legacy_base()
{
// Different surface, same controller: …/api/s/<site> -> …/v2/api/site/<site>.
Assert.Equal(
new Uri("https://gw.lan:11443/proxy/network/v2/api/site/default"),
Options().SiteV2Url);
Assert.Equal("default", Options().Site);
}

[Fact]
public void Site_is_parsed_from_the_base_url_not_assumed()
{
var o = Options("https://gw.lan/proxy/network/api/s/otherplace");
Assert.Equal("otherplace", o.Site);
Assert.Equal(new Uri("https://gw.lan/proxy/network/v2/api/site/otherplace"), o.SiteV2Url);
}

[Fact]
public void StaticDns_round_trips_the_controller_shape()
{
// Captured from a real POST response — note v2 returns a BARE object, no envelope.
const string body =
"""
{ "_id": "6a2e", "enabled": true, "key": "*.topaz.local.dev", "port": 0,
"priority": 0, "record_type": "A", "ttl": 300, "value": "10.50.0.10", "weight": 0 }
""";

var r = JsonSerializer.Deserialize<UnifiStaticDnsRecord>(body, Json)!;

Assert.Equal("*.topaz.local.dev", r.Key); // wildcards are a supported key
Assert.Equal("A", r.RecordType);
Assert.Equal("10.50.0.10", r.Value);
Assert.Equal(300, r.Ttl);
Assert.True(r.Enabled);
}

[Fact]
public void StaticDns_list_deserializes_a_bare_array()
{
// The legacy surface wraps everything in {meta,data}; v2 does not. Getting this
// wrong fails at runtime only, against a real controller.
const string body = """[{ "key": "a.example", "value": "10.0.0.1", "record_type": "A" }]""";
var rows = JsonSerializer.Deserialize<IReadOnlyList<UnifiStaticDnsRecord>>(body, Json)!;
Assert.Equal("a.example", Assert.Single(rows).Key);
}

[Fact]
public void StaticDns_write_body_is_complete_because_partials_are_rejected()
{
// The v2 endpoint answers a partial PUT with 400 Validation failed, so every field
// must serialize even at its default — the omit-nulls serializer must not thin it out.
var json = JsonSerializer.Serialize(
new UnifiStaticDnsRecord { Key = "*.lab.chrison.dev", Value = "10.10.0.13" }, Json);

foreach (var field in new[] { "key", "value", "record_type", "enabled", "ttl", "port", "priority", "weight" })
{
Assert.Contains($"\"{field}\":", json);
}

Assert.DoesNotContain("\"_id\"", json); // server-assigned; omitted on create
}

// ---- API-key auth mode ----

private static UnifiLegacyOptions KeyOptions() =>
Expand Down
16 changes: 16 additions & 0 deletions tests/UnifiSharp.Tests/UnifiLegacyLiveTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,22 @@ public async Task ApiKey_auth_can_read_known_clients_and_their_reservations()
Assert.All(users.Where(u => u.UseFixedIp == true),
u => Assert.False(string.IsNullOrEmpty(u.FixedIp))); // a reservation always carries an address
}

[SkippableFact]
public async Task Static_dns_reads_from_the_v2_site_api()
{
Skip.If(_fixture.Client is null, "No UniFi env — skipping live read test.");

// Exercises the whole v2 path: a different base URL from the legacy surface, a bare
// array instead of {meta,data}, reached through SendAbsoluteAsync.
var records = await _fixture.Client!.ListStaticDnsAsync();

Assert.All(records, r =>
{
Assert.False(string.IsNullOrEmpty(r.Key));
Assert.False(string.IsNullOrEmpty(r.RecordType));
});
}
}

public class UnifiLegacyLiveTests : IClassFixture<LegacyContainerFixture>
Expand Down
Loading