diff --git a/README.md b/README.md index 11e4950..0f37ed3 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,12 @@ A `dotnet` global tool over the library: export UNIFI_BASE_URL="https://localhost:8443/proxy/network/integration/v1" export UNIFI_API_KEY="…" UNIFI_VERIFY_TLS=false unifisharp sites # list sites -unifisharp discover # JSON snapshot: sites + device/client/network counts +unifisharp discover # JSON snapshot: sites + networks/WLANs/firewall/devices/clients +unifisharp networks # networks/VLANs per site (name, vlan id, purpose, enabled) +unifisharp wlans # WLANs/SSIDs per site (ssid, enabled, security) +unifisharp firewall # firewall zones, policies, and ACL rules per site +unifisharp devices # adopted devices per site (name, model, ip, mac, firmware, state) +unifisharp clients # connected clients per site (name, type, ip, connectedAt) ``` ## Status diff --git a/src/UnifiSharp.Cli/Program.cs b/src/UnifiSharp.Cli/Program.cs index d37d963..5670e6b 100644 --- a/src/UnifiSharp.Cli/Program.cs +++ b/src/UnifiSharp.Cli/Program.cs @@ -3,7 +3,7 @@ // unifisharp — a thin read-only CLI over the UnifiSharp library. // -// Commands: sites | discover +// Commands: sites | discover | devices | clients | networks | firewall | wlans // Config (env): UNIFI_BASE_URL (…/proxy/network/integration/v1), UNIFI_API_KEY, // UNIFI_VERIFY_TLS (optional, 'false' for self-signed consoles) @@ -17,7 +17,12 @@ Usage: unifisharp sites List sites (id + name) - discover Dump a UnifiSnapshot (sites + device/client/network counts) as JSON + discover Dump a UnifiSnapshot (sites + networks/WLANs/firewall/devices/clients) as JSON + devices List adopted devices per site (name, model, ip, mac, firmware, state) as JSON + clients List connected clients per site (name, type, ip, connectedAt) as JSON + networks List networks/VLANs per site (name, vlan id, purpose, enabled) as JSON + firewall List firewall zones, policies, and ACL rules per site as JSON + wlans List WLANs/SSIDs per site (ssid, enabled, security) as JSON Config (env): UNIFI_BASE_URL (…/proxy/network/integration/v1), UNIFI_API_KEY, UNIFI_VERIFY_TLS (optional, 'false' for self-signed) @@ -34,6 +39,14 @@ discover Dump a UnifiSnapshot (sites + device/client/network counts) as JSON var client = UnifiApi.Create(options); +var json = new JsonSerializerOptions +{ + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, +}; + +void Dump(object value) => Console.WriteLine(JsonSerializer.Serialize(value, json)); + switch (command) { case "sites": @@ -45,15 +58,35 @@ discover Dump a UnifiSnapshot (sites + device/client/network counts) as JSON return 0; case "discover": - var snapshot = await new UnifiDiscovery(client).DiscoverAsync(); - Console.WriteLine(JsonSerializer.Serialize(snapshot, new JsonSerializerOptions - { - WriteIndented = true, - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - })); + Dump(await new UnifiDiscovery(client).DiscoverAsync()); + return 0; + + case "devices": + Dump((await new UnifiDiscovery(client).DiscoverAsync()) + .Sites.Select(s => new { s.Site, s.Id, s.Devices })); + return 0; + + case "clients": + Dump((await new UnifiDiscovery(client).DiscoverAsync()) + .Sites.Select(s => new { s.Site, s.Id, s.Clients })); + return 0; + + case "networks": + Dump((await new UnifiDiscovery(client).DiscoverAsync()) + .Sites.Select(s => new { s.Site, s.Id, s.Networks })); + return 0; + + case "firewall": + Dump((await new UnifiDiscovery(client).DiscoverAsync()) + .Sites.Select(s => new { s.Site, s.Id, s.Firewall })); + return 0; + + case "wlans": + Dump((await new UnifiDiscovery(client).DiscoverAsync()) + .Sites.Select(s => new { s.Site, s.Id, s.Wlans })); return 0; default: - Console.Error.WriteLine($"Unknown command '{command}'. Try: sites | discover"); + Console.Error.WriteLine($"Unknown command '{command}'. Try: sites | discover | devices | clients | networks | firewall | wlans"); return 1; } diff --git a/src/UnifiSharp/UnifiDiscovery.cs b/src/UnifiSharp/UnifiDiscovery.cs index 1c3559f..98f1c1a 100644 --- a/src/UnifiSharp/UnifiDiscovery.cs +++ b/src/UnifiSharp/UnifiDiscovery.cs @@ -4,9 +4,10 @@ namespace UnifiSharp; /// /// Read-only discovery: walks the UniFi controller via the generated client and -/// produces a structured — sites and the -/// devices/clients/networks each contains. The in-code, repeatable read sweep, -/// sibling to ProxmoxSharp's discovery. +/// produces a structured — for each site the +/// networks/VLANs, WLANs, firewall (zones + policies + ACL rules), and the +/// devices/clients it contains. The in-code, repeatable read sweep, sibling to +/// ProxmoxSharp's discovery. /// public sealed class UnifiDiscovery { @@ -32,24 +33,94 @@ public async Task DiscoverAsync(CancellationToken cancellationTok } var builder = _client.V1.Sites[id]; + var devices = (await builder.Devices.GetAsync(cancellationToken: cancellationToken) .ConfigureAwait(false))?.Data ?? []; var clients = (await builder.Clients.GetAsync(cancellationToken: cancellationToken) .ConfigureAwait(false))?.Data ?? []; var networks = (await builder.Networks.GetAsync(cancellationToken: cancellationToken) .ConfigureAwait(false))?.Data ?? []; + var wlans = (await builder.Wifi.Broadcasts.GetAsync(cancellationToken: cancellationToken) + .ConfigureAwait(false))?.Data ?? []; + var zones = (await builder.Firewall.Zones.GetAsync(cancellationToken: cancellationToken) + .ConfigureAwait(false))?.Data ?? []; + var policies = (await builder.Firewall.Policies.GetAsync(cancellationToken: cancellationToken) + .ConfigureAwait(false))?.Data ?? []; + var aclRules = (await builder.AclRules.GetAsync(cancellationToken: cancellationToken) + .ConfigureAwait(false))?.Data ?? []; snapshots.Add(new UnifiSiteSnapshot { Site = site.Name ?? id.ToString(), Id = site.Id, - Devices = devices.Count, - Clients = clients.Count, - Networks = networks - .Select(n => n.Name) - .Where(n => !string.IsNullOrEmpty(n)) - .Select(n => n!) - .ToList(), + DeviceCount = devices.Count, + ClientCount = clients.Count, + Networks = networks.Select(n => new UnifiNetworkInfo + { + Name = n.Name ?? string.Empty, + Id = n.Id, + VlanId = n.VlanId, + Enabled = n.Enabled, + Purpose = n.Management, + IsDefault = n.Default, + }).ToList(), + Wlans = wlans.Select(w => new UnifiWlanInfo + { + Ssid = w.Name ?? string.Empty, + Id = w.Id, + Enabled = w.Enabled, + Security = w.SecurityConfiguration?.Type, + NetworkType = w.Network?.Type, + }).ToList(), + Firewall = new UnifiFirewallSnapshot + { + Zones = zones.Select(z => new UnifiFirewallZoneInfo + { + Name = z.Name ?? string.Empty, + Id = z.Id, + NetworkIds = (z.NetworkIds ?? []) + .Where(g => g.HasValue) + .Select(g => g!.Value) + .ToList(), + }).ToList(), + Policies = policies.Select(p => new UnifiFirewallPolicyInfo + { + Name = p.Name ?? string.Empty, + Id = p.Id, + Index = p.Index, + Enabled = p.Enabled, + Action = p.Action?.Type, + SourceZoneId = p.Source?.ZoneId, + DestinationZoneId = p.Destination?.ZoneId, + }).ToList(), + AclRules = aclRules.Select(a => new UnifiAclRuleInfo + { + Name = a.Name ?? string.Empty, + Id = a.Id, + Index = a.Index, + Enabled = a.Enabled, + Type = a.Type, + Action = a.Action?.ToString(), + }).ToList(), + }, + Devices = devices.Select(d => new UnifiDeviceInfo + { + Name = d.Name ?? string.Empty, + Id = d.Id, + Model = d.Model, + MacAddress = d.MacAddress, + IpAddress = d.IpAddress, + FirmwareVersion = d.FirmwareVersion, + State = d.State?.ToString(), + }).ToList(), + Clients = clients.Select(c => new UnifiClientInfo + { + Name = c.Name ?? string.Empty, + Id = c.Id, + Type = c.Type, + IpAddress = c.IpAddress, + ConnectedAt = c.ConnectedAt, + }).ToList(), }); } diff --git a/src/UnifiSharp/UnifiSnapshot.cs b/src/UnifiSharp/UnifiSnapshot.cs index f0484b2..09b5cbf 100644 --- a/src/UnifiSharp/UnifiSnapshot.cs +++ b/src/UnifiSharp/UnifiSnapshot.cs @@ -6,12 +6,107 @@ public sealed record UnifiSnapshot public required IReadOnlyList Sites { get; init; } } -/// A site and what it contains. +/// A site and the detail it contains. Mirrors the ProxmoxSharp discover style. public sealed record UnifiSiteSnapshot { public required string Site { get; init; } public Guid? Id { get; init; } - public int Devices { get; init; } - public int Clients { get; init; } - public IReadOnlyList Networks { get; init; } = []; + + /// Total counts — kept for quick at-a-glance summaries. + public int DeviceCount { get; init; } + public int ClientCount { get; init; } + + public IReadOnlyList Networks { get; init; } = []; + public IReadOnlyList Wlans { get; init; } = []; + public UnifiFirewallSnapshot Firewall { get; init; } = new(); + public IReadOnlyList Devices { get; init; } = []; + public IReadOnlyList Clients { get; init; } = []; +} + +/// A network / VLAN. Subnet is not exposed by the integration list API; carries the network's management role. +public sealed record UnifiNetworkInfo +{ + public required string Name { get; init; } + public Guid? Id { get; init; } + public int? VlanId { get; init; } + public bool? Enabled { get; init; } + + /// The network's management/purpose role (the API's management field, e.g. routed/switched). + public string? Purpose { get; init; } + public bool? IsDefault { get; init; } +} + +/// A WLAN / SSID broadcast. Security type and the bound network are surfaced; band is not exposed in the overview API. +public sealed record UnifiWlanInfo +{ + public required string Ssid { get; init; } + public Guid? Id { get; init; } + public bool? Enabled { get; init; } + + /// Security configuration type (e.g. wpa2, wpa3, open) where the API reports it. + public string? Security { get; init; } + + /// The bound network reference type (the API exposes only the reference kind, not its id). + public string? NetworkType { get; init; } +} + +/// Firewall read surface: zones, policies, and legacy ACL rules. +public sealed record UnifiFirewallSnapshot +{ + public IReadOnlyList Zones { get; init; } = []; + public IReadOnlyList Policies { get; init; } = []; + public IReadOnlyList AclRules { get; init; } = []; +} + +/// A firewall zone and the networks it groups. +public sealed record UnifiFirewallZoneInfo +{ + public required string Name { get; init; } + public Guid? Id { get; init; } + public IReadOnlyList NetworkIds { get; init; } = []; +} + +/// A zone-based firewall policy. +public sealed record UnifiFirewallPolicyInfo +{ + public required string Name { get; init; } + public Guid? Id { get; init; } + public int? Index { get; init; } + public bool? Enabled { get; init; } + public string? Action { get; init; } + public Guid? SourceZoneId { get; init; } + public Guid? DestinationZoneId { get; init; } +} + +/// A (legacy) ACL rule. +public sealed record UnifiAclRuleInfo +{ + public required string Name { get; init; } + public Guid? Id { get; init; } + public int? Index { get; init; } + public bool? Enabled { get; init; } + public string? Type { get; init; } + public string? Action { get; init; } +} + +/// An adopted infrastructure device (AP / switch / gateway). +public sealed record UnifiDeviceInfo +{ + public required string Name { get; init; } + public Guid? Id { get; init; } + public string? Model { get; init; } + public string? MacAddress { get; init; } + public string? IpAddress { get; init; } + public string? FirmwareVersion { get; init; } + public string? State { get; init; } +} + +/// A connected client (wired or wireless). +public sealed record UnifiClientInfo +{ + public required string Name { get; init; } + public Guid? Id { get; init; } + public string? Type { get; init; } + public string? IpAddress { get; init; } + public DateTimeOffset? ConnectedAt { get; init; } } diff --git a/tests/UnifiSharp.Tests/UnifiLiveTests.cs b/tests/UnifiSharp.Tests/UnifiLiveTests.cs index 704a3a7..812af8a 100644 --- a/tests/UnifiSharp.Tests/UnifiLiveTests.cs +++ b/tests/UnifiSharp.Tests/UnifiLiveTests.cs @@ -20,4 +20,31 @@ public async Task Discover_returns_a_snapshot() Assert.NotNull(snapshot); Assert.NotNull(snapshot.Sites); } + + [SkippableFact] + public async Task Discover_populates_enriched_sections() + { + var options = UnifiClientOptions.TryFromEnvironment(); + Skip.If(options is null, "No UNIFI_BASE_URL / UNIFI_API_KEY — skipping live UniFi discovery."); + + var snapshot = await new UnifiDiscovery(UnifiApi.Create(options!)).DiscoverAsync(); + + Skip.If(snapshot.Sites.Count == 0, "No sites returned by the controller."); + + // Every enriched section must be non-null (records default to empty lists); + // at least one site should expose networks and devices on a real controller. + foreach (var site in snapshot.Sites) + { + Assert.NotNull(site.Networks); + Assert.NotNull(site.Wlans); + Assert.NotNull(site.Devices); + Assert.NotNull(site.Clients); + Assert.NotNull(site.Firewall); + Assert.NotNull(site.Firewall.Zones); + Assert.NotNull(site.Firewall.Policies); + Assert.NotNull(site.Firewall.AclRules); + } + + Assert.Contains(snapshot.Sites, s => s.Networks.Count > 0); + } } diff --git a/tests/UnifiSharp.Tests/UnifiSnapshotTests.cs b/tests/UnifiSharp.Tests/UnifiSnapshotTests.cs new file mode 100644 index 0000000..448de79 --- /dev/null +++ b/tests/UnifiSharp.Tests/UnifiSnapshotTests.cs @@ -0,0 +1,104 @@ +using System.Text.Json; +using UnifiSharp; +using Xunit; + +namespace UnifiSharp.Tests; + +/// +/// Pure unit tests for the enriched record graph — +/// shape and JSON serialization, no controller required. +/// +public class UnifiSnapshotTests +{ + private static readonly JsonSerializerOptions Json = new() + { + WriteIndented = false, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + }; + + private static UnifiSnapshot Sample() => new() + { + Sites = + [ + new UnifiSiteSnapshot + { + Site = "Default", + Id = Guid.Parse("11111111-1111-1111-1111-111111111111"), + DeviceCount = 2, + ClientCount = 3, + Networks = + [ + new UnifiNetworkInfo { Name = "Homelab", VlanId = 1010, Enabled = true, Purpose = "routed" }, + ], + Wlans = + [ + new UnifiWlanInfo { Ssid = "HomeWiFi", Enabled = true, Security = "wpa3", NetworkType = "DEFAULT" }, + ], + Firewall = new UnifiFirewallSnapshot + { + Zones = [new UnifiFirewallZoneInfo { Name = "Internal", NetworkIds = [Guid.NewGuid()] }], + Policies = [new UnifiFirewallPolicyInfo { Name = "Allow LAN", Index = 1, Enabled = true, Action = "ALLOW" }], + AclRules = [new UnifiAclRuleInfo { Name = "Block IoT", Enabled = true, Type = "L3", Action = "BLOCK" }], + }, + Devices = + [ + new UnifiDeviceInfo { Name = "UDM", Model = "UDMPRO", IpAddress = "10.10.0.1", MacAddress = "aa:bb", FirmwareVersion = "4.0", State = "ONLINE" }, + ], + Clients = + [ + new UnifiClientInfo { Name = "laptop", Type = "WIRELESS", IpAddress = "10.10.0.50" }, + ], + }, + ], + }; + + [Fact] + public void Snapshot_carries_enriched_sections() + { + var snapshot = Sample(); + var site = Assert.Single(snapshot.Sites); + + Assert.Equal(2, site.DeviceCount); + Assert.Equal(3, site.ClientCount); + Assert.Equal(1010, Assert.Single(site.Networks).VlanId); + Assert.Equal("HomeWiFi", Assert.Single(site.Wlans).Ssid); + Assert.Equal("wpa3", site.Wlans[0].Security); + Assert.Single(site.Firewall.Zones); + Assert.Equal("ALLOW", Assert.Single(site.Firewall.Policies).Action); + Assert.Equal("BLOCK", Assert.Single(site.Firewall.AclRules).Action); + Assert.Equal("UDM", Assert.Single(site.Devices).Name); + Assert.Equal("laptop", Assert.Single(site.Clients).Name); + } + + [Fact] + public void Snapshot_round_trips_through_json() + { + var snapshot = Sample(); + + var serialized = JsonSerializer.Serialize(snapshot, Json); + var restored = JsonSerializer.Deserialize(serialized, Json); + + Assert.NotNull(restored); + var site = Assert.Single(restored!.Sites); + Assert.Equal("Default", site.Site); + Assert.Equal("Homelab", Assert.Single(site.Networks).Name); + Assert.Equal("routed", site.Networks[0].Purpose); + Assert.Equal("Internal", Assert.Single(site.Firewall.Zones).Name); + Assert.Equal("UDMPRO", Assert.Single(site.Devices).Model); + } + + [Fact] + public void Empty_sections_default_to_empty_not_null() + { + var site = new UnifiSiteSnapshot { Site = "Empty" }; + + Assert.Empty(site.Networks); + Assert.Empty(site.Wlans); + Assert.Empty(site.Devices); + Assert.Empty(site.Clients); + Assert.NotNull(site.Firewall); + Assert.Empty(site.Firewall.Zones); + Assert.Empty(site.Firewall.Policies); + Assert.Empty(site.Firewall.AclRules); + } +}