Skip to content

Commit a523ee5

Browse files
Add static DNS (v2 site API) to the legacy adapter (#18)
Controller-local DNS records were reachable from nothing but curl. Homelab needs them to declare internal names as IaC and to stop LAN access to the Pangolin-fronted zones depending on NAT hairpin. Static DNS lives on the v2 site API, which shares the host and the auth with the legacy surface but none of its conventions: bare JSON arrays instead of the {meta,data} envelope, and updates that are full replacements — a PUT carrying only the changed field is answered 400 Validation failed, where the legacy rest/user path accepts exactly that. Treating them alike compiles and passes every unit test, then fails against a real controller, so the difference is encoded in the types: UnifiStaticDnsRecord is non-nullable throughout and a test pins that every field serializes even at its default. Options gains Site (parsed from the base URL, not assumed) and SiteV2Url; the session gains SendAbsoluteAsync, since a v2 path cannot be reached relative to the legacy base. Auth, TLS and the 401 policy are unchanged and shared. All four verbs were probed against the live controller before any of it was written, and wildcard keys confirmed to match arbitrary labels. Bumps to 0.3.0.
1 parent af05455 commit a523ee5

8 files changed

Lines changed: 252 additions & 4 deletions

File tree

‎README.md‎

Lines changed: 17 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/>write adapter · API-key or session<br/>(port-forwards · firewall · networks · clients)"]
26+
RT --> LEG["🧩 UnifiSharp.Legacy<br/>write adapter · API-key or session<br/>(port-forwards · firewall · networks<br/>clients · static DNS)"]
2727
classDef gen fill:#e0e7ff,stroke:#4f46e5;
2828
class API gen;
2929
```
@@ -128,6 +128,22 @@ With `UNIFI_LOCAL_HOST` alone the site URL is derived as
128128
> so a shell holding only an API key can never point them at a production gateway.
129129
> Read-only live checks (`UnifiLegacyReadOnlyLiveTests`) run in either mode.
130130
131+
### Two surfaces, two sets of rules
132+
133+
The adapter spans the legacy site API **and** the v2 site API, and they do not behave
134+
alike. Assuming otherwise fails only at runtime, against a real controller:
135+
136+
| | legacy `…/api/s/<site>` | v2 `…/v2/api/site/<site>` |
137+
|---|---|---|
138+
| response | `{ "meta": {...}, "data": [...] }` | bare JSON array / object |
139+
| update | **partial** — send only changed fields | **full replacement** — a partial PUT is `400 Validation failed` |
140+
| covers | port-forwards, firewall groups, networks, clients | static DNS |
141+
142+
That is why `UnifiStaticDnsRecord` is non-nullable throughout while the legacy DTOs are
143+
nullable: on v2 a partial is not something you can send, so the type should not suggest it.
144+
Static-DNS **wildcards are supported** and match arbitrary labels
145+
(`*.lab.example.com` answers `anything.lab.example.com`).
146+
131147
## Status
132148

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

‎src/UnifiSharp/Legacy/UnifiLegacyClient.cs‎

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,73 @@ public Task<UnifiUser> UpdateUserAsync(string id, UnifiUser spec, CancellationTo
9696
public Task<UnifiUser> CreateUserAsync(UnifiUser spec, CancellationToken ct = default)
9797
=> CreateAsync("user", spec, ct);
9898

99+
// ── Static DNS (v2 site API: static-dns) ──────────────────────────────────
100+
// A DIFFERENT surface from everything above: bare JSON arrays instead of the
101+
// {meta,data} envelope, and updates are full replacements — a PUT carrying only the
102+
// changed field returns 400 Validation failed, where rest/user accepts exactly that.
103+
// Hence UnifiStaticDnsRecord is non-nullable throughout and Update takes a whole record.
104+
105+
public async Task<IReadOnlyList<UnifiStaticDnsRecord>> ListStaticDnsAsync(CancellationToken ct = default)
106+
{
107+
using var resp = await _session.SendAbsoluteAsync(HttpMethod.Get, StaticDnsUrl(), null, ct).ConfigureAwait(false);
108+
return await ReadV2Async<IReadOnlyList<UnifiStaticDnsRecord>>(resp, "static-dns", ct).ConfigureAwait(false) ?? [];
109+
}
110+
111+
public async Task<UnifiStaticDnsRecord> CreateStaticDnsAsync(UnifiStaticDnsRecord spec, CancellationToken ct = default)
112+
{
113+
var body = UnifiLegacySession.JsonBody(JsonSerializer.Serialize(spec with { Id = null }, SerializerOptions));
114+
using var resp = await _session.SendAbsoluteAsync(HttpMethod.Post, StaticDnsUrl(), body, ct).ConfigureAwait(false);
115+
return await ReadV2Async<UnifiStaticDnsRecord>(resp, "static-dns", ct).ConfigureAwait(false)
116+
?? throw new UnifiLegacyException("create static-dns: ok but no object returned");
117+
}
118+
119+
/// <summary>Replace a record wholesale. Pass the full desired state — partials are rejected.</summary>
120+
public async Task<UnifiStaticDnsRecord> UpdateStaticDnsAsync(string id, UnifiStaticDnsRecord spec, CancellationToken ct = default)
121+
{
122+
ArgumentException.ThrowIfNullOrEmpty(id);
123+
var body = UnifiLegacySession.JsonBody(JsonSerializer.Serialize(spec with { Id = id }, SerializerOptions));
124+
using var resp = await _session.SendAbsoluteAsync(HttpMethod.Put, StaticDnsUrl(id), body, ct).ConfigureAwait(false);
125+
return await ReadV2Async<UnifiStaticDnsRecord>(resp, "static-dns", ct).ConfigureAwait(false) ?? spec;
126+
}
127+
128+
public async Task DeleteStaticDnsAsync(string id, CancellationToken ct = default)
129+
{
130+
ArgumentException.ThrowIfNullOrEmpty(id);
131+
using var resp = await _session.SendAbsoluteAsync(HttpMethod.Delete, StaticDnsUrl(id), null, ct).ConfigureAwait(false);
132+
await ReadV2Async<JsonElement?>(resp, "static-dns", ct).ConfigureAwait(false); // throws on failure
133+
}
134+
135+
private Uri StaticDnsUrl(string? id = null)
136+
{
137+
var b = _session.Options.SiteV2Url.AbsoluteUri.TrimEnd('/');
138+
return new Uri(id is null ? $"{b}/static-dns" : $"{b}/static-dns/{id}");
139+
}
140+
141+
// v2 has no envelope, so success is the status code and the body is the payload
142+
// (empty on DELETE). Surface the server's message on failure rather than a bare code.
143+
private static async Task<T?> ReadV2Async<T>(HttpResponseMessage resp, string resource, CancellationToken ct)
144+
{
145+
var body = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
146+
if (!resp.IsSuccessStatusCode)
147+
{
148+
throw new UnifiLegacyException($"{resource}: HTTP {(int)resp.StatusCode} — {Truncate(body)}");
149+
}
150+
151+
if (string.IsNullOrWhiteSpace(body))
152+
{
153+
return default;
154+
}
155+
156+
try
157+
{
158+
return JsonSerializer.Deserialize<T>(body, SerializerOptions);
159+
}
160+
catch (JsonException ex)
161+
{
162+
throw new UnifiLegacyException($"{resource}: unparseable response — {ex.Message}: {Truncate(body)}");
163+
}
164+
}
165+
99166
// ── Generic REST verbs over the {meta,data} envelope ──────────────────────
100167

101168
private async Task<IReadOnlyList<T>> ListAsync<T>(string resource, CancellationToken ct)

‎src/UnifiSharp/Legacy/UnifiLegacyModels.cs‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,35 @@ public sealed record UnifiUser
136136
[JsonPropertyName("last_ip")] public string? LastIp { get; init; }
137137
}
138138

139+
/// <summary>
140+
/// A controller-local DNS record (<b>v2</b> site API, <c>static-dns</c>) — how a name
141+
/// resolves on the LAN without any public zone being involved.
142+
/// <para><b>The v2 surface does not behave like the legacy one.</b> It returns a bare JSON
143+
/// array rather than a <c>{meta,data}</c> envelope, and an update is a FULL REPLACEMENT —
144+
/// a PUT carrying only the changed field is rejected with
145+
/// <c>400 Validation failed</c>, where the legacy <c>rest/user</c> path accepts exactly
146+
/// that. So every property here is non-nullable with a sane default: a partial is not a
147+
/// thing you can send, and the type should not imply otherwise.</para>
148+
/// <para>Wildcards are supported and work for arbitrary labels — <c>*.lab.chrison.dev</c>
149+
/// resolves <c>anything.lab.chrison.dev</c>.</para>
150+
/// </summary>
151+
public sealed record UnifiStaticDnsRecord
152+
{
153+
[JsonPropertyName("_id")] public string? Id { get; init; }
154+
/// <summary>The name, e.g. <c>pulse.lab.chrison.dev</c> or <c>*.lab.chrison.dev</c>.</summary>
155+
[JsonPropertyName("key")] public string Key { get; init; } = "";
156+
/// <summary><c>A</c>, <c>AAAA</c>, <c>CNAME</c>, <c>TXT</c>, <c>MX</c>, <c>SRV</c>.</summary>
157+
[JsonPropertyName("record_type")] public string RecordType { get; init; } = "A";
158+
/// <summary>The answer — an address for A/AAAA, a target for CNAME.</summary>
159+
[JsonPropertyName("value")] public string Value { get; init; } = "";
160+
[JsonPropertyName("enabled")] public bool Enabled { get; init; } = true;
161+
[JsonPropertyName("ttl")] public int Ttl { get; init; } = 300;
162+
/// <summary>SRV only; the controller stores 0 for every other type.</summary>
163+
[JsonPropertyName("port")] public int Port { get; init; }
164+
[JsonPropertyName("priority")] public int Priority { get; init; }
165+
[JsonPropertyName("weight")] public int Weight { get; init; }
166+
}
167+
139168
/// <summary>
140169
/// A network / VLAN (<c>rest/networkconf</c>). Only the commonly-managed fields
141170
/// are typed; the server fills the rest with defaults on create.

‎src/UnifiSharp/Legacy/UnifiLegacyOptions.cs‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,28 @@ public sealed record UnifiLegacyOptions
5656
public static Uri SiteUrlFor(string host, string site = "default") =>
5757
new($"https://{host}/proxy/network/api/s/{site}");
5858

59+
/// <summary>
60+
/// The site name parsed out of <see cref="BaseUrl"/> — the last path segment of
61+
/// <c>…/api/s/&lt;site&gt;</c>. Falls back to <c>default</c>.
62+
/// </summary>
63+
public string Site
64+
{
65+
get
66+
{
67+
var seg = BaseUrl.Segments.LastOrDefault()?.Trim('/');
68+
return string.IsNullOrEmpty(seg) ? "default" : seg;
69+
}
70+
}
71+
72+
/// <summary>
73+
/// Base URL of the controller's <b>v2 site API</b>
74+
/// (<c>…/proxy/network/v2/api/site/&lt;site&gt;</c>) — a different surface from the
75+
/// legacy <c>/api/s/&lt;site&gt;</c> one, with different conventions: it returns bare
76+
/// JSON arrays rather than the <c>{meta,data}</c> envelope, and its updates are full
77+
/// replacements rather than partials. Static DNS lives here.
78+
/// </summary>
79+
public Uri SiteV2Url => new(ControllerRoot, $"/proxy/network/v2/api/site/{Site}");
80+
5981
/// <summary>
6082
/// Throw unless one auth mode is fully configured. The session calls this on
6183
/// construction so a half-configured options object fails loudly and early

‎src/UnifiSharp/Legacy/UnifiLegacySession.cs‎

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ namespace UnifiSharp.Legacy;
1919
public sealed class UnifiLegacySession : IDisposable
2020
{
2121
private readonly UnifiLegacyOptions _options;
22+
23+
/// <summary>The options this session was built from — callers need <see cref="UnifiLegacyOptions.SiteV2Url"/>.</summary>
24+
public UnifiLegacyOptions Options => _options;
2225
private readonly HttpClient _http;
2326
private readonly Uri _loginUrl;
2427
private string? _csrfToken;
@@ -96,13 +99,43 @@ public async Task<HttpResponseMessage> SendAsync(
9699
return resp;
97100
}
98101

99-
private async Task<HttpResponseMessage> SendOnceAsync(
102+
/// <summary>
103+
/// Send to an ABSOLUTE url on the same controller, reusing this session's auth. The
104+
/// v2 site API (<c>…/proxy/network/v2/api/site/&lt;site&gt;</c>) is not under the legacy
105+
/// site base, so it cannot be reached with a relative path.
106+
/// </summary>
107+
public async Task<HttpResponseMessage> SendAbsoluteAsync(
108+
HttpMethod method, Uri url, HttpContent? content, CancellationToken ct = default)
109+
{
110+
if (!_loggedIn)
111+
{
112+
await LoginAsync(ct).ConfigureAwait(false);
113+
}
114+
115+
var resp = await SendOnceAsync(method, url, content, ct).ConfigureAwait(false);
116+
if (resp.StatusCode == HttpStatusCode.Unauthorized && !_options.UsesApiKey)
117+
{
118+
resp.Dispose();
119+
await LoginAsync(ct).ConfigureAwait(false);
120+
resp = await SendOnceAsync(method, url, content, ct).ConfigureAwait(false);
121+
}
122+
123+
return resp;
124+
}
125+
126+
private Task<HttpResponseMessage> SendOnceAsync(
100127
HttpMethod method, string relativePath, HttpContent? content, CancellationToken ct)
101128
{
102129
// BaseUrl is the site base (…/api/s/<site>); ensure a trailing slash so the
103130
// relative path appends rather than replaces the last segment.
104131
var baseWithSlash = _options.BaseUrl.AbsoluteUri.TrimEnd('/') + "/";
105-
using var req = new HttpRequestMessage(method, new Uri(new Uri(baseWithSlash), relativePath));
132+
return SendOnceAsync(method, new Uri(new Uri(baseWithSlash), relativePath), content, ct);
133+
}
134+
135+
private async Task<HttpResponseMessage> SendOnceAsync(
136+
HttpMethod method, Uri url, HttpContent? content, CancellationToken ct)
137+
{
138+
using var req = new HttpRequestMessage(method, url);
106139
if (content is not null)
107140
{
108141
req.Content = content;

‎src/UnifiSharp/UnifiSharp.csproj‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
<Nullable>enable</Nullable>
77
<!-- Independent SemVer for the hand-written surface; decoupled from the
88
UniFi API version (UnifiSharp.Api). -->
9-
<VersionPrefix>0.2.0</VersionPrefix>
9+
<VersionPrefix>0.3.0</VersionPrefix>
1010
<!-- Public nuget.org ID uses the reserved Chrison.* prefix (the bare
1111
'UnifiSharp' ID is taken on nuget.org by an unrelated project).
1212
AssemblyName/RootNamespace stay 'UnifiSharp' (project name) so consumer

‎tests/UnifiSharp.Tests/UnifiLegacyClientTests.cs‎

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,71 @@ public void Network_vlan_still_serializes_as_a_string()
136136
Assert.Contains("\"vlan\":\"3990\"", JsonSerializer.Serialize(new UnifiNetwork { Vlan = "3990" }, Json));
137137
}
138138

139+
// ---- v2 site API / static DNS ----
140+
141+
[Fact]
142+
public void SiteV2Url_is_derived_from_the_legacy_base()
143+
{
144+
// Different surface, same controller: …/api/s/<site> -> …/v2/api/site/<site>.
145+
Assert.Equal(
146+
new Uri("https://gw.lan:11443/proxy/network/v2/api/site/default"),
147+
Options().SiteV2Url);
148+
Assert.Equal("default", Options().Site);
149+
}
150+
151+
[Fact]
152+
public void Site_is_parsed_from_the_base_url_not_assumed()
153+
{
154+
var o = Options("https://gw.lan/proxy/network/api/s/otherplace");
155+
Assert.Equal("otherplace", o.Site);
156+
Assert.Equal(new Uri("https://gw.lan/proxy/network/v2/api/site/otherplace"), o.SiteV2Url);
157+
}
158+
159+
[Fact]
160+
public void StaticDns_round_trips_the_controller_shape()
161+
{
162+
// Captured from a real POST response — note v2 returns a BARE object, no envelope.
163+
const string body =
164+
"""
165+
{ "_id": "6a2e", "enabled": true, "key": "*.topaz.local.dev", "port": 0,
166+
"priority": 0, "record_type": "A", "ttl": 300, "value": "10.50.0.10", "weight": 0 }
167+
""";
168+
169+
var r = JsonSerializer.Deserialize<UnifiStaticDnsRecord>(body, Json)!;
170+
171+
Assert.Equal("*.topaz.local.dev", r.Key); // wildcards are a supported key
172+
Assert.Equal("A", r.RecordType);
173+
Assert.Equal("10.50.0.10", r.Value);
174+
Assert.Equal(300, r.Ttl);
175+
Assert.True(r.Enabled);
176+
}
177+
178+
[Fact]
179+
public void StaticDns_list_deserializes_a_bare_array()
180+
{
181+
// The legacy surface wraps everything in {meta,data}; v2 does not. Getting this
182+
// wrong fails at runtime only, against a real controller.
183+
const string body = """[{ "key": "a.example", "value": "10.0.0.1", "record_type": "A" }]""";
184+
var rows = JsonSerializer.Deserialize<IReadOnlyList<UnifiStaticDnsRecord>>(body, Json)!;
185+
Assert.Equal("a.example", Assert.Single(rows).Key);
186+
}
187+
188+
[Fact]
189+
public void StaticDns_write_body_is_complete_because_partials_are_rejected()
190+
{
191+
// The v2 endpoint answers a partial PUT with 400 Validation failed, so every field
192+
// must serialize even at its default — the omit-nulls serializer must not thin it out.
193+
var json = JsonSerializer.Serialize(
194+
new UnifiStaticDnsRecord { Key = "*.lab.chrison.dev", Value = "10.10.0.13" }, Json);
195+
196+
foreach (var field in new[] { "key", "value", "record_type", "enabled", "ttl", "port", "priority", "weight" })
197+
{
198+
Assert.Contains($"\"{field}\":", json);
199+
}
200+
201+
Assert.DoesNotContain("\"_id\"", json); // server-assigned; omitted on create
202+
}
203+
139204
// ---- API-key auth mode ----
140205

141206
private static UnifiLegacyOptions KeyOptions() =>

‎tests/UnifiSharp.Tests/UnifiLegacyLiveTests.cs‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,22 @@ public async Task ApiKey_auth_can_read_known_clients_and_their_reservations()
7272
Assert.All(users.Where(u => u.UseFixedIp == true),
7373
u => Assert.False(string.IsNullOrEmpty(u.FixedIp))); // a reservation always carries an address
7474
}
75+
76+
[SkippableFact]
77+
public async Task Static_dns_reads_from_the_v2_site_api()
78+
{
79+
Skip.If(_fixture.Client is null, "No UniFi env — skipping live read test.");
80+
81+
// Exercises the whole v2 path: a different base URL from the legacy surface, a bare
82+
// array instead of {meta,data}, reached through SendAbsoluteAsync.
83+
var records = await _fixture.Client!.ListStaticDnsAsync();
84+
85+
Assert.All(records, r =>
86+
{
87+
Assert.False(string.IsNullOrEmpty(r.Key));
88+
Assert.False(string.IsNullOrEmpty(r.RecordType));
89+
});
90+
}
7591
}
7692

7793
public class UnifiLegacyLiveTests : IClassFixture<LegacyContainerFixture>

0 commit comments

Comments
 (0)