diff --git a/README.md b/README.md
index e0b89b9..8f52b5d 100644
--- a/README.md
+++ b/README.md
@@ -143,6 +143,34 @@ 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`).
+## Firewall policies (`UnifiSharp.Firewall`)
+
+Zone-based firewall policies (UniFi Network 9+) go through the **official** integration API,
+`{UNIFI_BASE_URL}/v1/sites/{site}/firewall/policies`, which has full CRUD. They don't use the
+legacy adapter.
+
+```csharp
+using var fw = new UnifiFirewallClient(UnifiClientOptions.TryFromEnvironment()!);
+var names = await fw.GetNamesAsync(); // zone + network name → id
+var spec = new FirewallPolicySpec
+{
+ Name = "qbittorrent-peers-v6",
+ Source = new() { Zone = "External" },
+ Destination = new() { Zone = "Homelab", Ipv6InterfaceId = "::6342:9", Ports = ["63429"] },
+ IpVersion = FirewallIpVersion.IPv6,
+ Protocol = "tcp_udp",
+};
+await fw.CreateAsync(spec, names);
+```
+
+- **`FirewallPolicySpec`** is the supported subset: zones, addresses (IP, CIDR, `a-b` range), networks, an IPv6 interface identifier, ports, IP version, protocol, action, logging and enabled.
+- **Equality is semantic**, so `live == desired` is the drift check.
+- **`FirewallPolicyJson.FromJson`** returns a reason instead of a spec for anything outside the subset: domain/app/region filters, schedules, matching lists and the like. A caller can never mistake "can't compare" for "matches".
+- **`Ipv6InterfaceId`** is written with the `/::ffff:ffff:ffff:ffff` mask, so it matches the host suffix under *any* delegated prefix. Pinning a full IPv6 address breaks the day the ISP re-delegates.
+- **Updates are PUT (full replacement).** Only ever write `USER_DEFINED` policies (`LiveFirewallPolicy.IsUserDefined`).
+- **Ordering is not managed.** New user policies land at index 10000, ahead of the predefined allow/block defaults.
+- Generated against the 10.4.57 spec and checked live against Network 10.6.106.
+
## Status
Read client + `discover` + `unifisharp` CLI building from the 10.4.57 spec.
diff --git a/src/UnifiSharp/Firewall/FirewallPolicyJson.cs b/src/UnifiSharp/Firewall/FirewallPolicyJson.cs
new file mode 100644
index 0000000..86e45b4
--- /dev/null
+++ b/src/UnifiSharp/Firewall/FirewallPolicyJson.cs
@@ -0,0 +1,243 @@
+using System.Text.Json.Nodes;
+
+namespace UnifiSharp.Firewall;
+
+/// Name ↔ id lookups the mapper needs. Built from the zone and network listings.
+public sealed record FirewallNames(
+ IReadOnlyDictionary ZoneIds,
+ IReadOnlyDictionary NetworkIds)
+{
+ internal string ZoneName(Guid id) => ZoneIds.FirstOrDefault(kv => kv.Value == id).Key ?? id.ToString();
+ internal string NetworkName(Guid id) => NetworkIds.FirstOrDefault(kv => kv.Value == id).Key ?? id.ToString();
+
+ internal Guid ZoneId(string name) => ZoneIds.TryGetValue(name, out var id) ? id
+ : throw new ArgumentException($"unknown firewall zone '{name}' (known: {string.Join(", ", ZoneIds.Keys)})");
+
+ internal Guid NetworkId(string name) => NetworkIds.TryGetValue(name, out var id) ? id
+ : throw new ArgumentException($"unknown network '{name}' (known: {string.Join(", ", NetworkIds.Keys)})");
+}
+
+///
+/// Maps to and from the integration API's JSON
+/// (/v1/sites/{site}/firewall/policies). Hand-written rather than via the generated
+/// Kiota models: those fan out into a separate class per IP version × protocol style × filter
+/// kind, and a round-trippable subset is far clearer as the wire shape itself.
+///
+public static class FirewallPolicyJson
+{
+ // The interface-identifier mask: the low 64 bits are the IID.
+ internal const string IidMask = "::ffff:ffff:ffff:ffff";
+
+ public static JsonObject ToJson(FirewallPolicySpec p, FirewallNames names)
+ {
+ var action = new JsonObject { ["type"] = p.Action.ToString().ToUpperInvariant() };
+ if (p.Action == FirewallAction.Allow) action["allowReturnTraffic"] = p.AllowReturnTraffic;
+
+ var scope = new JsonObject
+ {
+ ["ipVersion"] = p.IpVersion switch
+ {
+ FirewallIpVersion.IPv4 => "IPV4",
+ FirewallIpVersion.IPv6 => "IPV6",
+ _ => "IPV4_AND_IPV6",
+ },
+ };
+ var proto = p.Protocol.Trim().ToLowerInvariant();
+ if (proto == "tcp_udp")
+ scope["protocolFilter"] = new JsonObject { ["type"] = "PRESET", ["preset"] = new JsonObject { ["name"] = "TCP_UDP" } };
+ else if (proto != "all")
+ scope["protocolFilter"] = new JsonObject
+ {
+ ["type"] = "NAMED_PROTOCOL",
+ ["protocol"] = new JsonObject { ["name"] = proto.ToUpperInvariant() },
+ ["matchOpposite"] = false,
+ };
+
+ var o = new JsonObject
+ {
+ ["enabled"] = p.Enabled,
+ ["name"] = p.Name,
+ ["action"] = action,
+ ["source"] = Endpoint(p.Source, names),
+ ["destination"] = Endpoint(p.Destination, names),
+ ["ipProtocolScope"] = scope,
+ ["loggingEnabled"] = p.Logging,
+ };
+ if (!string.IsNullOrEmpty(p.Description)) o["description"] = p.Description;
+ return o;
+ }
+
+ private static JsonObject Endpoint(FirewallEndpoint e, FirewallNames names)
+ {
+ var o = new JsonObject { ["zoneId"] = names.ZoneId(e.Zone).ToString() };
+ var narrowings = (e.Addresses.Count > 0 ? 1 : 0) + (e.Networks.Count > 0 ? 1 : 0) + (e.Ipv6InterfaceId is null ? 0 : 1);
+ if (narrowings > 1)
+ throw new ArgumentException($"zone '{e.Zone}': pick ONE of addresses, networks or ipv6InterfaceId");
+
+ JsonObject? f = null;
+ if (e.Addresses.Count > 0)
+ f = new JsonObject
+ {
+ ["type"] = "IP_ADDRESS",
+ ["ipAddressFilter"] = new JsonObject
+ {
+ ["type"] = "IP_ADDRESSES",
+ ["matchOpposite"] = e.MatchOpposite,
+ ["items"] = new JsonArray(e.Addresses.Select(IpItem).ToArray()),
+ },
+ };
+ else if (e.Networks.Count > 0)
+ f = new JsonObject
+ {
+ ["type"] = "NETWORK",
+ ["networkFilter"] = new JsonObject
+ {
+ ["networkIds"] = new JsonArray(e.Networks.Select(n => (JsonNode)names.NetworkId(n).ToString()).ToArray()),
+ ["matchOpposite"] = e.MatchOpposite,
+ },
+ };
+ else if (e.Ipv6InterfaceId is { } iid)
+ f = new JsonObject
+ {
+ ["type"] = "IPV6_IID",
+ ["ipv6IidFilter"] = new JsonObject { ["ipv6Iid"] = $"{iid}/{IidMask}", ["matchOpposite"] = e.MatchOpposite },
+ };
+
+ if (e.Ports.Count > 0)
+ {
+ var ports = new JsonObject
+ {
+ ["type"] = "PORTS",
+ ["matchOpposite"] = e.MatchOppositePorts,
+ ["items"] = new JsonArray(e.Ports.Select(PortItem).ToArray()),
+ };
+ f ??= new JsonObject { ["type"] = "PORT" };
+ f["portFilter"] = ports;
+ }
+ if (f is not null) o["trafficFilter"] = f;
+ return o;
+ }
+
+ private static JsonNode IpItem(string a)
+ {
+ a = a.Trim();
+ if (a.Contains('/')) return new JsonObject { ["type"] = "SUBNET", ["value"] = a };
+ var dash = a.IndexOf('-');
+ if (dash > 0) return new JsonObject { ["type"] = "IP_ADDRESS_RANGE", ["start"] = a[..dash].Trim(), ["stop"] = a[(dash + 1)..].Trim() };
+ return new JsonObject { ["type"] = "IP_ADDRESS", ["value"] = a };
+ }
+
+ private static JsonNode PortItem(string p)
+ {
+ p = p.Trim();
+ var dash = p.IndexOf('-');
+ if (dash > 0)
+ return new JsonObject { ["type"] = "PORT_NUMBER_RANGE", ["start"] = int.Parse(p[..dash]), ["stop"] = int.Parse(p[(dash + 1)..]) };
+ return new JsonObject { ["type"] = "PORT_NUMBER", ["value"] = int.Parse(p) };
+ }
+
+ ///
+ /// Read a live policy back into a spec. Returns Spec = null with a reason when the
+ /// policy uses something outside the supported subset (domain/app/region filters, schedules,
+ /// connection-state or IPsec filters, protocol numbers…), so a caller never mistakes
+ /// "can't compare" for "matches".
+ ///
+ public static (FirewallPolicySpec? Spec, string? Unsupported) FromJson(JsonObject live, FirewallNames names)
+ {
+ foreach (var k in new[] { "schedule", "connectionStateFilter", "ipsecFilter" })
+ if (live[k] is { } n && n.GetValueKind() is not System.Text.Json.JsonValueKind.Null
+ && !(n is JsonArray { Count: 0 }) && !(k == "schedule" && n["mode"]?.GetValue() == "ALWAYS"))
+ return (null, $"uses {k}");
+
+ var actionType = live["action"]?["type"]?.GetValue() ?? "";
+ if (!Enum.TryParse(actionType, ignoreCase: true, out var action)) return (null, $"action {actionType}");
+
+ var scope = live["ipProtocolScope"];
+ var ipVersion = scope?["ipVersion"]?.GetValue() switch
+ {
+ "IPV4" => FirewallIpVersion.IPv4,
+ "IPV6" => FirewallIpVersion.IPv6,
+ _ => FirewallIpVersion.Both,
+ };
+ var protocol = "all";
+ if (scope?["protocolFilter"] is JsonObject pf)
+ {
+ switch (pf["type"]?.GetValue())
+ {
+ case "PRESET": protocol = (pf["preset"]?["name"]?.GetValue() ?? "").ToLowerInvariant(); break;
+ case "NAMED_PROTOCOL" when pf["matchOpposite"]?.GetValue() != true:
+ protocol = (pf["protocol"]?["name"]?.GetValue() ?? "").ToLowerInvariant(); break;
+ default: return (null, $"protocol filter {pf["type"]}");
+ }
+ }
+
+ var (src, srcWhy) = ReadEndpoint(live["source"] as JsonObject, names);
+ if (src is null) return (null, $"source {srcWhy}");
+ var (dst, dstWhy) = ReadEndpoint(live["destination"] as JsonObject, names);
+ if (dst is null) return (null, $"destination {dstWhy}");
+
+ var desc = live["description"]?.GetValue();
+ return (new FirewallPolicySpec
+ {
+ Name = live["name"]?.GetValue() ?? "",
+ Description = string.IsNullOrEmpty(desc) ? null : desc,
+ Enabled = live["enabled"]?.GetValue() ?? true,
+ Action = action,
+ AllowReturnTraffic = action != FirewallAction.Allow || (live["action"]?["allowReturnTraffic"]?.GetValue() ?? false),
+ Source = src,
+ Destination = dst,
+ IpVersion = ipVersion,
+ Protocol = protocol,
+ Logging = live["loggingEnabled"]?.GetValue() ?? false,
+ }, null);
+ }
+
+ private static (FirewallEndpoint? E, string? Why) ReadEndpoint(JsonObject? o, FirewallNames names)
+ {
+ if (o?["zoneId"]?.GetValue() is not { } zid || !Guid.TryParse(zid, out var zoneId)) return (null, "has no zone");
+ var e = new FirewallEndpoint { Zone = names.ZoneName(zoneId) };
+ if (o["trafficFilter"] is not JsonObject f) return (e, null);
+
+ IReadOnlyList ports = [];
+ var oppPorts = false;
+ if (f["portFilter"] is JsonObject pf)
+ {
+ if (pf["type"]?.GetValue() != "PORTS") return (null, "uses a port matching list");
+ oppPorts = pf["matchOpposite"]?.GetValue() ?? false;
+ ports = (pf["items"] as JsonArray ?? []).Select(i => i!["type"]?.GetValue() == "PORT_NUMBER_RANGE"
+ ? $"{i["start"]}-{i["stop"]}" : $"{i["value"]}").ToList();
+ }
+ if (f["macAddressFilter"] is { } mac && mac.GetValueKind() != System.Text.Json.JsonValueKind.Null) return (null, "uses a MAC filter");
+
+ switch (f["type"]?.GetValue())
+ {
+ case "PORT":
+ return (e with { Ports = ports, MatchOppositePorts = oppPorts }, null);
+ case "IP_ADDRESS":
+ var af = f["ipAddressFilter"];
+ if (af?["type"]?.GetValue() != "IP_ADDRESSES") return (null, "uses an IP matching list");
+ var addrs = (af["items"] as JsonArray ?? []).Select(i => i!["type"]?.GetValue() switch
+ {
+ "IP_ADDRESS_RANGE" => $"{i["start"]}-{i["stop"]}",
+ _ => i["value"]?.GetValue() ?? "",
+ }).ToList();
+ return (e with { Addresses = addrs, MatchOpposite = af["matchOpposite"]?.GetValue() ?? false, Ports = ports, MatchOppositePorts = oppPorts }, null);
+ case "NETWORK":
+ var nf = f["networkFilter"];
+ var nets = (nf?["networkIds"] as JsonArray ?? []).Select(n => Guid.TryParse(n?.GetValue(), out var g) ? names.NetworkName(g) : "").ToList();
+ return (e with { Networks = nets, MatchOpposite = nf?["matchOpposite"]?.GetValue() ?? false, Ports = ports, MatchOppositePorts = oppPorts }, null);
+ case "IPV6_IID":
+ var raw = f["ipv6IidFilter"]?["ipv6Iid"]?.GetValue() ?? "";
+ var slash = raw.IndexOf('/');
+ if (slash >= 0 && raw[(slash + 1)..] != IidMask) return (null, $"uses a non-/64 IID mask ({raw})");
+ return (e with
+ {
+ Ipv6InterfaceId = slash >= 0 ? raw[..slash] : raw,
+ MatchOpposite = f["ipv6IidFilter"]?["matchOpposite"]?.GetValue() ?? false,
+ Ports = ports, MatchOppositePorts = oppPorts,
+ }, null);
+ default:
+ return (null, $"uses a {f["type"]} filter");
+ }
+ }
+}
diff --git a/src/UnifiSharp/Firewall/FirewallPolicySpec.cs b/src/UnifiSharp/Firewall/FirewallPolicySpec.cs
new file mode 100644
index 0000000..af905e0
--- /dev/null
+++ b/src/UnifiSharp/Firewall/FirewallPolicySpec.cs
@@ -0,0 +1,71 @@
+namespace UnifiSharp.Firewall;
+
+public enum FirewallAction { Allow, Block, Reject }
+
+public enum FirewallIpVersion { IPv4, IPv6, Both }
+
+///
+/// One end of a zone-based firewall policy: the zone, plus at most one way of narrowing it
+/// (addresses, networks, or an IPv6 interface identifier) and optionally ports.
+///
+public sealed record FirewallEndpoint
+{
+ /// Zone NAME as the controller shows it (e.g. "External", "Homelab"); resolved to an id on write.
+ public required string Zone { get; init; }
+
+ /// IPs, CIDR subnets (contain '/'), or ranges written "first-last".
+ public IReadOnlyList Addresses { get; init; } = [];
+
+ /// Network NAMES (e.g. "Consumer"); resolved to ids on write.
+ public IReadOnlyList Networks { get; init; } = [];
+
+ ///
+ /// IPv6 interface identifier, e.g. "::6342:9". Matches that suffix under ANY prefix, so a
+ /// rule survives the ISP re-delegating the prefix. Written to the API with the /64 IID mask.
+ ///
+ public string? Ipv6InterfaceId { get; init; }
+
+ /// Ports or ranges, e.g. "443", "63429", "1000-2000". Empty = all ports.
+ public IReadOnlyList Ports { get; init; } = [];
+
+ /// Invert the address/network/IID match ("everything except").
+ public bool MatchOpposite { get; init; }
+
+ /// Invert the port match.
+ public bool MatchOppositePorts { get; init; }
+
+ public bool Equals(FirewallEndpoint? other) =>
+ other is not null && Zone == other.Zone && Ipv6InterfaceId == other.Ipv6InterfaceId
+ && MatchOpposite == other.MatchOpposite && MatchOppositePorts == other.MatchOppositePorts
+ && SetEq(Addresses, other.Addresses) && SetEq(Networks, other.Networks) && SetEq(Ports, other.Ports);
+
+ public override int GetHashCode() => HashCode.Combine(Zone, Ipv6InterfaceId, Addresses.Count, Ports.Count);
+
+ internal static bool SetEq(IReadOnlyList a, IReadOnlyList b) =>
+ a.Count == b.Count && a.Order(StringComparer.OrdinalIgnoreCase).SequenceEqual(b.Order(StringComparer.OrdinalIgnoreCase), StringComparer.OrdinalIgnoreCase);
+}
+
+///
+/// Desired state of a zone-based firewall policy (UniFi Network 9+), in the subset of the
+/// integration API's model this library can round-trip. Equality is semantic (list order and
+/// case don't matter), so live == desired is the drift check.
+///
+public sealed record FirewallPolicySpec
+{
+ public required string Name { get; init; }
+ public string? Description { get; init; }
+ public bool Enabled { get; init; } = true;
+ public FirewallAction Action { get; init; } = FirewallAction.Allow;
+
+ /// Allow only: also create the mirrored return-traffic policy (the UI default).
+ public bool AllowReturnTraffic { get; init; } = true;
+
+ public required FirewallEndpoint Source { get; init; }
+ public required FirewallEndpoint Destination { get; init; }
+ public FirewallIpVersion IpVersion { get; init; } = FirewallIpVersion.Both;
+
+ /// "all" (no filter), "tcp_udp" (preset), or a named protocol such as "tcp", "udp", "gre".
+ public string Protocol { get; init; } = "all";
+
+ public bool Logging { get; init; }
+}
diff --git a/src/UnifiSharp/Firewall/UnifiFirewallClient.cs b/src/UnifiSharp/Firewall/UnifiFirewallClient.cs
new file mode 100644
index 0000000..325edf2
--- /dev/null
+++ b/src/UnifiSharp/Firewall/UnifiFirewallClient.cs
@@ -0,0 +1,124 @@
+using System.Net.Http.Headers;
+using System.Text;
+using System.Text.Json.Nodes;
+
+namespace UnifiSharp.Firewall;
+
+public sealed record FirewallZone(Guid Id, string Name, string Origin);
+
+/// A policy as the controller holds it. is USER_DEFINED,
+/// SYSTEM_DEFINED or DERIVED; only USER_DEFINED policies are ever written by this library's callers.
+public sealed record LiveFirewallPolicy(Guid Id, string Name, string Origin, JsonObject Raw)
+{
+ public bool IsUserDefined => Origin == "USER_DEFINED";
+}
+
+///
+/// Zone-based firewall policies through the OFFICIAL integration API
+/// ({BaseUrl}/v1/sites/{site}/firewall/…, X-API-KEY). Not the legacy adapter: the
+/// integration API has full CRUD for policies (UniFi Network 9+), so there's no reason to
+/// go through the undocumented surface (ADR-0003).
+///
+public sealed class UnifiFirewallClient : IDisposable
+{
+ private readonly HttpClient _http;
+ private readonly string _base;
+ private readonly string? _siteName;
+ private Guid? _siteId;
+
+ /// BaseUrl is the integration root, e.g. https://gw/proxy/network/integration.
+ /// Site to use by name; null = the controller's first (usually only) site.
+ public UnifiFirewallClient(UnifiClientOptions options, string? siteName = null, HttpMessageHandler? handler = null)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+ handler ??= options.VerifyTls ? new HttpClientHandler()
+ : new HttpClientHandler { ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator };
+ _http = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(60) };
+ _http.DefaultRequestHeaders.Add("X-API-KEY", options.ApiKey);
+ _http.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
+ _base = options.BaseUrl.AbsoluteUri.TrimEnd('/');
+ _siteName = siteName;
+ }
+
+ public async Task GetSiteIdAsync(CancellationToken ct = default)
+ {
+ if (_siteId is { } id) return id;
+ var sites = await ListAllAsync($"{_base}/v1/sites", ct).ConfigureAwait(false);
+ var site = _siteName is null ? sites.FirstOrDefault()
+ : sites.FirstOrDefault(s => string.Equals(s["name"]?.GetValue(), _siteName, StringComparison.OrdinalIgnoreCase));
+ _siteId = site?["id"]?.GetValue() is { } sid ? Guid.Parse(sid)
+ : throw new UnifiFirewallException($"site '{_siteName ?? "(first)"}' not found");
+ return _siteId.Value;
+ }
+
+ public async Task> ListZonesAsync(CancellationToken ct = default)
+ {
+ var rows = await ListAllAsync($"{await SiteAsync(ct)}/firewall/zones", ct).ConfigureAwait(false);
+ return rows.Select(r => new FirewallZone(Guid.Parse(r["id"]!.GetValue()), r["name"]!.GetValue(),
+ r["metadata"]?["origin"]?.GetValue() ?? "")).ToList();
+ }
+
+ public async Task GetNamesAsync(CancellationToken ct = default)
+ {
+ var zones = await ListZonesAsync(ct).ConfigureAwait(false);
+ var nets = await ListAllAsync($"{await SiteAsync(ct)}/networks", ct).ConfigureAwait(false);
+ return new FirewallNames(
+ zones.ToDictionary(z => z.Name, z => z.Id, StringComparer.OrdinalIgnoreCase),
+ nets.Where(n => n["name"] is not null && n["id"] is not null)
+ .GroupBy(n => n["name"]!.GetValue(), StringComparer.OrdinalIgnoreCase)
+ .ToDictionary(g => g.Key, g => Guid.Parse(g.First()["id"]!.GetValue()), StringComparer.OrdinalIgnoreCase));
+ }
+
+ public async Task> ListPoliciesAsync(CancellationToken ct = default)
+ {
+ var rows = await ListAllAsync($"{await SiteAsync(ct)}/firewall/policies", ct).ConfigureAwait(false);
+ return rows.Select(r => new LiveFirewallPolicy(Guid.Parse(r["id"]!.GetValue()),
+ r["name"]?.GetValue() ?? "", r["metadata"]?["origin"]?.GetValue() ?? "", r)).ToList();
+ }
+
+ public async Task CreateAsync(FirewallPolicySpec spec, FirewallNames names, CancellationToken ct = default)
+ => Parse(await SendAsync(HttpMethod.Post, $"{await SiteAsync(ct)}/firewall/policies", FirewallPolicyJson.ToJson(spec, names), ct).ConfigureAwait(false));
+
+ /// Replace a policy wholesale (PUT): the controller keeps nothing the spec doesn't carry.
+ public async Task UpdateAsync(Guid id, FirewallPolicySpec spec, FirewallNames names, CancellationToken ct = default)
+ => Parse(await SendAsync(HttpMethod.Put, $"{await SiteAsync(ct)}/firewall/policies/{id}", FirewallPolicyJson.ToJson(spec, names), ct).ConfigureAwait(false));
+
+ public async Task DeleteAsync(Guid id, CancellationToken ct = default)
+ => await SendAsync(HttpMethod.Delete, $"{await SiteAsync(ct)}/firewall/policies/{id}", null, ct).ConfigureAwait(false);
+
+ private static LiveFirewallPolicy Parse(JsonNode? n) => n is JsonObject o
+ ? new LiveFirewallPolicy(Guid.Parse(o["id"]!.GetValue()), o["name"]?.GetValue() ?? "", o["metadata"]?["origin"]?.GetValue() ?? "", o)
+ : throw new UnifiFirewallException("controller returned no policy object");
+
+ private async Task SiteAsync(CancellationToken ct) => $"{_base}/v1/sites/{await GetSiteIdAsync(ct).ConfigureAwait(false)}";
+
+ // The integration API pages every list: {offset, limit, count, totalCount, data}.
+ private async Task> ListAllAsync(string url, CancellationToken ct)
+ {
+ var all = new List();
+ for (var offset = 0; ;)
+ {
+ var page = await SendAsync(HttpMethod.Get, $"{url}?offset={offset}&limit=200", null, ct).ConfigureAwait(false);
+ var data = page?["data"] as JsonArray ?? [];
+ all.AddRange(data.OfType());
+ var total = page?["totalCount"]?.GetValue() ?? all.Count;
+ offset += data.Count;
+ if (data.Count == 0 || offset >= total) return all;
+ }
+ }
+
+ private async Task SendAsync(HttpMethod method, string url, JsonObject? body, CancellationToken ct)
+ {
+ using var req = new HttpRequestMessage(method, url);
+ if (body is not null) req.Content = new StringContent(body.ToJsonString(), Encoding.UTF8, "application/json");
+ using var resp = await _http.SendAsync(req, ct).ConfigureAwait(false);
+ var text = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
+ if (!resp.IsSuccessStatusCode)
+ throw new UnifiFirewallException($"{method} {url} → {(int)resp.StatusCode}: {text}");
+ return string.IsNullOrWhiteSpace(text) ? null : JsonNode.Parse(text);
+ }
+
+ public void Dispose() => _http.Dispose();
+}
+
+public sealed class UnifiFirewallException(string message) : Exception(message);
diff --git a/src/UnifiSharp/UnifiSharp.csproj b/src/UnifiSharp/UnifiSharp.csproj
index ed1832a..a38358a 100644
--- a/src/UnifiSharp/UnifiSharp.csproj
+++ b/src/UnifiSharp/UnifiSharp.csproj
@@ -6,7 +6,7 @@
enable
- 0.3.0
+ 0.4.0