Skip to content

Commit 01eebb4

Browse files
Read and replace a network as raw JSON (#24)
Adds raw get/replace for networks and NetworkDhcpDns so one setting can change without dropping the rest. Closes #23
1 parent 9940468 commit 01eebb4

7 files changed

Lines changed: 710 additions & 1 deletion

File tree

‎README.md‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,9 @@ With `UNIFI_LOCAL_HOST` alone the site URL is derived as
126126
> port-forward, a firewall group and a VLAN) are gated on *session* auth on purpose,
127127
> so a shell holding only an API key can never point them at a production gateway.
128128
> Read-only live checks (`UnifiLegacyReadOnlyLiveTests`) run in either mode.
129+
> `UnifiLegacyNetworkRawLiveTests` (a raw DHCP-DNS write on a scratch network, then a
130+
> byte-for-byte restore) is stricter still: it needs `UNIFI_LEGACY_BASE_URL` +
131+
> `UNIFI_USERNAME` + `UNIFI_PASSWORD` and **refuses** any host but localhost / 127.0.0.1 / ::1.
129132
130133
### Two surfaces, two sets of rules
131134

@@ -143,6 +146,16 @@ nullable: on v2 a partial is not something you can send, so the type should not
143146
Static-DNS **wildcards are supported** and match arbitrary labels
144147
(`*.lab.example.com` answers `anything.lab.example.com`).
145148

149+
**Networks are the exception on the legacy side.** `UnifiNetwork` models about a dozen of
150+
a live network's ~63 fields, so there is no typed network update. Change a network as raw
151+
JSON instead: read it, change named keys, put the whole object back.
152+
153+
```csharp
154+
var net = await legacy.GetNetworkRawAsync(id); // throws unless exactly one row
155+
var dns = NetworkDhcpDns.Read(net); // null = auto
156+
await legacy.ReplaceNetworkRawAsync(id, NetworkDhcpDns.With(net, ["10.20.0.53", "10.20.0.54"]));
157+
```
158+
146159
## Firewall policies (`UnifiSharp.Firewall`)
147160

