From 2a8672135dc1ccb6d35ab7f6504f9d80b2bd3bff Mon Sep 17 00:00:00 2001 From: Chrison Simtian Date: Fri, 23 Oct 2026 22:34:39 +1300 Subject: [PATCH 1/4] Read and replace a network as raw JSON (#23) UnifiNetwork models about 12 of a live network's ~63 fields and rest/networkconf/{id} is a full replacement, so a typed update would reset the rest. Add ListNetworksRawAsync, GetNetworkRawAsync (exactly one row or throw) and ReplaceNetworkRawAsync (body sent verbatim) over JsonObject, and NetworkDhcpDns to read or change only dhcpd_dns_enabled and dhcpd_dns_1..4. There is deliberately no UpdateNetworkAsync(UnifiNetwork). UnifiLegacySession gains an internal handler seam so unit tests can assert the bytes on the wire. --- README.md | 10 + src/UnifiSharp/Legacy/NetworkDhcpDns.cs | 110 +++++++++ src/UnifiSharp/Legacy/UnifiLegacyClient.cs | 55 +++++ src/UnifiSharp/Legacy/UnifiLegacySession.cs | 11 +- src/UnifiSharp/UnifiSharp.csproj | 5 + .../UnifiLegacyClientTests.cs | 223 ++++++++++++++++++ .../UnifiSharp.Tests/UnifiLegacyLiveTests.cs | 15 ++ 7 files changed, 428 insertions(+), 1 deletion(-) create mode 100644 src/UnifiSharp/Legacy/NetworkDhcpDns.cs diff --git a/README.md b/README.md index bc2b39a..40a9504 100644 --- a/README.md +++ b/README.md @@ -143,6 +143,16 @@ nullable: on v2 a partial is not something you can send, so the type should not Static-DNS **wildcards are supported** and match arbitrary labels (`*.lab.example.com` answers `anything.lab.example.com`). +**Networks are the exception on the legacy side.** `UnifiNetwork` models about a dozen of +a live network's ~63 fields, so there is no typed network update. Change a network as raw +JSON instead: read it, change named keys, put the whole object back. + +```csharp +var net = await legacy.GetNetworkRawAsync(id); // throws unless exactly one row +var dns = NetworkDhcpDns.Read(net); // null = auto +await legacy.ReplaceNetworkRawAsync(id, NetworkDhcpDns.With(net, ["10.20.0.53", "10.20.0.54"])); +``` + ## Firewall policies (`UnifiSharp.Firewall`) Zone-based firewall policies (UniFi Network 9+) go through the **official** integration API, diff --git a/src/UnifiSharp/Legacy/NetworkDhcpDns.cs b/src/UnifiSharp/Legacy/NetworkDhcpDns.cs new file mode 100644 index 0000000..8ca9aa3 --- /dev/null +++ b/src/UnifiSharp/Legacy/NetworkDhcpDns.cs @@ -0,0 +1,110 @@ +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace UnifiSharp.Legacy; + +/// +/// The DHCP DNS servers a rest/networkconf object hands out, kept as API knowledge +/// in one place: dhcpd_dns_enabled plus four fixed slots dhcpd_dns_1..4. +/// Works on the raw object from so a +/// change touches these keys and nothing else. +/// +public static class NetworkDhcpDns +{ + public const int MaxServers = 4; + + private const string EnabledKey = "dhcpd_dns_enabled"; + + private static string SlotKey(int slot) => $"dhcpd_dns_{slot}"; + + /// + /// The servers handed out, slots 1..4 in order with blanks dropped; or null when + /// dhcpd_dns_enabled is false or absent ("auto": the gateway hands out itself, and + /// any leftover slot values are not live). Throws when a + /// key is present but not the shape this reads — unknown is reported, never guessed. + /// + public static IReadOnlyList? Read(JsonObject network) + { + ArgumentNullException.ThrowIfNull(network); + + // Absent is treated as false: without the flag the controller does not apply the + // slots, so "auto" is the live behaviour. (Present-but-not-a-bool throws below.) + if (!network.TryGetPropertyValue(EnabledKey, out var enabled)) + { + return null; + } + + if (enabled?.GetValueKind() is not (JsonValueKind.True or JsonValueKind.False)) + { + throw new UnifiLegacyException($"{EnabledKey} is {Describe(enabled)}, expected a boolean"); + } + + if (!enabled.GetValue()) + { + return null; + } + + var servers = new List(MaxServers); + for (var slot = 1; slot <= MaxServers; slot++) + { + // An absent slot carries no server, the same as a blank one; With always writes all four. + if (!network.TryGetPropertyValue(SlotKey(slot), out var value)) + { + continue; + } + + if (value?.GetValueKind() is not JsonValueKind.String) + { + throw new UnifiLegacyException($"{SlotKey(slot)} is {Describe(value)}, expected a string"); + } + + var server = value.GetValue(); + if (!string.IsNullOrWhiteSpace(server)) + { + servers.Add(server); + } + } + + // Enabled with every slot blank comes back as an empty list, not null: it is a + // different live state from "auto", and the caller should see it as such. + return servers; + } + + /// + /// A deep clone of with dhcpd_dns_enabled true, slots + /// 1..n set to in order and the remaining slots blanked to + /// "" (blanked, not removed: the PUT is a full replacement and an absent key is not + /// a cleared one). No other key is touched and the input is never mutated. + /// + /// 0 or more than servers, or a blank entry. + public static JsonObject With(JsonObject network, IReadOnlyList servers) + { + ArgumentNullException.ThrowIfNull(network); + ArgumentNullException.ThrowIfNull(servers); + + // Zero would mean "clear DNS", which no declaration should do by accident; reverting to + // auto is a separate, explicit operation this helper does not offer. + if (servers.Count is 0 or > MaxServers) + { + throw new ArgumentException( + $"A network hands out 1 to {MaxServers} DHCP DNS servers; got {servers.Count}.", nameof(servers)); + } + + if (servers.Any(string.IsNullOrWhiteSpace)) + { + throw new ArgumentException("A DHCP DNS server cannot be blank.", nameof(servers)); + } + + var copy = network.DeepClone().AsObject(); + copy[EnabledKey] = true; + for (var slot = 1; slot <= MaxServers; slot++) + { + copy[SlotKey(slot)] = slot <= servers.Count ? servers[slot - 1] : ""; + } + + return copy; + } + + private static string Describe(JsonNode? node) => + node is null ? "null" : $"{node.GetValueKind()} {node.ToJsonString()}"; +} diff --git a/src/UnifiSharp/Legacy/UnifiLegacyClient.cs b/src/UnifiSharp/Legacy/UnifiLegacyClient.cs index 2cde8e5..cc5f3f6 100644 --- a/src/UnifiSharp/Legacy/UnifiLegacyClient.cs +++ b/src/UnifiSharp/Legacy/UnifiLegacyClient.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using System.Text.Json.Nodes; using System.Text.Json.Serialization; namespace UnifiSharp.Legacy; @@ -73,6 +74,48 @@ public Task CreateNetworkAsync(UnifiNetwork spec, CancellationToke public Task DeleteNetworkAsync(string id, CancellationToken ct = default) => DeleteAsync("networkconf", id, ct); + // NOTE there is no UpdateNetworkAsync(UnifiNetwork), deliberately. UnifiNetwork models + // ~12 of a live network's ~63 fields and rest/networkconf/{id} is a full replacement, + // so a typed update would silently reset the rest. Updates go through the raw trio + // below: read the whole object, change named keys (e.g. NetworkDhcpDns.With), put it back. + + /// Every network as the controller sent it — no field dropped, no number re-formatted. + public Task> ListNetworksRawAsync(CancellationToken ct = default) + => ListRawAsync("networkconf", ct); + + /// + /// One network as the controller sent it. Throws unless the controller returns exactly + /// one row — zero or several is not "the" network, and a write built on it would be a guess. + /// + public async Task GetNetworkRawAsync(string id, CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrEmpty(id); + using var resp = await _session.SendAsync( + HttpMethod.Get, $"rest/networkconf/{id}", content: null, ct).ConfigureAwait(false); + var rows = RawRows(await ReadEnvelopeAsync(resp, "networkconf", ct).ConfigureAwait(false), "networkconf"); + return rows.Count == 1 + ? rows[0] + : throw new UnifiLegacyException($"networkconf/{id}: expected exactly one network, got {rows.Count}"); + } + + /// + /// Replace a network wholesale with , sent verbatim. Pass an object + /// read by and changed in place of named keys only; the + /// caller's object is not modified. Returns the controller's echo of the network, or a copy + /// of when the controller answers ok with no row — re-read to confirm. + /// + public async Task ReplaceNetworkRawAsync(string id, JsonObject body, CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrEmpty(id); + ArgumentNullException.ThrowIfNull(body); + // ToJsonString writes parsed numbers from their original text, so 1.50 stays 1.50. + using var resp = await _session.SendAsync( + HttpMethod.Put, $"rest/networkconf/{id}", UnifiLegacySession.JsonBody(body.ToJsonString()), ct).ConfigureAwait(false); + var rows = RawRows(await ReadEnvelopeAsync(resp, "networkconf", ct).ConfigureAwait(false), "networkconf"); + // Same echo-on-empty as UpdateAsync, but cloned so the caller's object stays theirs. + return rows.Count > 0 ? rows[0] : body.DeepClone().AsObject(); + } + // ── Known clients / DHCP reservations (rest/user) ───────────────────────── // NOTE there is no Delete here, deliberately. Removing a known client discards // its name and history along with the reservation; retiring a reservation is @@ -171,6 +214,18 @@ private async Task> ListAsync(string resource, CancellationT return (await ReadEnvelopeAsync(resp, resource, ct).ConfigureAwait(false)).Data; } + private async Task> ListRawAsync(string resource, CancellationToken ct) + { + using var resp = await _session.SendAsync(HttpMethod.Get, $"rest/{resource}", content: null, ct).ConfigureAwait(false); + return RawRows(await ReadEnvelopeAsync(resp, resource, ct).ConfigureAwait(false), resource); + } + + // A null row is a shape we don't recognise, not an empty object — refuse it rather + // than hand back something a caller could write over a real network. + private static IReadOnlyList RawRows(UnifiLegacyEnvelope env, string resource) => + env.Data.Select(r => r ?? throw new UnifiLegacyException($"{resource}: response row is null")) + .ToList(); + private async Task CreateAsync(string resource, T spec, CancellationToken ct) { var json = JsonSerializer.Serialize(spec, SerializerOptions); diff --git a/src/UnifiSharp/Legacy/UnifiLegacySession.cs b/src/UnifiSharp/Legacy/UnifiLegacySession.cs index bafe3f2..9ae918f 100644 --- a/src/UnifiSharp/Legacy/UnifiLegacySession.cs +++ b/src/UnifiSharp/Legacy/UnifiLegacySession.cs @@ -28,12 +28,21 @@ public sealed class UnifiLegacySession : IDisposable private bool _loggedIn; public UnifiLegacySession(UnifiLegacyOptions options) + : this(options, CreateHandler(options)) { } + + // Test seam: lets unit tests assert the exact bytes on the wire without a controller. + internal UnifiLegacySession(UnifiLegacyOptions options, HttpMessageHandler handler) { ArgumentNullException.ThrowIfNull(options); options.Validate(); _options = options; _loginUrl = new Uri(_options.ControllerRoot, "/api/auth/login"); + _http = new HttpClient(handler); + } + private static HttpClientHandler CreateHandler(UnifiLegacyOptions options) + { + ArgumentNullException.ThrowIfNull(options); var handler = new HttpClientHandler { CookieContainer = new CookieContainer(), UseCookies = true }; if (!options.VerifyTls) { @@ -41,7 +50,7 @@ public UnifiLegacySession(UnifiLegacyOptions options) HttpClientHandler.DangerousAcceptAnyServerCertificateValidator; } - _http = new HttpClient(handler); + return handler; } /// diff --git a/src/UnifiSharp/UnifiSharp.csproj b/src/UnifiSharp/UnifiSharp.csproj index a38358a..397b81b 100644 --- a/src/UnifiSharp/UnifiSharp.csproj +++ b/src/UnifiSharp/UnifiSharp.csproj @@ -23,4 +23,9 @@ + + + + + diff --git a/tests/UnifiSharp.Tests/UnifiLegacyClientTests.cs b/tests/UnifiSharp.Tests/UnifiLegacyClientTests.cs index 34eeb26..413e036 100644 --- a/tests/UnifiSharp.Tests/UnifiLegacyClientTests.cs +++ b/tests/UnifiSharp.Tests/UnifiLegacyClientTests.cs @@ -1,9 +1,13 @@ +using System.Net; using System.Text.Json; +using System.Text.Json.Nodes; using System.Text.Json.Serialization; using UnifiSharp.Legacy; namespace UnifiSharp.Tests; +#pragma warning disable CS0618 // UnifiLegacyClient is intentionally obsolete (ADR-0003) + // Pure unit tests for the legacy write adapter — no controller required. public class UnifiLegacyClientTests { @@ -298,4 +302,223 @@ public void User_envelope_deserializes_reservation_fields() Assert.Equal("10.10.135.221", u.FixedIp); Assert.Equal("shell.devops.chrison.internal", u.LocalDnsRecord); } + + // ---- rest/networkconf as raw JSON — read-modify-write without the lossy model ---- + + // Synthetic, not captured: every JSON kind the controller can send, plus keys no model + // knows about. A number with a trailing zero and an exponent proves number TEXT survives, + // not just the value. + private const string RawNetwork = + """ + { "_id": "net1", "name": "Consumer", "purpose": "corporate", "vlan": 1010, + "vlan_enabled": true, "dhcpd_dns_enabled": true, + "dhcpd_dns_1": "10.20.0.1", "dhcpd_dns_2": "", "dhcpd_dns_3": "", "dhcpd_dns_4": "", + "dhcpd_leasetime": 86400, "ratio": 1.50, "big": 1e3, "nothing": null, + "nat_outbound_ip_addresses": [], "ipv6_ra_preferred_lifetime": 14400, + "dhcp_relay": { "servers": ["10.0.0.1", null], "enabled": false }, + "some_future_field": { "nested": [1, { "deep": true }] } } + """; + + private static string Envelope(string dataArray, string rc = "ok", string? msg = null) => + $$"""{ "meta": { "rc": "{{rc}}"{{(msg is null ? "" : $", \"msg\": \"{msg}\"")}} }, "data": {{dataArray}} }"""; + + [Fact] + public async Task GetNetworkRaw_gets_the_one_row_with_every_key_intact() + { + var handler = new FakeHandler(Envelope($"[{RawNetwork}]")); + using var client = handler.Client(); + + var net = await client.GetNetworkRawAsync("net1"); + + var req = Assert.Single(handler.Requests); + Assert.Equal(HttpMethod.Get, req.Method); + Assert.EndsWith("/api/s/default/rest/networkconf/net1", req.Uri.AbsolutePath); + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(RawNetwork), net)); + Assert.Equal("1.50", net["ratio"]!.ToJsonString()); // number text, not a re-formatted double + Assert.True(net.ContainsKey("nothing")); // an explicit null is a key, not an absence + } + + [Theory] + [InlineData("[]")] + [InlineData("""[{ "_id": "a" }, { "_id": "b" }]""")] + [InlineData("[null]")] + public async Task GetNetworkRaw_throws_unless_exactly_one_row(string data) + { + // Zero rows or two is not "the" network — a read-modify-write on a guess is the bug. + using var client = new FakeHandler(Envelope(data)).Client(); + await Assert.ThrowsAsync(() => client.GetNetworkRawAsync("net1")); + } + + [Fact] + public async Task ListNetworksRaw_returns_every_row_raw() + { + var handler = new FakeHandler(Envelope($$"""[{{RawNetwork}}, { "_id": "net2", "x": 1 }]""")); + using var client = handler.Client(); + + var nets = await client.ListNetworksRawAsync(); + + Assert.EndsWith("/rest/networkconf", Assert.Single(handler.Requests).Uri.AbsolutePath); + Assert.Equal(2, nets.Count); + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(RawNetwork), nets[0])); + } + + [Fact] + public async Task ReplaceNetworkRaw_puts_the_body_verbatim() + { + var handler = new FakeHandler(Envelope($"[{RawNetwork}]")); + using var client = handler.Client(); + var read = await client.GetNetworkRawAsync("net1"); + + await client.ReplaceNetworkRawAsync("net1", read); + + var put = handler.Requests[1]; + Assert.Equal(HttpMethod.Put, put.Method); + Assert.EndsWith("/rest/networkconf/net1", put.Uri.AbsolutePath); + var sent = JsonNode.Parse(put.Body!)!.AsObject(); + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(RawNetwork), sent)); + // Byte-for-byte on numbers: a double round-trip would print 1.5 and 1000. + Assert.Contains("\"ratio\":1.50", put.Body); + Assert.Contains("\"big\":1e3", put.Body); + Assert.Contains("\"vlan\":1010", put.Body); + Assert.Contains("\"nothing\":null", put.Body); + } + + [Fact] + public async Task ReplaceNetworkRaw_does_not_mutate_the_callers_object() + { + using var client = new FakeHandler(Envelope("[]")).Client(); + var body = JsonNode.Parse(RawNetwork)!.AsObject(); + + await client.ReplaceNetworkRawAsync("net1", body); + + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(RawNetwork), body)); + } + + [Theory] + [InlineData("GET")] + [InlineData("PUT")] + public async Task Raw_network_error_envelope_surfaces_the_api_msg(string verb) + { + // The legacy API answers validation failures with HTTP 400 and rc:error. + using var client = new FakeHandler( + Envelope("[]", rc: "error", msg: "api.err.InvalidPayload"), HttpStatusCode.BadRequest).Client(); + + var ex = await Assert.ThrowsAsync(() => verb == "GET" + ? client.GetNetworkRawAsync("net1") + : client.ReplaceNetworkRawAsync("net1", JsonNode.Parse(RawNetwork)!.AsObject())); + + Assert.Contains("api.err.InvalidPayload", ex.Message); + } + + // ---- NetworkDhcpDns — the dhcpd_dns_N naming, kept out of every caller ---- + + private static JsonObject Raw() => JsonNode.Parse(RawNetwork)!.AsObject(); + + [Fact] + public void DhcpDns_With_changes_only_the_dns_keys_and_leaves_the_input_alone() + { + var input = Raw(); + + var output = NetworkDhcpDns.With(input, ["10.20.0.53", "10.20.0.54"]); + + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(RawNetwork), input)); // input untouched + Assert.NotSame(input, output); + + var changed = input.Select(kv => kv.Key) + .Union(output.Select(kv => kv.Key)) + .Where(k => !JsonNode.DeepEquals(input[k], output[k]) || input.ContainsKey(k) != output.ContainsKey(k)) + .ToHashSet(); + // dhcpd_dns_enabled was already true and slots 3-4 already blank, so only 1 and 2 move here; + // the invariant is that nothing OUTSIDE the DNS key set moves. + Assert.Subset( + new HashSet { "dhcpd_dns_enabled", "dhcpd_dns_1", "dhcpd_dns_2", "dhcpd_dns_3", "dhcpd_dns_4" }, + changed); + Assert.Equal("10.20.0.53", (string?)output["dhcpd_dns_1"]); + Assert.Equal("10.20.0.54", (string?)output["dhcpd_dns_2"]); + } + + [Fact] + public void DhcpDns_With_enables_dns_and_writes_all_four_slots_from_auto() + { + var input = JsonNode.Parse("""{ "_id": "n", "dhcpd_dns_enabled": false, "other": 7 }""")!.AsObject(); + + var output = NetworkDhcpDns.With(input, ["10.20.0.53"]); + + Assert.True((bool)output["dhcpd_dns_enabled"]!); + Assert.Equal("10.20.0.53", (string?)output["dhcpd_dns_1"]); + Assert.Equal("", (string?)output["dhcpd_dns_2"]); + Assert.Equal("", (string?)output["dhcpd_dns_3"]); + Assert.Equal("", (string?)output["dhcpd_dns_4"]); + Assert.Equal(7, (int)output["other"]!); + Assert.False((bool)input["dhcpd_dns_enabled"]!); + Assert.False(input.ContainsKey("dhcpd_dns_1")); + } + + [Fact] + public void DhcpDns_With_blanks_a_dropped_slot_rather_than_removing_it() + { + // Removing the key would leave the controller's old value in place on a full PUT. + var input = NetworkDhcpDns.With(Raw(), ["1.1.1.1", "8.8.8.8", "9.9.9.9"]); + + var output = NetworkDhcpDns.With(input, ["1.1.1.1", "8.8.8.8"]); + + Assert.True(output.ContainsKey("dhcpd_dns_3")); + Assert.Equal("", (string?)output["dhcpd_dns_3"]); + Assert.Equal("9.9.9.9", (string?)input["dhcpd_dns_3"]); + } + + [Theory] + [InlineData(0)] + [InlineData(5)] + public void DhcpDns_With_rejects_an_empty_or_oversized_list(int count) + { + // Empty would mean "clear the DNS" — never something a declaration should do silently. + var servers = Enumerable.Range(1, count).Select(i => $"10.0.0.{i}").ToList(); + Assert.Throws(() => NetworkDhcpDns.With(Raw(), servers)); + } + + [Theory] + [InlineData("""{ "dhcpd_dns_enabled": false, "dhcpd_dns_1": "1.1.1.1" }""")] + [InlineData("""{ "dhcpd_dns_1": "1.1.1.1" }""")] + public void DhcpDns_Read_is_null_when_dns_is_automatic(string json) + { + // Disabled means the gateway hands itself out; stale slot values are not the live list. + Assert.Null(NetworkDhcpDns.Read(JsonNode.Parse(json)!.AsObject())); + } + + [Fact] + public void DhcpDns_Read_returns_the_slots_in_order_skipping_blanks() + { + var net = JsonNode.Parse( + """{ "dhcpd_dns_enabled": true, "dhcpd_dns_1": "10.20.0.53", "dhcpd_dns_2": "", "dhcpd_dns_3": "10.20.0.54" }""")! + .AsObject(); + + Assert.Equal(["10.20.0.53", "10.20.0.54"], NetworkDhcpDns.Read(net)); + } + + [Theory] + [InlineData("""{ "dhcpd_dns_enabled": "yes" }""")] + [InlineData("""{ "dhcpd_dns_enabled": true, "dhcpd_dns_1": 10 }""")] + public void DhcpDns_Read_throws_on_a_shape_it_does_not_recognise(string json) + { + // A field present but shaped differently is unknown, not "auto" and not empty. + Assert.Throws(() => NetworkDhcpDns.Read(JsonNode.Parse(json)!.AsObject())); + } + + // Records each request (body read eagerly — the client disposes it) and answers with a + // canned envelope. API-key options, so the session never makes a login request. + private sealed class FakeHandler(string responseBody, HttpStatusCode status = HttpStatusCode.OK) : HttpMessageHandler + { + public List<(HttpMethod Method, Uri Uri, string? Body)> Requests { get; } = []; + + public UnifiLegacyClient Client() => + new(new UnifiLegacySession(KeyOptions(), this), ownsSession: true); + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken ct) + { + var body = request.Content is null ? null : await request.Content.ReadAsStringAsync(ct); + Requests.Add((request.Method, request.RequestUri!, body)); + return new HttpResponseMessage(status) { Content = new StringContent(responseBody) }; + } + } } diff --git a/tests/UnifiSharp.Tests/UnifiLegacyLiveTests.cs b/tests/UnifiSharp.Tests/UnifiLegacyLiveTests.cs index ad048a8..87340dd 100644 --- a/tests/UnifiSharp.Tests/UnifiLegacyLiveTests.cs +++ b/tests/UnifiSharp.Tests/UnifiLegacyLiveTests.cs @@ -88,6 +88,21 @@ public async Task Static_dns_reads_from_the_v2_site_api() Assert.False(string.IsNullOrEmpty(r.RecordType)); }); } + + [SkippableFact] + public async Task Network_reads_raw_and_its_dhcp_dns_is_readable() + { + Skip.If(_fixture.Client is null, "No UniFi env — skipping live read test."); + + // Read only: proves the raw path and the dhcpd_dns_N naming against a real controller. + var networks = await _fixture.Client!.ListNetworksRawAsync(); + Assert.NotEmpty(networks); + var first = networks[0]; + var net = await _fixture.Client.GetNetworkRawAsync((string)first["_id"]!); + + Assert.Equal((string?)first["_id"], (string?)net["_id"]); + _ = NetworkDhcpDns.Read(net); // null (auto) or a list — either way, no throw + } } public class UnifiLegacyLiveTests : IClassFixture From 49b944357bb69a1fdf0a2753e94180abf1e183df Mon Sep 17 00:00:00 2001 From: Chrison Simtian Date: Fri, 23 Oct 2026 22:41:31 +1300 Subject: [PATCH 2/4] Guard raw network replace against a mismatched body, unsafe id and null data --- src/UnifiSharp/Legacy/NetworkDhcpDns.cs | 40 +++++++--- src/UnifiSharp/Legacy/UnifiLegacyClient.cs | 37 ++++++++- .../UnifiLegacyClientTests.cs | 76 +++++++++++++++++++ 3 files changed, 137 insertions(+), 16 deletions(-) diff --git a/src/UnifiSharp/Legacy/NetworkDhcpDns.cs b/src/UnifiSharp/Legacy/NetworkDhcpDns.cs index 8ca9aa3..8646d3f 100644 --- a/src/UnifiSharp/Legacy/NetworkDhcpDns.cs +++ b/src/UnifiSharp/Legacy/NetworkDhcpDns.cs @@ -34,12 +34,7 @@ public static class NetworkDhcpDns return null; } - if (enabled?.GetValueKind() is not (JsonValueKind.True or JsonValueKind.False)) - { - throw new UnifiLegacyException($"{EnabledKey} is {Describe(enabled)}, expected a boolean"); - } - - if (!enabled.GetValue()) + if (!ReadEnabled(enabled)) { return null; } @@ -53,12 +48,7 @@ public static class NetworkDhcpDns continue; } - if (value?.GetValueKind() is not JsonValueKind.String) - { - throw new UnifiLegacyException($"{SlotKey(slot)} is {Describe(value)}, expected a string"); - } - - var server = value.GetValue(); + var server = ReadSlot(slot, value); if (!string.IsNullOrWhiteSpace(server)) { servers.Add(server); @@ -77,6 +67,7 @@ public static class NetworkDhcpDns /// a cleared one). No other key is touched and the input is never mutated. /// /// 0 or more than servers, or a blank entry. + /// A DNS key is present but not the shape accepts. public static JsonObject With(JsonObject network, IReadOnlyList servers) { ArgumentNullException.ThrowIfNull(network); @@ -95,6 +86,21 @@ public static JsonObject With(JsonObject network, IReadOnlyList servers) throw new ArgumentException("A DHCP DNS server cannot be blank.", nameof(servers)); } + // Same checks as Read, slots included even when disabled: a key shaped differently means + // the API drifted, and overwriting it would hide that rather than report it. + if (network.TryGetPropertyValue(EnabledKey, out var enabled)) + { + ReadEnabled(enabled); + } + + for (var slot = 1; slot <= MaxServers; slot++) + { + if (network.TryGetPropertyValue(SlotKey(slot), out var value)) + { + ReadSlot(slot, value); + } + } + var copy = network.DeepClone().AsObject(); copy[EnabledKey] = true; for (var slot = 1; slot <= MaxServers; slot++) @@ -105,6 +111,16 @@ public static JsonObject With(JsonObject network, IReadOnlyList servers) return copy; } + private static bool ReadEnabled(JsonNode? enabled) => + enabled?.GetValueKind() is JsonValueKind.True or JsonValueKind.False + ? enabled.GetValue() + : throw new UnifiLegacyException($"{EnabledKey} is {Describe(enabled)}, expected a boolean"); + + private static string ReadSlot(int slot, JsonNode? value) => + value?.GetValueKind() is JsonValueKind.String + ? value.GetValue() + : throw new UnifiLegacyException($"{SlotKey(slot)} is {Describe(value)}, expected a string"); + private static string Describe(JsonNode? node) => node is null ? "null" : $"{node.GetValueKind()} {node.ToJsonString()}"; } diff --git a/src/UnifiSharp/Legacy/UnifiLegacyClient.cs b/src/UnifiSharp/Legacy/UnifiLegacyClient.cs index cc5f3f6..3f7f977 100644 --- a/src/UnifiSharp/Legacy/UnifiLegacyClient.cs +++ b/src/UnifiSharp/Legacy/UnifiLegacyClient.cs @@ -89,7 +89,7 @@ public Task> ListNetworksRawAsync(CancellationToken ct /// public async Task GetNetworkRawAsync(string id, CancellationToken ct = default) { - ArgumentException.ThrowIfNullOrEmpty(id); + ThrowUnlessPlainSegment(id); using var resp = await _session.SendAsync( HttpMethod.Get, $"rest/networkconf/{id}", content: null, ct).ConfigureAwait(false); var rows = RawRows(await ReadEnvelopeAsync(resp, "networkconf", ct).ConfigureAwait(false), "networkconf"); @@ -103,11 +103,27 @@ public async Task GetNetworkRawAsync(string id, CancellationToken ct /// read by and changed in place of named keys only; the /// caller's object is not modified. Returns the controller's echo of the network, or a copy /// of when the controller answers ok with no row — re-read to confirm. + /// "Verbatim" is per value, not per byte, for strings: numbers keep their original text + /// and keys their order, but a string is re-escaped on write, so "caf\u00e9" read in + /// may go out as "café" (same value, different bytes). /// + /// is not a plain path segment, or + /// 's _id is missing, not a string, or not . public async Task ReplaceNetworkRawAsync(string id, JsonObject body, CancellationToken ct = default) { - ArgumentException.ThrowIfNullOrEmpty(id); + ThrowUnlessPlainSegment(id); ArgumentNullException.ThrowIfNull(body); + // The PUT replaces the network at {id} with whatever body says, so a body read from + // another network would silently overwrite this one with it. Missing counts as mismatch. + var bodyId = body.TryGetPropertyValue("_id", out var idNode) && idNode?.GetValueKind() is JsonValueKind.String + ? idNode.GetValue() + : null; + if (bodyId != id) + { + throw new ArgumentException( + $"body._id is {(bodyId is null ? "missing or not a string" : $"'{bodyId}'")}, expected '{id}'.", nameof(body)); + } + // ToJsonString writes parsed numbers from their original text, so 1.50 stays 1.50. using var resp = await _session.SendAsync( HttpMethod.Put, $"rest/networkconf/{id}", UnifiLegacySession.JsonBody(body.ToJsonString()), ct).ConfigureAwait(false); @@ -221,11 +237,24 @@ private async Task> ListRawAsync(string resource, Canc } // A null row is a shape we don't recognise, not an empty object — refuse it rather - // than hand back something a caller could write over a real network. + // than hand back something a caller could write over a real network. Likewise "data":null: + // the deserializer leaves Data null despite its type, and that is not "no rows". private static IReadOnlyList RawRows(UnifiLegacyEnvelope env, string resource) => - env.Data.Select(r => r ?? throw new UnifiLegacyException($"{resource}: response row is null")) + (env.Data ?? throw new UnifiLegacyException($"{resource}: response data is null")) + .Select(r => r ?? throw new UnifiLegacyException($"{resource}: response row is null")) .ToList(); + // The id is interpolated into the path, so "a/../x" or "a?b" would address something else. + // Controller ids are hex ObjectIds; anything percent-encoding would change is refused, not escaped. + private static void ThrowUnlessPlainSegment(string id, [System.Runtime.CompilerServices.CallerArgumentExpression(nameof(id))] string? paramName = null) + { + ArgumentException.ThrowIfNullOrEmpty(id, paramName); + if (id is "." or ".." || Uri.EscapeDataString(id) != id) + { + throw new ArgumentException($"'{id}' is not a plain path segment.", paramName); + } + } + private async Task CreateAsync(string resource, T spec, CancellationToken ct) { var json = JsonSerializer.Serialize(spec, SerializerOptions); diff --git a/tests/UnifiSharp.Tests/UnifiLegacyClientTests.cs b/tests/UnifiSharp.Tests/UnifiLegacyClientTests.cs index 413e036..196067d 100644 --- a/tests/UnifiSharp.Tests/UnifiLegacyClientTests.cs +++ b/tests/UnifiSharp.Tests/UnifiLegacyClientTests.cs @@ -410,6 +410,70 @@ public async Task Raw_network_error_envelope_surfaces_the_api_msg(string verb) Assert.Contains("api.err.InvalidPayload", ex.Message); } + [Theory] + [InlineData("""{ "_id": "net2", "name": "Other" }""")] + [InlineData("""{ "name": "no id" }""")] + [InlineData("""{ "_id": null }""")] + [InlineData("""{ "_id": 1 }""")] + public async Task ReplaceNetworkRaw_refuses_a_body_that_is_not_the_named_network(string json) + { + // A full replacement of net1 with net2's body would silently turn one network into another. + var handler = new FakeHandler(Envelope("[]")); + using var client = handler.Client(); + + await Assert.ThrowsAsync( + () => client.ReplaceNetworkRawAsync("net1", JsonNode.Parse(json)!.AsObject())); + Assert.Empty(handler.Requests); + } + + [Theory] + [InlineData("a/../x")] + [InlineData("..")] + [InlineData(".")] + [InlineData("a?b=c")] + [InlineData("a#b")] + [InlineData("a%2Fb")] + [InlineData("a b")] + public async Task Raw_network_rejects_an_id_that_is_not_a_plain_path_segment(string id) + { + var handler = new FakeHandler(Envelope($"[{RawNetwork}]")); + using var client = handler.Client(); + var body = JsonNode.Parse(RawNetwork)!.AsObject(); + body["_id"] = id; + + await Assert.ThrowsAsync(() => client.GetNetworkRawAsync(id)); + await Assert.ThrowsAsync(() => client.ReplaceNetworkRawAsync(id, body)); + Assert.Empty(handler.Requests); + } + + [Theory] + [InlineData("""{ "meta": { "rc": "ok" }, "data": null }""")] + public async Task Raw_network_reports_a_null_data_array_as_an_api_shape_error(string response) + { + using var client = new FakeHandler(response).Client(); + + await Assert.ThrowsAsync(() => client.GetNetworkRawAsync("net1")); + await Assert.ThrowsAsync(() => client.ListNetworksRawAsync()); + await Assert.ThrowsAsync( + () => client.ReplaceNetworkRawAsync("net1", JsonNode.Parse(RawNetwork)!.AsObject())); + } + + [Fact] + public async Task ReplaceNetworkRaw_sends_With_output_byte_for_byte() + { + // DeepEquals compares values, so it cannot see number text, key order or a dropped + // null; only the literal wire body can. Expected is RawNetwork minified by hand, with + // the two DNS slots changed in place. + var handler = new FakeHandler(Envelope("[]")); + using var client = handler.Client(); + + await client.ReplaceNetworkRawAsync("net1", NetworkDhcpDns.With(Raw(), ["10.20.0.53", "10.20.0.54"])); + + const string expected = + """{"_id":"net1","name":"Consumer","purpose":"corporate","vlan":1010,"vlan_enabled":true,"dhcpd_dns_enabled":true,"dhcpd_dns_1":"10.20.0.53","dhcpd_dns_2":"10.20.0.54","dhcpd_dns_3":"","dhcpd_dns_4":"","dhcpd_leasetime":86400,"ratio":1.50,"big":1e3,"nothing":null,"nat_outbound_ip_addresses":[],"ipv6_ra_preferred_lifetime":14400,"dhcp_relay":{"servers":["10.0.0.1",null],"enabled":false},"some_future_field":{"nested":[1,{"deep":true}]}}"""; + Assert.Equal(expected, Assert.Single(handler.Requests).Body); + } + // ---- NetworkDhcpDns — the dhcpd_dns_N naming, kept out of every caller ---- private static JsonObject Raw() => JsonNode.Parse(RawNetwork)!.AsObject(); @@ -505,6 +569,18 @@ public void DhcpDns_Read_throws_on_a_shape_it_does_not_recognise(string json) Assert.Throws(() => NetworkDhcpDns.Read(JsonNode.Parse(json)!.AsObject())); } + [Theory] + [InlineData("""{ "dhcpd_dns_enabled": "yes" }""")] + [InlineData("""{ "dhcpd_dns_enabled": null }""")] + [InlineData("""{ "dhcpd_dns_enabled": true, "dhcpd_dns_1": 10 }""")] + [InlineData("""{ "dhcpd_dns_enabled": false, "dhcpd_dns_2": { "ip": "1.1.1.1" } }""")] + public void DhcpDns_With_throws_on_a_shape_it_does_not_recognise(string json) + { + // Overwriting a malformed key would paper over the very drift Read refuses to guess at. + Assert.Throws( + () => NetworkDhcpDns.With(JsonNode.Parse(json)!.AsObject(), ["10.20.0.53"])); + } + // Records each request (body read eagerly — the client disposes it) and answers with a // canned envelope. API-key options, so the session never makes a login request. private sealed class FakeHandler(string responseBody, HttpStatusCode status = HttpStatusCode.OK) : HttpMessageHandler From eb23e710081a30af189e027e6eba0ac895f145c3 Mon Sep 17 00:00:00 2001 From: Chrison Simtian Date: Fri, 23 Oct 2026 22:56:10 +1300 Subject: [PATCH 3/4] Add a local-only live test proving a raw DHCP DNS write is reversible Creates a scratch network, writes DHCP DNS through NetworkDhcpDns.With, asserts only the DNS keys moved, restores the saved object and asserts every key matches its JSON text, and checks no other network changed. Skips without UNIFI_LEGACY_BASE_URL + UNIFI_USERNAME + UNIFI_PASSWORD and refuses any host but localhost / 127.0.0.1 / ::1. --- README.md | 3 + .../UnifiSharp.Tests/UnifiLegacyLiveTests.cs | 148 ++++++++++++++++++ 2 files changed, 151 insertions(+) diff --git a/README.md b/README.md index 40a9504..45b73e9 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,9 @@ With `UNIFI_LOCAL_HOST` alone the site URL is derived as > port-forward, a firewall group and a VLAN) are gated on *session* auth on purpose, > 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. +> `UnifiLegacyNetworkRawLiveTests` (a raw DHCP-DNS write on a scratch network, then a +> byte-for-byte restore) is stricter still: it needs `UNIFI_LEGACY_BASE_URL` + +> `UNIFI_USERNAME` + `UNIFI_PASSWORD` and **refuses** any host but localhost / 127.0.0.1 / ::1. ### Two surfaces, two sets of rules diff --git a/tests/UnifiSharp.Tests/UnifiLegacyLiveTests.cs b/tests/UnifiSharp.Tests/UnifiLegacyLiveTests.cs index 87340dd..8f0937b 100644 --- a/tests/UnifiSharp.Tests/UnifiLegacyLiveTests.cs +++ b/tests/UnifiSharp.Tests/UnifiLegacyLiveTests.cs @@ -1,3 +1,4 @@ +using System.Text.Json.Nodes; using UnifiSharp.Legacy; using Xunit; @@ -183,3 +184,150 @@ public async Task Network_create_delete_roundtrip() await client.DeleteNetworkAsync(created.Id!); } } + +/// +/// Proves a field-scoped raw network write is reversible: setting DHCP DNS touches only the +/// DNS keys, and putting back the saved object restores the network exactly. +/// Stricter gate than : this PUTs over a whole network, +/// so it runs only against a controller on this machine. Missing env skips; an env pointing +/// anywhere else is refused (fails) before any login, never quietly skipped. +/// +public class UnifiLegacyNetworkRawLiveTests +{ + private static readonly string[] Servers = ["10.20.0.53", "10.20.0.54"]; + + private static readonly HashSet DnsKeys = + ["dhcpd_dns_enabled", "dhcpd_dns_1", "dhcpd_dns_2", "dhcpd_dns_3", "dhcpd_dns_4"]; + + // Keys the controller rewrites on its own between reads, excluded from every comparison + // below. Empty until a run shows one — add each by name with the reason it moves, so a + // real difference can never hide here unannounced. + private static readonly HashSet VolatileKeys = []; + + [SkippableFact] + public async Task Network_dhcp_dns_write_is_reversible_byte_for_byte() + { + var baseUrl = Environment.GetEnvironmentVariable("UNIFI_LEGACY_BASE_URL"); + Skip.If( + string.IsNullOrEmpty(baseUrl) + || string.IsNullOrEmpty(Environment.GetEnvironmentVariable("UNIFI_USERNAME")) + || string.IsNullOrEmpty(Environment.GetEnvironmentVariable("UNIFI_PASSWORD")), + "UNIFI_LEGACY_BASE_URL + UNIFI_USERNAME + UNIFI_PASSWORD not all set — skipping local write test."); + + // Check the URL the client will actually use, not just the variable: TryFromEnvironment + // is what resolves it, and it is the one that has to be local. + var options = UnifiLegacyOptions.TryFromEnvironment() + ?? throw new InvalidOperationException("UNIFI_* env is set but did not resolve to legacy options."); + RefuseUnlessLocal(new Uri(baseUrl!)); + RefuseUnlessLocal(options.BaseUrl); + + using var client = new UnifiLegacyClient(options); + + var before = await client.ListNetworksRawAsync(); + var (vlan, octet) = PickUnusedVlanAndSubnet(before); + + var created = await client.CreateNetworkAsync(new UnifiNetwork + { + Name = $"scratch-dhcpdns-{Guid.NewGuid().ToString("N")[..8]}", + Purpose = "corporate", + VlanEnabled = true, + Vlan = vlan.ToString(), + IpSubnet = $"10.232.{octet}.1/24", + DhcpdEnabled = true, + DhcpdStart = $"10.232.{octet}.6", + DhcpdStop = $"10.232.{octet}.254", + }); + Assert.False(string.IsNullOrEmpty(created.Id)); + var id = created.Id!; + + try + { + // Kept as text so nothing below can mutate the baseline. + var saved = (await client.GetNetworkRawAsync(id)).ToJsonString(); + var savedObj = Parse(saved); + + // UnifiNetwork cannot express dhcpd_dns_enabled, so "DHCP DNS off" is the controller's + // create default — asserted, not assumed. + Assert.True(NetworkDhcpDns.Read(savedObj) is null, + $"precondition: a new network should have DHCP DNS off (auto); dhcpd_dns_enabled is {savedObj["dhcpd_dns_enabled"]?.ToJsonString() ?? "absent"}"); + + await client.ReplaceNetworkRawAsync(id, NetworkDhcpDns.With(Parse(saved), Servers)); + var changed = await client.GetNetworkRawAsync(id); + + Assert.Equal(Servers, NetworkDhcpDns.Read(changed)); + AssertSameKeys(savedObj, changed, ignore: DnsKeys, what: "after the DNS write, a non-DNS key"); + Assert.Equal((string?)savedObj["_id"], (string?)changed["_id"]); + Assert.Equal((string?)savedObj["site_id"], (string?)changed["site_id"]); + + await client.ReplaceNetworkRawAsync(id, Parse(saved)); + var restored = await client.GetNetworkRawAsync(id); + + AssertSameKeys(savedObj, restored, ignore: [], what: "after restoring the saved object, key"); + } + finally + { + await client.DeleteNetworkAsync(id); + } + + // Every other network, whole: the scratch network is gone and nothing else moved. + var after = await client.ListNetworksRawAsync(); + var beforeById = ById(before); + var afterById = ById(after); + Assert.Equal(beforeById.Keys.Order(), afterById.Keys.Order()); + foreach (var (otherId, was) in beforeById) + { + AssertSameKeys(was, afterById[otherId], ignore: [], what: $"network {otherId} ('{was["name"]}') changed: key"); + } + } + + private static void RefuseUnlessLocal(Uri url) + { + // Uri.Host lower-cases names and brackets IPv6, so ::1 arrives as "[::1]". + if (url.Host is not ("localhost" or "127.0.0.1" or "[::1]")) + { + throw new InvalidOperationException( + $"Refusing to write networks on {url.Host}: this test only runs against a controller on localhost / 127.0.0.1 / ::1."); + } + } + + private static (int Vlan, int Octet) PickUnusedVlanAndSubnet(IReadOnlyList networks) + { + // node.ToString() gives the bare text for both shapes the vlan arrives in (number or string). + var vlans = networks.Select(n => n["vlan"]?.ToString()).ToHashSet(); + var subnets = networks.Select(n => n["ip_subnet"]?.ToString() ?? "").ToList(); + for (var attempt = 0; attempt < 20; attempt++) + { + var vlan = Random.Shared.Next(3900, 4000); // inside UniFi's valid 1–4009 + var octet = Random.Shared.Next(0, 256); + if (!vlans.Contains(vlan.ToString()) && !subnets.Any(s => s.StartsWith($"10.232.{octet}.", StringComparison.Ordinal))) + { + return (vlan, octet); + } + } + + throw new InvalidOperationException("No free VLAN 3900–3999 / 10.232.x.0/24 found for the scratch network."); + } + + // Compared as each value's JSON text, not semantically: the claim is byte-for-byte, so 1 + // vs 1.0 or a re-ordered nested object is a difference. The union of keys is walked, so a + // key the controller added or dropped is reported too. + private static void AssertSameKeys(JsonObject expected, JsonObject actual, IReadOnlyCollection ignore, string what) + { + foreach (var key in expected.Select(p => p.Key).Union(actual.Select(p => p.Key))) + { + if (ignore.Contains(key) || VolatileKeys.Contains(key)) + { + continue; + } + + var want = expected.TryGetPropertyValue(key, out var e) ? e?.ToJsonString() ?? "null" : ""; + var got = actual.TryGetPropertyValue(key, out var a) ? a?.ToJsonString() ?? "null" : ""; + Assert.True(want == got, $"{what} '{key}' differs: expected {want}, got {got}"); + } + } + + private static Dictionary ById(IReadOnlyList networks) => + networks.ToDictionary(n => (string?)n["_id"] ?? throw new InvalidOperationException($"network without a string _id: {n.ToJsonString()}")); + + private static JsonObject Parse(string json) => JsonNode.Parse(json)!.AsObject(); +} From 82f128e7ee62aeaa51efa33077aa025bdea17420 Mon Sep 17 00:00:00 2001 From: Chrison Simtian Date: Fri, 23 Oct 2026 22:57:16 +1300 Subject: [PATCH 4/4] Make the live DNS write test start from an explicit baseline --- .../UnifiSharp.Tests/UnifiLegacyLiveTests.cs | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/tests/UnifiSharp.Tests/UnifiLegacyLiveTests.cs b/tests/UnifiSharp.Tests/UnifiLegacyLiveTests.cs index 8f0937b..1893a1f 100644 --- a/tests/UnifiSharp.Tests/UnifiLegacyLiveTests.cs +++ b/tests/UnifiSharp.Tests/UnifiLegacyLiveTests.cs @@ -242,14 +242,24 @@ public async Task Network_dhcp_dns_write_is_reversible_byte_for_byte() try { + // Found by this test: the controller treats a PUT as a MERGE for omitted keys. A body + // without dhcpd_dns_enabled does not switch it off again, so "restore" cannot be done by + // replaying a body that lacks the key. Real LAN networks carry the key explicitly + // (dhcpd_dns_enabled=false), so the baseline is made explicit here, as on the live console. + var fresh = await client.GetNetworkRawAsync(id); + Assert.True(NetworkDhcpDns.Read(fresh) is null, + $"precondition: a new network should have DHCP DNS off (auto); dhcpd_dns_enabled is {fresh["dhcpd_dns_enabled"]?.ToJsonString() ?? "absent"}"); + var explicitOff = Parse(fresh.ToJsonString()); + explicitOff["dhcpd_dns_enabled"] = false; + for (var slot = 1; slot <= NetworkDhcpDns.MaxServers; slot++) + explicitOff[$"dhcpd_dns_{slot}"] = ""; // as on Consumer: all four keys present, blank when unused + await client.ReplaceNetworkRawAsync(id, explicitOff); + // Kept as text so nothing below can mutate the baseline. var saved = (await client.GetNetworkRawAsync(id)).ToJsonString(); var savedObj = Parse(saved); - - // UnifiNetwork cannot express dhcpd_dns_enabled, so "DHCP DNS off" is the controller's - // create default — asserted, not assumed. - Assert.True(NetworkDhcpDns.Read(savedObj) is null, - $"precondition: a new network should have DHCP DNS off (auto); dhcpd_dns_enabled is {savedObj["dhcpd_dns_enabled"]?.ToJsonString() ?? "absent"}"); + Assert.True(NetworkDhcpDns.Read(savedObj) is null && (bool?)savedObj["dhcpd_dns_enabled"] == false, + "precondition: the baseline carries dhcpd_dns_enabled=false explicitly"); await client.ReplaceNetworkRawAsync(id, NetworkDhcpDns.With(Parse(saved), Servers)); var changed = await client.GetNetworkRawAsync(id);