Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -143,6 +146,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,
Expand Down
126 changes: 126 additions & 0 deletions src/UnifiSharp/Legacy/NetworkDhcpDns.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
using System.Text.Json;
using System.Text.Json.Nodes;

namespace UnifiSharp.Legacy;

/// <summary>
/// The DHCP DNS servers a <c>rest/networkconf</c> object hands out, kept as API knowledge
/// in one place: <c>dhcpd_dns_enabled</c> plus four fixed slots <c>dhcpd_dns_1..4</c>.
/// Works on the raw object from <see cref="UnifiLegacyClient.GetNetworkRawAsync"/> so a
/// change touches these keys and nothing else.
/// </summary>
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}";

/// <summary>
/// The servers handed out, slots 1..4 in order with blanks dropped; or <c>null</c> when
/// <c>dhcpd_dns_enabled</c> is false or absent ("auto": the gateway hands out itself, and
/// any leftover slot values are not live). Throws <see cref="UnifiLegacyException"/> when a
/// key is present but not the shape this reads — unknown is reported, never guessed.
/// </summary>
public static IReadOnlyList<string>? 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 (!ReadEnabled(enabled))
{
return null;
}

var servers = new List<string>(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;
}

var server = ReadSlot(slot, value);
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;
}

/// <summary>
/// A deep clone of <paramref name="network"/> with <c>dhcpd_dns_enabled</c> true, slots
/// 1..n set to <paramref name="servers"/> in order and the remaining slots blanked to
/// <c>""</c> (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.
/// </summary>
/// <exception cref="ArgumentException">0 or more than <see cref="MaxServers"/> servers, or a blank entry.</exception>
/// <exception cref="UnifiLegacyException">A DNS key is present but not the shape <see cref="Read"/> accepts.</exception>
public static JsonObject With(JsonObject network, IReadOnlyList<string> 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));
}

// 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++)
{
copy[SlotKey(slot)] = slot <= servers.Count ? servers[slot - 1] : "";
}

return copy;
}

private static bool ReadEnabled(JsonNode? enabled) =>
enabled?.GetValueKind() is JsonValueKind.True or JsonValueKind.False
? enabled.GetValue<bool>()
: 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<string>()
: 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()}";
}
84 changes: 84 additions & 0 deletions src/UnifiSharp/Legacy/UnifiLegacyClient.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;

namespace UnifiSharp.Legacy;
Expand Down Expand Up @@ -73,6 +74,64 @@ public Task<UnifiNetwork> 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.

/// <summary>Every network as the controller sent it — no field dropped, no number re-formatted.</summary>
public Task<IReadOnlyList<JsonObject>> ListNetworksRawAsync(CancellationToken ct = default)
=> ListRawAsync("networkconf", ct);

/// <summary>
/// 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.
/// </summary>
public async Task<JsonObject> GetNetworkRawAsync(string id, CancellationToken ct = default)
{
ThrowUnlessPlainSegment(id);
using var resp = await _session.SendAsync(
HttpMethod.Get, $"rest/networkconf/{id}", content: null, ct).ConfigureAwait(false);
var rows = RawRows(await ReadEnvelopeAsync<JsonObject?>(resp, "networkconf", ct).ConfigureAwait(false), "networkconf");
return rows.Count == 1
? rows[0]
: throw new UnifiLegacyException($"networkconf/{id}: expected exactly one network, got {rows.Count}");
}

/// <summary>
/// Replace a network wholesale with <paramref name="body"/>, sent verbatim. Pass an object
/// read by <see cref="GetNetworkRawAsync"/> 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 <paramref name="body"/> when the controller answers ok with no row — re-read to confirm.
/// <para>"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 <c>"caf\u00e9"</c> read in
/// may go out as <c>"café"</c> (same value, different bytes).</para>
/// </summary>
/// <exception cref="ArgumentException"><paramref name="id"/> is not a plain path segment, or
/// <paramref name="body"/>'s <c>_id</c> is missing, not a string, or not <paramref name="id"/>.</exception>
public async Task<JsonObject> ReplaceNetworkRawAsync(string id, JsonObject body, CancellationToken ct = default)
{
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<string>()
: 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);
var rows = RawRows(await ReadEnvelopeAsync<JsonObject?>(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
Expand Down Expand Up @@ -171,6 +230,31 @@ private async Task<IReadOnlyList<T>> ListAsync<T>(string resource, CancellationT
return (await ReadEnvelopeAsync<T>(resp, resource, ct).ConfigureAwait(false)).Data;
}

private async Task<IReadOnlyList<JsonObject>> ListRawAsync(string resource, CancellationToken ct)
{
using var resp = await _session.SendAsync(HttpMethod.Get, $"rest/{resource}", content: null, ct).ConfigureAwait(false);
return RawRows(await ReadEnvelopeAsync<JsonObject?>(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. Likewise "data":null:
// the deserializer leaves Data null despite its type, and that is not "no rows".
private static IReadOnlyList<JsonObject> RawRows(UnifiLegacyEnvelope<JsonObject?> env, string resource) =>
(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<T> CreateAsync<T>(string resource, T spec, CancellationToken ct)
{
var json = JsonSerializer.Serialize(spec, SerializerOptions);
Expand Down
11 changes: 10 additions & 1 deletion src/UnifiSharp/Legacy/UnifiLegacySession.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,20 +28,29 @@ 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)
{
handler.ServerCertificateCustomValidationCallback =
HttpClientHandler.DangerousAcceptAnyServerCertificateValidator;
}

_http = new HttpClient(handler);
return handler;
}

/// <summary>
Expand Down
5 changes: 5 additions & 0 deletions src/UnifiSharp/UnifiSharp.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,9 @@
<ProjectReference Include="../UnifiSharp.Api/UnifiSharp.Api.csproj" />
</ItemGroup>

<ItemGroup>
<!-- Reaches UnifiLegacySession's handler seam; the public surface stays unchanged. -->
<InternalsVisibleTo Include="UnifiSharp.Tests" />
</ItemGroup>

</Project>
Loading
Loading