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