From 0431768fe9ce60a11177ce9d754b4c6dce036e20 Mon Sep 17 00:00:00 2001 From: Chrison Simtian Date: Sun, 16 Aug 2026 21:08:57 +1200 Subject: [PATCH] Add static DNS (v2 site API) to the legacy adapter Controller-local DNS records are how a name resolves on the LAN without any public zone being involved, and they were reachable from nothing but curl. Homelab needs them to declare internal names as IaC (Homelab#314) and to stop LAN access to the Pangolin-fronted zones depending on NAT hairpin (Homelab#419). They live on the v2 site API, which is a different surface from everything the adapter covered so far and does not share its conventions: - responses are bare JSON arrays/objects, not the {meta,data} envelope - an update is a FULL REPLACEMENT; a PUT carrying only the changed field is answered 400 Validation failed, where the legacy rest/user path accepts exactly that - it hangs off /proxy/network/v2/api/site/, outside the legacy site base So UnifiStaticDnsRecord is non-nullable throughout while the legacy DTOs are nullable: on v2 a partial is not something you can send, and the type should not imply it is. Every field serializes even at its default, which a test pins. UnifiLegacyOptions gains Site (parsed from the base URL rather than assumed) and SiteV2Url; UnifiLegacySession gains SendAbsoluteAsync, since a v2 path cannot be reached relative to the legacy base. Auth, TLS handling and the 401 policy are unchanged and shared. Verified against the live controller before writing any of it: POST returns the created record with _id at status 200, PUT with a full body replaces, PUT with a partial is rejected, DELETE returns 200 with an empty body. Wildcard keys are supported and match arbitrary labels. A read-only live test covers the v2 path end to end. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 18 ++++- src/UnifiSharp/Legacy/UnifiLegacyClient.cs | 67 +++++++++++++++++++ src/UnifiSharp/Legacy/UnifiLegacyModels.cs | 29 ++++++++ src/UnifiSharp/Legacy/UnifiLegacyOptions.cs | 22 ++++++ src/UnifiSharp/Legacy/UnifiLegacySession.cs | 37 +++++++++- src/UnifiSharp/UnifiSharp.csproj | 2 +- .../UnifiLegacyClientTests.cs | 65 ++++++++++++++++++ .../UnifiSharp.Tests/UnifiLegacyLiveTests.cs | 16 +++++ 8 files changed, 252 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 07d7298..444d6a9 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
write adapter · API-key or session
(port-forwards · firewall · networks · clients)"] + RT --> LEG["🧩 UnifiSharp.Legacy
write adapter · API-key or session
(port-forwards · firewall · networks
clients · static DNS)"] classDef gen fill:#e0e7ff,stroke:#4f46e5; class API gen; ``` @@ -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/` | v2 `…/v2/api/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. diff --git a/src/UnifiSharp/Legacy/UnifiLegacyClient.cs b/src/UnifiSharp/Legacy/UnifiLegacyClient.cs index 019892c..2cde8e5 100644 --- a/src/UnifiSharp/Legacy/UnifiLegacyClient.cs +++ b/src/UnifiSharp/Legacy/UnifiLegacyClient.cs @@ -96,6 +96,73 @@ public Task UpdateUserAsync(string id, UnifiUser spec, CancellationTo public Task 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> ListStaticDnsAsync(CancellationToken ct = default) + { + using var resp = await _session.SendAbsoluteAsync(HttpMethod.Get, StaticDnsUrl(), null, ct).ConfigureAwait(false); + return await ReadV2Async>(resp, "static-dns", ct).ConfigureAwait(false) ?? []; + } + + public async Task 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(resp, "static-dns", ct).ConfigureAwait(false) + ?? throw new UnifiLegacyException("create static-dns: ok but no object returned"); + } + + /// Replace a record wholesale. Pass the full desired state — partials are rejected. + public async Task 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(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(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 ReadV2Async(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(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> ListAsync(string resource, CancellationToken ct) diff --git a/src/UnifiSharp/Legacy/UnifiLegacyModels.cs b/src/UnifiSharp/Legacy/UnifiLegacyModels.cs index b50178d..160982a 100644 --- a/src/UnifiSharp/Legacy/UnifiLegacyModels.cs +++ b/src/UnifiSharp/Legacy/UnifiLegacyModels.cs @@ -136,6 +136,35 @@ public sealed record UnifiUser [JsonPropertyName("last_ip")] public string? LastIp { get; init; } } +/// +/// A controller-local DNS record (v2 site API, static-dns) — how a name +/// resolves on the LAN without any public zone being involved. +/// The v2 surface does not behave like the legacy one. It returns a bare JSON +/// array rather than a {meta,data} envelope, and an update is a FULL REPLACEMENT — +/// a PUT carrying only the changed field is rejected with +/// 400 Validation failed, where the legacy rest/user 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. +/// Wildcards are supported and work for arbitrary labels — *.lab.chrison.dev +/// resolves anything.lab.chrison.dev. +/// +public sealed record UnifiStaticDnsRecord +{ + [JsonPropertyName("_id")] public string? Id { get; init; } + /// The name, e.g. pulse.lab.chrison.dev or *.lab.chrison.dev. + [JsonPropertyName("key")] public string Key { get; init; } = ""; + /// A, AAAA, CNAME, TXT, MX, SRV. + [JsonPropertyName("record_type")] public string RecordType { get; init; } = "A"; + /// The answer — an address for A/AAAA, a target for CNAME. + [JsonPropertyName("value")] public string Value { get; init; } = ""; + [JsonPropertyName("enabled")] public bool Enabled { get; init; } = true; + [JsonPropertyName("ttl")] public int Ttl { get; init; } = 300; + /// SRV only; the controller stores 0 for every other type. + [JsonPropertyName("port")] public int Port { get; init; } + [JsonPropertyName("priority")] public int Priority { get; init; } + [JsonPropertyName("weight")] public int Weight { get; init; } +} + /// /// A network / VLAN (rest/networkconf). Only the commonly-managed fields /// are typed; the server fills the rest with defaults on create. diff --git a/src/UnifiSharp/Legacy/UnifiLegacyOptions.cs b/src/UnifiSharp/Legacy/UnifiLegacyOptions.cs index 0af34c6..be5b316 100644 --- a/src/UnifiSharp/Legacy/UnifiLegacyOptions.cs +++ b/src/UnifiSharp/Legacy/UnifiLegacyOptions.cs @@ -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}"); + /// + /// The site name parsed out of — the last path segment of + /// …/api/s/<site>. Falls back to default. + /// + public string Site + { + get + { + var seg = BaseUrl.Segments.LastOrDefault()?.Trim('/'); + return string.IsNullOrEmpty(seg) ? "default" : seg; + } + } + + /// + /// Base URL of the controller's v2 site API + /// (…/proxy/network/v2/api/site/<site>) — a different surface from the + /// legacy /api/s/<site> one, with different conventions: it returns bare + /// JSON arrays rather than the {meta,data} envelope, and its updates are full + /// replacements rather than partials. Static DNS lives here. + /// + public Uri SiteV2Url => new(ControllerRoot, $"/proxy/network/v2/api/site/{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 diff --git a/src/UnifiSharp/Legacy/UnifiLegacySession.cs b/src/UnifiSharp/Legacy/UnifiLegacySession.cs index f2bd248..bafe3f2 100644 --- a/src/UnifiSharp/Legacy/UnifiLegacySession.cs +++ b/src/UnifiSharp/Legacy/UnifiLegacySession.cs @@ -19,6 +19,9 @@ namespace UnifiSharp.Legacy; public sealed class UnifiLegacySession : IDisposable { private readonly UnifiLegacyOptions _options; + + /// The options this session was built from — callers need . + public UnifiLegacyOptions Options => _options; private readonly HttpClient _http; private readonly Uri _loginUrl; private string? _csrfToken; @@ -96,13 +99,43 @@ public async Task SendAsync( return resp; } - private async Task SendOnceAsync( + /// + /// Send to an ABSOLUTE url on the same controller, reusing this session's auth. The + /// v2 site API (…/proxy/network/v2/api/site/<site>) is not under the legacy + /// site base, so it cannot be reached with a relative path. + /// + public async Task 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 SendOnceAsync( HttpMethod method, string relativePath, HttpContent? content, CancellationToken ct) { // BaseUrl is the site base (…/api/s/); 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 SendOnceAsync( + HttpMethod method, Uri url, HttpContent? content, CancellationToken ct) + { + using var req = new HttpRequestMessage(method, url); if (content is not null) { req.Content = content; diff --git a/src/UnifiSharp/UnifiSharp.csproj b/src/UnifiSharp/UnifiSharp.csproj index 1c44312..ed1832a 100644 --- a/src/UnifiSharp/UnifiSharp.csproj +++ b/src/UnifiSharp/UnifiSharp.csproj @@ -6,7 +6,7 @@ enable - 0.2.0 + 0.3.0