From 836d56e4561b1e578efb31676dceaf70c8dca0d6 Mon Sep 17 00:00:00 2001 From: Jason-Morcos <10710367+Jason-Morcos@users.noreply.github.com> Date: Sun, 2 Aug 2026 18:43:39 -0700 Subject: [PATCH] Resolve clients from complete UniFi data --- .../Services/ClientDashboardService.cs | 233 +++++++++++++++++- .../ClientDashboardServiceTests.cs | 126 ++++++++++ 2 files changed, 348 insertions(+), 11 deletions(-) create mode 100644 tests/NetworkOptimizer.Web.Tests/ClientDashboardServiceTests.cs diff --git a/src/NetworkOptimizer.Web/Services/ClientDashboardService.cs b/src/NetworkOptimizer.Web/Services/ClientDashboardService.cs index 713b415f44..8e3c243556 100644 --- a/src/NetworkOptimizer.Web/Services/ClientDashboardService.cs +++ b/src/NetworkOptimizer.Web/Services/ClientDashboardService.cs @@ -9,6 +9,7 @@ using NetworkOptimizer.UniFi; using NetworkOptimizer.UniFi.Models; using NetworkOptimizer.Web.Models; +using NetworkOptimizer.Web.Services.Ssh; using NetworkOptimizer.WiFi; using NetworkOptimizer.WiFi.Models; @@ -31,6 +32,7 @@ public class ClientDashboardService private readonly ClientSpeedTestService _speedTestService; private readonly IConfiguration _configuration; private readonly IServiceScopeFactory _scopeFactory; + private readonly IGatewaySshService _gatewaySshService; // Track last trace hash per client MAC to detect changes private readonly ConcurrentDictionary _lastTraceHashes = new(); @@ -58,7 +60,8 @@ public ClientDashboardService( SpeedTestServiceRegistry speedTestRegistry, IConfiguration configuration, IServiceScopeFactory scopeFactory, - SiteContextService siteContext) + SiteContextService siteContext, + IGatewaySshService gatewaySshService) { _logger = logger; _siteDbFactory = siteDbFactory; @@ -71,6 +74,7 @@ public ClientDashboardService( _speedTestService = siteServices.ClientSpeedTest; _configuration = configuration; _scopeFactory = scopeFactory; + _gatewaySshService = gatewaySshService; } /// Context for the database holding this instance's site data. @@ -132,6 +136,47 @@ public async Task> GetSelectableClientsAsync() try { UniFiClientResponse? client = null; + List? activeDetails = null; + List? history = null; + Dictionary? activeIpLookup = null; + Dictionary? historyIpLookup = null; + + async Task> GetActiveDetailsAsync() + { + activeDetails ??= await _connectionService.Client.GetActiveClientsAsync(); + return activeDetails; + } + + async Task> GetActiveIpLookupAsync() + { + activeIpLookup ??= ClientIpEnricher.BuildMacToIpLookup(await GetActiveDetailsAsync()); + return activeIpLookup; + } + + async Task> GetHistoryAsync() + { + history ??= await _connectionService.Client.GetClientHistoryAsync(withinHours: 720); + return history; + } + + async Task> GetHistoryIpLookupAsync() + { + historyIpLookup ??= ClientIpEnricher.BuildMacToIpLookup(await GetHistoryAsync()); + return historyIpLookup; + } + + IReadOnlyDictionary? BuildBestIpLookup() + { + if (activeIpLookup == null) + return historyIpLookup; + if (historyIpLookup == null) + return activeIpLookup; + + var combined = new Dictionary(historyIpLookup, StringComparer.OrdinalIgnoreCase); + foreach (var pair in activeIpLookup) + combined[pair.Key] = pair.Value; + return combined; + } // Fast path: if we already know the MAC, fetch just this client if (_ipToMacCache.TryGetValue(clientIp, out var knownMac)) @@ -141,11 +186,21 @@ public async Task> GetSelectableClientsAsync() // Verify the IP still matches - if another device took this IP // (DHCP reassignment), the MAC lookup returns the wrong device. - // Match on BestIp so fixed/reservation devices (empty live ip) still match. - if (client != null && client.BestIp != clientIp) + if (client != null && !ClientMatchesIp(client, clientIp)) { - _logger.LogTrace("Identify {Ip}: IP mismatch (device now at {NewIp}), invalidating cache", clientIp, client.Ip); - client = null; + var activeLookup = await GetActiveIpLookupAsync(); + if (!ClientMatchesIp(client, clientIp, activeLookup)) + { + var historyLookup = await GetHistoryIpLookupAsync(); + if (!ClientMatchesIp(client, clientIp, historyLookup)) + { + _logger.LogTrace( + "Identify {Ip}: IP mismatch (device now at {NewIp}), invalidating cache", + clientIp, + ResolveClientIp(client, BuildBestIpLookup())); + client = null; + } + } } // If lookup failed or IP changed, invalidate and fall through to full list @@ -161,7 +216,18 @@ public async Task> GetSelectableClientsAsync() { _logger.LogTrace("Identify {Ip}: slow path via stat/sta (all clients)", clientIp); var clients = await _connectionService.Client.GetClientsAsync(); - client = clients?.FirstOrDefault(c => c.BestIp == clientIp); + client = clients?.FirstOrDefault(c => ClientMatchesIp(c, clientIp)); + if (client == null && clients?.Count > 0) + { + var activeLookup = await GetActiveIpLookupAsync(); + client = clients.FirstOrDefault(c => ClientMatchesIp(c, clientIp, activeLookup)); + } + + if (client == null && clients?.Count > 0) + { + var historyLookup = await GetHistoryIpLookupAsync(); + client = clients.FirstOrDefault(c => ClientMatchesIp(c, clientIp, historyLookup)); + } } if (client != null) @@ -174,7 +240,7 @@ public async Task> GetSelectableClientsAsync() // same name here as in Client Stats instead of a raw MAC. var displayNames = await ClientDisplayNameCache.GetAsync(_connectionService.Client); displayNames.TryGetValue(client.Mac.ToLowerInvariant(), out var displayName); - var identity = MapClientToIdentity(client, displayName); + var identity = MapClientToIdentity(client, displayName, BuildBestIpLookup()); // Try WiFiman endpoint for more-realtime signal data, overlay on top of stat/sta await OverlayWiFiManDataAsync(identity, clientIp); @@ -183,13 +249,40 @@ public async Task> GetSelectableClientsAsync() return identity; } + // The v2 active-client endpoint can still contain a client omitted by stat/sta. + var activeDetail = (await GetActiveDetailsAsync()) + .FirstOrDefault(c => ClientDetailMatchesIp(c, clientIp)); + if (activeDetail == null) + { + var clientMac = await ResolveMacFromGatewayNeighborAsync(clientIp); + if (!string.IsNullOrEmpty(clientMac)) + activeDetail = activeDetails?.FirstOrDefault(c => MacEquals(c.Mac, clientMac)); + } + + if (activeDetail != null) + { + var identity = MapClientDetailToIdentity(activeDetail, clientIp); + _offlineIdentityCache.TryRemove(clientIp, out _); + _ipToMacCache[clientIp] = identity.Mac; + + _logger.LogDebug( + "Identified active client {Ip} as {Name} ({Mac}) from UniFi active-client details", + clientIp, + identity.DisplayName, + identity.Mac); + + await OverlayWiFiManDataAsync(identity, clientIp); + return identity; + } + // Device not in active list - check offline cache if (_offlineIdentityCache.TryGetValue(clientIp, out var cached)) return cached; // Try client history API (includes offline devices) - var history = await _connectionService.Client.GetClientHistoryAsync(withinHours: 720); - var histClient = history?.FirstOrDefault(c => c.BestIp == clientIp); + var historyClients = await GetHistoryAsync(); + var histClient = historyClients.FirstOrDefault(c => + string.Equals(c.BestIp, clientIp, StringComparison.OrdinalIgnoreCase)); if (histClient != null) { @@ -261,6 +354,34 @@ public async Task> GetSelectableClientsAsync() } } + private async Task ResolveMacFromGatewayNeighborAsync(string clientIp) + { + if (!System.Net.IPAddress.TryParse(clientIp, out var ipAddress) + || ipAddress.AddressFamily != System.Net.Sockets.AddressFamily.InterNetworkV6) + { + return null; + } + + try + { + var (success, output) = await _gatewaySshService.RunCommandAsync( + "ip -6 neigh show", + TimeSpan.FromSeconds(5)); + if (!success) + { + _logger.LogDebug("Gateway IPv6 neighbor lookup failed while identifying {Ip}", clientIp); + return null; + } + + return TryGetMacFromNeighborOutput(output, clientIp); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Gateway IPv6 neighbor lookup failed while identifying {Ip}", clientIp); + return null; + } + } + /// /// Poll current signal quality for a client, run a trace, store the result, and return live data. /// @@ -950,7 +1071,10 @@ private async Task StoreSignalLogAsync( } } - private ClientIdentity MapClientToIdentity(UniFiClientResponse client, string? displayName = null) + private ClientIdentity MapClientToIdentity( + UniFiClientResponse client, + string? displayName = null, + IReadOnlyDictionary? macToIpLookup = null) { // Bridged UniFi ecosystem devices (e.g. a Protect camera on a UniFi Device Bridge) have // no user Name/display_name but expose a friendly ucore name like "[Camera] Front Door". @@ -965,7 +1089,7 @@ private ClientIdentity MapClientToIdentity(UniFiClientResponse client, string? d : !string.IsNullOrEmpty(client.Name) ? client.Name : !string.IsNullOrEmpty(ucoreName) ? ucoreName : null, Hostname = !string.IsNullOrEmpty(client.Hostname) ? client.Hostname : null, - Ip = client.Ip, + Ip = ResolveClientIp(client, macToIpLookup), IsWired = client.IsWired, SignalDbm = client.Signal, NoiseDbm = client.Noise, @@ -987,6 +1111,93 @@ private ClientIdentity MapClientToIdentity(UniFiClientResponse client, string? d }; } + internal static bool ClientDetailMatchesIp(UniFiClientDetailResponse client, string clientIp) + { + return !string.IsNullOrWhiteSpace(clientIp) + && string.Equals(client.BestIp, clientIp, StringComparison.OrdinalIgnoreCase); + } + + internal static bool MacEquals(string? left, string? right) + { + return !string.IsNullOrWhiteSpace(left) + && !string.IsNullOrWhiteSpace(right) + && string.Equals(left, right, StringComparison.OrdinalIgnoreCase); + } + + internal static string? TryGetMacFromNeighborOutput(string output, string clientIp) + { + if (string.IsNullOrWhiteSpace(output) || string.IsNullOrWhiteSpace(clientIp)) + return null; + + foreach (var line in output.Split( + '\n', + StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + var parts = line.Split( + ' ', + StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (parts.Length < 4 + || !string.Equals(parts[0], clientIp, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + for (var i = 1; i < parts.Length - 1; i++) + { + if (string.Equals(parts[i], "lladdr", StringComparison.OrdinalIgnoreCase)) + return parts[i + 1]; + } + } + + return null; + } + + internal static ClientIdentity MapClientDetailToIdentity( + UniFiClientDetailResponse client, + string requestedIp) + { + return new ClientIdentity + { + Mac = client.Mac, + Name = !string.IsNullOrEmpty(client.DisplayName) ? client.DisplayName : client.Name, + Hostname = client.Hostname, + Ip = requestedIp, + IsWired = client.IsWired + || string.Equals(client.Type, "WIRED", StringComparison.OrdinalIgnoreCase), + Oui = client.Oui, + NetworkName = client.NetworkName ?? client.LastConnectionNetworkName, + IsOffline = string.Equals(client.Status, "offline", StringComparison.OrdinalIgnoreCase) + }; + } + + internal static bool ClientMatchesIp( + UniFiClientResponse client, + string clientIp, + IReadOnlyDictionary? macToIpLookup = null) + { + return !string.IsNullOrWhiteSpace(clientIp) + && string.Equals( + ResolveClientIp(client, macToIpLookup), + clientIp, + StringComparison.OrdinalIgnoreCase); + } + + internal static string? ResolveClientIp( + UniFiClientResponse client, + IReadOnlyDictionary? macToIpLookup = null) + { + if (!string.IsNullOrEmpty(client.BestIp)) + return client.BestIp; + + if (!string.IsNullOrEmpty(client.Mac) + && macToIpLookup?.TryGetValue(client.Mac, out var enrichedIp) == true) + { + return enrichedIp; + } + + return null; + } + /// /// Overlay WiFiman realtime data onto an existing ClientIdentity. /// WiFiman provides more-realtime signal/channel/band/rate data than stat/sta. diff --git a/tests/NetworkOptimizer.Web.Tests/ClientDashboardServiceTests.cs b/tests/NetworkOptimizer.Web.Tests/ClientDashboardServiceTests.cs new file mode 100644 index 0000000000..75d732e629 --- /dev/null +++ b/tests/NetworkOptimizer.Web.Tests/ClientDashboardServiceTests.cs @@ -0,0 +1,126 @@ +using FluentAssertions; +using NetworkOptimizer.UniFi.Models; +using NetworkOptimizer.Web.Services; +using Xunit; + +namespace NetworkOptimizer.Web.Tests; + +public class ClientDashboardServiceTests +{ + [Fact] + public void ResolveClientIp_UsesLastOrFixedAddressWhenCurrentAddressIsMissing() + { + ClientDashboardService.ResolveClientIp(new UniFiClientResponse + { + LastIp = "10.0.0.21", + FixedIp = "10.0.0.20" + }).Should().Be("10.0.0.21"); + + ClientDashboardService.ResolveClientIp(new UniFiClientResponse + { + FixedIp = "10.0.0.20" + }).Should().Be("10.0.0.20"); + } + + [Fact] + public void ResolveClientIp_PrefersCurrentAddressOverFallbacks() + { + var client = new UniFiClientResponse + { + Ip = "10.0.0.22", + LastIp = "10.0.0.21", + FixedIp = "10.0.0.20" + }; + + ClientDashboardService.ResolveClientIp(client).Should().Be("10.0.0.22"); + } + + [Fact] + public void ResolveClientIp_UsesV2LookupWhenStatStaHasNoAddress() + { + var client = new UniFiClientResponse { Mac = "44:a7:f4:32:28:e0" }; + var lookup = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["44:A7:F4:32:28:E0"] = "10.0.0.21" + }; + + ClientDashboardService.ResolveClientIp(client, lookup).Should().Be("10.0.0.21"); + ClientDashboardService.ClientMatchesIp(client, "10.0.0.21", lookup).Should().BeTrue(); + } + + [Fact] + public void ResolveClientIp_DoesNotReplaceAStatStaAddressWithStaleV2Data() + { + var client = new UniFiClientResponse + { + Mac = "44:a7:f4:32:28:e0", + Ip = "10.0.0.22" + }; + var lookup = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + [client.Mac] = "10.0.0.21" + }; + + ClientDashboardService.ResolveClientIp(client, lookup).Should().Be("10.0.0.22"); + } + + [Fact] + public void ClientDetailMatchesIp_UsesV2BestAddress() + { + var client = new UniFiClientDetailResponse { LastIp = "10.0.0.3" }; + + ClientDashboardService.ClientDetailMatchesIp(client, "10.0.0.3").Should().BeTrue(); + } + + [Fact] + public void MapClientDetailToIdentity_PreservesMetadataAndRequestedAddress() + { + var client = new UniFiClientDetailResponse + { + Mac = "f4:4d:ad:05:58:36", + DisplayName = "Work Mac", + Hostname = "work-mac", + Type = "WIRED", + NetworkName = "Trusted", + Oui = "Apple" + }; + + var identity = ClientDashboardService.MapClientDetailToIdentity(client, "fd00::1234"); + + identity.Mac.Should().Be(client.Mac); + identity.DisplayName.Should().Be("Work Mac"); + identity.Ip.Should().Be("fd00::1234"); + identity.IsWired.Should().BeTrue(); + identity.NetworkName.Should().Be("Trusted"); + identity.IsOffline.Should().BeFalse(); + } + + [Fact] + public void TryGetMacFromNeighborOutput_MapsExactIpv6Neighbor() + { + var output = """ + fd00::1234 dev br2 lladdr f4:4d:ad:05:58:36 REACHABLE + fd00::5678 dev br2 lladdr 44:a7:f4:32:28:e0 STALE + """; + + ClientDashboardService.TryGetMacFromNeighborOutput(output, "fd00::1234") + .Should().Be("f4:4d:ad:05:58:36"); + } + + [Theory] + [InlineData("")] + [InlineData("fd00::1234 dev br2 FAILED")] + [InlineData("fd00::5678 dev br2 lladdr f4:4d:ad:05:58:36 REACHABLE")] + public void TryGetMacFromNeighborOutput_RejectsMissingOrDifferentEntries(string output) + { + ClientDashboardService.TryGetMacFromNeighborOutput(output, "fd00::1234") + .Should().BeNull(); + } + + [Fact] + public void MacEquals_IsCaseInsensitive() + { + ClientDashboardService.MacEquals("F4:4D:AD:05:58:36", "f4:4d:ad:05:58:36") + .Should().BeTrue(); + } +}