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
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
51 changes: 42 additions & 9 deletions src/UnifiSharp.Cli/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -17,7 +17,12 @@

Usage: unifisharp <command>
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)
Expand All @@ -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":
Expand All @@ -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;
}
91 changes: 81 additions & 10 deletions src/UnifiSharp/UnifiDiscovery.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@ namespace UnifiSharp;

/// <summary>
/// Read-only discovery: walks the UniFi controller via the generated client and
/// produces a structured <see cref="UnifiSnapshot"/> — sites and the
/// devices/clients/networks each contains. The in-code, repeatable read sweep,
/// sibling to ProxmoxSharp's discovery.
/// produces a structured <see cref="UnifiSnapshot"/> — 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.
/// </summary>
public sealed class UnifiDiscovery
{
Expand All @@ -32,24 +33,94 @@ public async Task<UnifiSnapshot> 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(),
});
}

Expand Down
103 changes: 99 additions & 4 deletions src/UnifiSharp/UnifiSnapshot.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,107 @@ public sealed record UnifiSnapshot
public required IReadOnlyList<UnifiSiteSnapshot> Sites { get; init; }
}

/// <summary>A site and what it contains.</summary>
/// <summary>A site and the detail it contains. Mirrors the ProxmoxSharp discover style.</summary>
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<string> Networks { get; init; } = [];

/// <summary>Total counts — kept for quick at-a-glance summaries.</summary>
public int DeviceCount { get; init; }
public int ClientCount { get; init; }

public IReadOnlyList<UnifiNetworkInfo> Networks { get; init; } = [];
public IReadOnlyList<UnifiWlanInfo> Wlans { get; init; } = [];
public UnifiFirewallSnapshot Firewall { get; init; } = new();
public IReadOnlyList<UnifiDeviceInfo> Devices { get; init; } = [];
public IReadOnlyList<UnifiClientInfo> Clients { get; init; } = [];
}

/// <summary>A network / VLAN. Subnet is not exposed by the integration list API; <see cref="Purpose"/> carries the network's management role.</summary>
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; }

/// <summary>The network's management/purpose role (the API's <c>management</c> field, e.g. routed/switched).</summary>
public string? Purpose { get; init; }
public bool? IsDefault { get; init; }
}

/// <summary>A WLAN / SSID broadcast. Security type and the bound network are surfaced; band is not exposed in the overview API.</summary>
public sealed record UnifiWlanInfo
{
public required string Ssid { get; init; }
public Guid? Id { get; init; }
public bool? Enabled { get; init; }

/// <summary>Security configuration type (e.g. wpa2, wpa3, open) where the API reports it.</summary>
public string? Security { get; init; }

/// <summary>The bound network reference type (the API exposes only the reference kind, not its id).</summary>
public string? NetworkType { get; init; }
}

/// <summary>Firewall read surface: zones, policies, and legacy ACL rules.</summary>
public sealed record UnifiFirewallSnapshot
{
public IReadOnlyList<UnifiFirewallZoneInfo> Zones { get; init; } = [];
public IReadOnlyList<UnifiFirewallPolicyInfo> Policies { get; init; } = [];
public IReadOnlyList<UnifiAclRuleInfo> AclRules { get; init; } = [];
}

/// <summary>A firewall zone and the networks it groups.</summary>
public sealed record UnifiFirewallZoneInfo
{
public required string Name { get; init; }
public Guid? Id { get; init; }
public IReadOnlyList<Guid> NetworkIds { get; init; } = [];
}

/// <summary>A zone-based firewall policy.</summary>
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; }
}

/// <summary>A (legacy) ACL rule.</summary>
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; }
}

/// <summary>An adopted infrastructure device (AP / switch / gateway).</summary>
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; }
}

/// <summary>A connected client (wired or wireless).</summary>
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; }
}
27 changes: 27 additions & 0 deletions tests/UnifiSharp.Tests/UnifiLiveTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Loading
Loading