148161
Zone-based firewall policies (UniFi Network 9+) go through the **official** integration API,
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
using System.Text.Json;
2+
using System.Text.Json.Nodes;
3+
4+
namespace UnifiSharp.Legacy;
5+
6+
/// <summary>
7+
/// The DHCP DNS servers a <c>rest/networkconf</c> object hands out, kept as API knowledge
8+
/// in one place: <c>dhcpd_dns_enabled</c> plus four fixed slots <c>dhcpd_dns_1..4</c>.
9+
/// Works on the raw object from <see cref="UnifiLegacyClient.GetNetworkRawAsync"/> so a
10+
/// change touches these keys and nothing else.
11+
/// </summary>
12+
public static class NetworkDhcpDns
13+
{
14+
public const int MaxServers = 4;
15+
16+
private const string EnabledKey = "dhcpd_dns_enabled";
17+
18+
private static string SlotKey(int slot) => $"dhcpd_dns_{slot}";
19+
20+
/// <summary>
21+
/// The servers handed out, slots 1..4 in order with blanks dropped; or <c>null</c> when
22+
/// <c>dhcpd_dns_enabled</c> is false or absent ("auto": the gateway hands out itself, and
23+
/// any leftover slot values are not live). Throws <see cref="UnifiLegacyException"/> when a
24+
/// key is present but not the shape this reads — unknown is reported, never guessed.
25+
/// </summary>
26+
public static IReadOnlyList<string>? Read(JsonObject network)
27+
{
28+
ArgumentNullException.ThrowIfNull(network);
29+
30+
// Absent is treated as false: without the flag the controller does not apply the
31+
// slots, so "auto" is the live behaviour. (Present-but-not-a-bool throws below.)
32+
if (!network.TryGetPropertyValue(EnabledKey, out var enabled))
33+
{
34+
return null;
35+
}
36+
37+
if (!ReadEnabled(enabled))
38+
{
39+
return null;
40+
}
41+
42+
var servers = new List<string>(MaxServers);
43+
for (var slot = 1; slot <= MaxServers; slot++)
44+
{
45+
// An absent slot carries no server, the same as a blank one; With always writes all four.
46+
if (!network.TryGetPropertyValue(SlotKey(slot), out var value))
47+
{
48+
continue;
49+
}
50+
51+
var server = ReadSlot(slot, value);
52+
if (!string.IsNullOrWhiteSpace(server))
53+
{
54+
servers.Add(server);
55+
}
56+
}
57+
58+
// Enabled with every slot blank comes back as an empty list, not null: it is a
59+
// different live state from "auto", and the caller should see it as such.
60+
return servers;
61+
}
62+
63+
/// <summary>
64+
/// A deep clone of <paramref name="network"/> with <c>dhcpd_dns_enabled</c> true, slots
65+
/// 1..n set to <paramref name="servers"/> in order and the remaining slots blanked to
66+
/// <c>""</c> (blanked, not removed: the PUT is a full replacement and an absent key is not
67+
/// a cleared one). No other key is touched and the input is never mutated.
68+
/// </summary>
69+
/// <exception cref="ArgumentException">0 or more than <see cref="MaxServers"/> servers, or a blank entry.</exception>
70+
/// <exception cref="UnifiLegacyException">A DNS key is present but not the shape <see cref="Read"/> accepts.</exception>
71+
public static JsonObject With(JsonObject network, IReadOnlyList<string> servers)
72+
{
73+
ArgumentNullException.ThrowIfNull(network);
74+
ArgumentNullException.ThrowIfNull(servers);
75+
76+
// Zero would mean "clear DNS", which no declaration should do by accident; reverting to
77+
// auto is a separate, explicit operation this helper does not offer.
78+
if (servers.Count is 0 or > MaxServers)
79+
{
80+
throw new ArgumentException(
81+
$"A network hands out 1 to {MaxServers} DHCP DNS servers; got {servers.Count}.", nameof(servers));
82+
}
83+
84+
if (servers.Any(string.IsNullOrWhiteSpace))
85+
{
86+
throw new ArgumentException("A DHCP DNS server cannot be blank.", nameof(servers));
87+
}
88+
89+
// Same checks as Read, slots included even when disabled: a key shaped differently means
90+
// the API drifted, and overwriting it would hide that rather than report it.
91+
if (network.TryGetPropertyValue(EnabledKey, out var enabled))
92+
{
93+
ReadEnabled(enabled);
94+
}
95+
96+
for (var slot = 1; slot <= MaxServers; slot++)
97+
{
98+
if (network.TryGetPropertyValue(SlotKey(slot), out var value))
99+
{
100+
ReadSlot(slot, value);
101+
}
102+
}
103+
104+
var copy = network.DeepClone().AsObject();
105+
copy[EnabledKey] = true;
106+
for (var slot = 1; slot <= MaxServers; slot++)
107+
{
108+
copy[SlotKey(slot)] = slot <= servers.Count ? servers[slot - 1] : "";
109+
}
110+
111+
return copy;
112+
}
113+
114+
private static bool ReadEnabled(JsonNode? enabled) =>
115+
enabled?.GetValueKind() is JsonValueKind.True or JsonValueKind.False
116+
? enabled.GetValue<bool>()
117+
: throw new UnifiLegacyException($"{EnabledKey} is {Describe(enabled)}, expected a boolean");
118+
119+
private static string ReadSlot(int slot, JsonNode? value) =>
120+
value?.GetValueKind() is JsonValueKind.String
121+
? value.GetValue<string>()
122+
: throw new UnifiLegacyException($"{SlotKey(slot)} is {Describe(value)}, expected a string");
123+
124+
private static string Describe(JsonNode? node) =>
125+
node is null ? "null" : $"{node.GetValueKind()} {node.ToJsonString()}";
126+
}

‎src/UnifiSharp/Legacy/UnifiLegacyClient.cs‎

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using System.Text.Json;
2+
using System.Text.Json.Nodes;
23
using System.Text.Json.Serialization;
34

45
namespace UnifiSharp.Legacy;
@@ -73,6 +74,64 @@ public Task<UnifiNetwork> CreateNetworkAsync(UnifiNetwork spec, CancellationToke
7374
public Task DeleteNetworkAsync(string id, CancellationToken ct = default)
7475
=> DeleteAsync("networkconf", id, ct);
7576

77+
// NOTE there is no UpdateNetworkAsync(UnifiNetwork), deliberately. UnifiNetwork models
78+
// ~12 of a live network's ~63 fields and rest/networkconf/{id} is a full replacement,
79+
// so a typed update would silently reset the rest. Updates go through the raw trio
80+
// below: read the whole object, change named keys (e.g. NetworkDhcpDns.With), put it back.
81+
82+
/// <summary>Every network as the controller sent it — no field dropped, no number re-formatted.</summary>
83+
public Task<IReadOnlyList<JsonObject>> ListNetworksRawAsync(CancellationToken ct = default)
84+
=> ListRawAsync("networkconf", ct);
85+
86+
/// <summary>
87+
/// One network as the controller sent it. Throws unless the controller returns exactly
88+
/// one row — zero or several is not "the" network, and a write built on it would be a guess.
89+
/// </summary>
90+
public async Task<JsonObject> GetNetworkRawAsync(string id, CancellationToken ct = default)
91+
{
92+
ThrowUnlessPlainSegment(id);
93+
using var resp = await _session.SendAsync(
94+
HttpMethod.Get, $"rest/networkconf/{id}", content: null, ct).ConfigureAwait(false);
95+
var rows = RawRows(await ReadEnvelopeAsync<JsonObject?>(resp, "networkconf", ct).ConfigureAwait(false), "networkconf");
96+
return rows.Count == 1
97+
? rows[0]
98+
: throw new UnifiLegacyException($"networkconf/{id}: expected exactly one network, got {rows.Count}");
99+
}
100+
101+
/// <summary>
102+
/// Replace a network wholesale with <paramref name="body"/>, sent verbatim. Pass an object
103+
/// read by <see cref="GetNetworkRawAsync"/> and changed in place of named keys only; the
104+
/// caller's object is not modified. Returns the controller's echo of the network, or a copy
105+
/// of <paramref name="body"/> when the controller answers ok with no row — re-read to confirm.
106+
/// <para>"Verbatim" is per value, not per byte, for strings: numbers keep their original text
107+
/// and keys their order, but a string is re-escaped on write, so <c>"caf\u00e9"</c> read in
108+
/// may go out as <c>"café"</c> (same value, different bytes).</para>
109+
/// </summary>
110+
/// <exception cref="ArgumentException"><paramref name="id"/> is not a plain path segment, or
111+
/// <paramref name="body"/>'s <c>_id</c> is missing, not a string, or not <paramref name="id"/>.</exception>
112+
public async Task<JsonObject> ReplaceNetworkRawAsync(string id, JsonObject body, CancellationToken ct = default)
113+
{
114+
ThrowUnlessPlainSegment(id);
115+
ArgumentNullException.ThrowIfNull(body);
116+
// The PUT replaces the network at {id} with whatever body says, so a body read from
117+
// another network would silently overwrite this one with it. Missing counts as mismatch.
118+
var bodyId = body.TryGetPropertyValue("_id", out var idNode) && idNode?.GetValueKind() is JsonValueKind.String
119+
? idNode.GetValue<string>()
120+
: null;
121+
if (bodyId != id)
122+
{
123+
throw new ArgumentException(
124+
$"body._id is {(bodyId is null ? "missing or not a string" : $"'{bodyId}'")}, expected '{id}'.", nameof(body));
125+
}
126+
127+
// ToJsonString writes parsed numbers from their original text, so 1.50 stays 1.50.
128+
using var resp = await _session.SendAsync(
129+
HttpMethod.Put, $"rest/networkconf/{id}", UnifiLegacySession.JsonBody(body.ToJsonString()), ct).ConfigureAwait(false);
130+
var rows = RawRows(await ReadEnvelopeAsync<JsonObject?>(resp, "networkconf", ct).ConfigureAwait(false), "networkconf");
131+
// Same echo-on-empty as UpdateAsync, but cloned so the caller's object stays theirs.
132+
return rows.Count > 0 ? rows[0] : body.DeepClone().AsObject();
133+
}
134+
76135
// ── Known clients / DHCP reservations (rest/user) ─────────────────────────
77136
// NOTE there is no Delete here, deliberately. Removing a known client discards
78137
// its name and history along with the reservation; retiring a reservation is
@@ -171,6 +230,31 @@ private async Task<IReadOnlyList<T>> ListAsync<T>(string resource, CancellationT
171230
return (await ReadEnvelopeAsync<T>(resp, resource, ct).ConfigureAwait(false)).Data;
172231
}
173232

233+
private async Task<IReadOnlyList<JsonObject>> ListRawAsync(string resource, CancellationToken ct)
234+
{
235+
using var resp = await _session.SendAsync(HttpMethod.Get, $"rest/{resource}", content: null, ct).ConfigureAwait(false);
236+
return RawRows(await ReadEnvelopeAsync<JsonObject?>(resp, resource, ct).ConfigureAwait(false), resource);
237+
}
238+
239+
// A null row is a shape we don't recognise, not an empty object — refuse it rather
240+
// than hand back something a caller could write over a real network. Likewise "data":null:
241+
// the deserializer leaves Data null despite its type, and that is not "no rows".
242+
private static IReadOnlyList<JsonObject> RawRows(UnifiLegacyEnvelope<JsonObject?> env, string resource) =>
243+
(env.Data ?? throw new UnifiLegacyException($"{resource}: response data is null"))
244+
.Select(r => r ?? throw new UnifiLegacyException($"{resource}: response row is null"))
245+
.ToList();
246+
247+
// The id is interpolated into the path, so "a/../x" or "a?b" would address something else.
248+
// Controller ids are hex ObjectIds; anything percent-encoding would change is refused, not escaped.
249+
private static void ThrowUnlessPlainSegment(string id, [System.Runtime.CompilerServices.CallerArgumentExpression(nameof(id))] string? paramName = null)
250+
{
251+
ArgumentException.ThrowIfNullOrEmpty(id, paramName);
252+
if (id is "." or ".." || Uri.EscapeDataString(id) != id)
253+
{
254+
throw new ArgumentException($"'{id}' is not a plain path segment.", paramName);
255+
}
256+
}
257+
174258
private async Task<T> CreateAsync<T>(string resource, T spec, CancellationToken ct)
175259
{
176260
var json = JsonSerializer.Serialize(spec, SerializerOptions);

‎src/UnifiSharp/Legacy/UnifiLegacySession.cs‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,20 +28,29 @@ public sealed class UnifiLegacySession : IDisposable
2828
private bool _loggedIn;
2929

3030
public UnifiLegacySession(UnifiLegacyOptions options)
31+
: this(options, CreateHandler(options)) { }
32+
33+
// Test seam: lets unit tests assert the exact bytes on the wire without a controller.
34+
internal UnifiLegacySession(UnifiLegacyOptions options, HttpMessageHandler handler)
3135
{
3236
ArgumentNullException.ThrowIfNull(options);
3337
options.Validate();
3438
_options = options;
3539
_loginUrl = new Uri(_options.ControllerRoot, "/api/auth/login");
40+
_http = new HttpClient(handler);
41+
}
3642

43+
private static HttpClientHandler CreateHandler(UnifiLegacyOptions options)
44+
{
45+
ArgumentNullException.ThrowIfNull(options);
3746
var handler = new HttpClientHandler { CookieContainer = new CookieContainer(), UseCookies = true };
3847
if (!options.VerifyTls)
3948
{
4049
handler.ServerCertificateCustomValidationCallback =
4150
HttpClientHandler.DangerousAcceptAnyServerCertificateValidator;
4251
}
4352

44-
_http = new HttpClient(handler);
53+
return handler;
4554
}
4655

4756
/// <summary>

‎src/UnifiSharp/UnifiSharp.csproj‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,4 +23,9 @@
2323
<ProjectReference Include="../UnifiSharp.Api/UnifiSharp.Api.csproj" />
2424
</ItemGroup>
2525

26+
<ItemGroup>
27+
<!-- Reaches UnifiLegacySession's handler seam; the public surface stays unchanged. -->
28+
<InternalsVisibleTo Include="UnifiSharp.Tests" />
29+
</ItemGroup>
30+
2631
</Project>

0 commit comments

Comments
 (0)