using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; using System.Net; using System.Threading; using System.Threading.Tasks; using LanDiscovery.Models; namespace LanDiscovery.Services { public class ScanOrchestrator { private readonly IpRangeService _rangeService; private readonly PingScanner _pingScanner; private readonly ArpActiveScanner _arpScanner; private readonly ArpPassiveSniffer _passiveSniffer; private readonly OuiVendorLookupService _ouiService; private CancellationTokenSource? _cts; private ConcurrentDictionary _discoveredDevices; public event Action? DeviceDiscovered; public event Action? DeviceUpdated; public event Action? ProgressChanged; public event Action? StatusChanged; public event Action? ScanCompleted; public bool IsScanning => _cts != null && !_cts.IsCancellationRequested; private readonly NameResolutionService _nameService; private readonly DeviceClassifier _classifier; private readonly NetBiosScanner _netBiosScanner; public ScanOrchestrator() { _rangeService = new IpRangeService(); _pingScanner = new PingScanner(); _arpScanner = new ArpActiveScanner(); _passiveSniffer = new ArpPassiveSniffer(); _ouiService = new OuiVendorLookupService(); _nameService = new NameResolutionService(); _classifier = new DeviceClassifier(); _netBiosScanner = new NetBiosScanner(); _discoveredDevices = new ConcurrentDictionary(); } public async Task StartScanAsync(IEnumerable cidrRanges, NetworkInterfaceInfo? nic) { if (IsScanning) return; _cts = new CancellationTokenSource(); var token = _cts.Token; _discoveredDevices.Clear(); string gatewayIp = nic?.Gateway ?? ""; // Start passive listener if possible if (nic != null && nic.IpAddress != null) { _passiveSniffer.StartByIp(nic.IpAddress, OnPassiveDiscovery); } var progressReporter = new Progress<(int scanned, int total)>(p => { // We need to map local progress to global progress if we want accurate bar. // Or just show progress for current range. // Let's show "Scanning..." bar for current range for MVP responsiveness. // Or accumulated? Since we don't know total count of ALL ranges upfront easily without iterating all. // Simpler: Just 0-100% per range, looping. double percent = (double)p.scanned / p.total * 100; ProgressChanged?.Invoke(percent); }); try { foreach (var cidr in cidrRanges) { if (token.IsCancellationRequested) break; var ips = _rangeService.GetIpsInRange(cidr).ToList(); if (ips.Count == 0) continue; var first = ips.FirstOrDefault()?.ToString() ?? "?"; var last = ips.LastOrDefault()?.ToString() ?? "?"; StatusChanged?.Invoke($"Scanning {cidr} ({first} - {last}) ..."); // ARP Scan var sourceIp = IPAddress.Parse(nic?.IpAddress ?? "0.0.0.0"); await _arpScanner.ScanRangeAsync(ips, sourceIp, progressReporter, async (result) => { if (result.Found) { var ipStr = result.Ip.ToString(); var device = _discoveredDevices.GetOrAdd(ipStr, _ => new DiscoveredDevice { IpAddress = ipStr, FirstSeen = DateTime.Now, DeviceTypeHint = DeviceTypeHint.Unknown }); device.MacAddress = result.MacAddress; device.Vendor = _ouiService.GetVendor(result.MacAddress); device.LastSeen = DateTime.Now; // Initial Classification device.DeviceTypeHint = _classifier.Classify(device, gatewayIp); // Resolve Name (Async but we want to populate it) DeviceDiscovered?.Invoke(device); _ = Task.Run(async () => { var name = await _nameService.ResolveHostnameAsync(ipStr); if (!string.IsNullOrEmpty(name)) { device.Hostname = name; // Re-classify with new hostname device.DeviceTypeHint = _classifier.Classify(device, gatewayIp); DeviceUpdated?.Invoke(device); } }); } }, token); // Ping Scan (Enrichment) // We only ping known hosts OR we ping scan all again? // Original: Ping scan all IPs for RTT. // This is valuable. StatusChanged?.Invoke($"Pinging {cidr}..."); await _pingScanner.ScanRangeAsync(ips, progressReporter, async (result) => { if (result.Status == System.Net.NetworkInformation.IPStatus.Success) { var ipStr = result.Ip.ToString(); if (_discoveredDevices.TryGetValue(ipStr, out var device)) { device.PingMs = result.RoundtripTime; device.IsPingAlive = true; device.LastSeen = DateTime.Now; DeviceUpdated?.Invoke(device); } else { // Found by Ping but not ARP? // Add it var newDevice = _discoveredDevices.GetOrAdd(ipStr, _ => new DiscoveredDevice { IpAddress = ipStr, FirstSeen = DateTime.Now, LastSeen = DateTime.Now, IsPingAlive = true, PingMs = result.RoundtripTime, DeviceTypeHint = DeviceTypeHint.Unknown }); newDevice.DeviceTypeHint = _classifier.Classify(newDevice, gatewayIp); DeviceDiscovered?.Invoke(newDevice); newDevice.DeviceTypeHint = _classifier.Classify(newDevice, gatewayIp); DeviceDiscovered?.Invoke(newDevice); _ = Task.Run(async () => { // 1. Try NetBIOS (MAC + Name) - Good for routed subnets var nbResult = await _netBiosScanner.ScanAsync(IPAddress.Parse(ipStr)); if (nbResult.Found) { if (!string.IsNullOrEmpty(nbResult.MacAddress) && nbResult.MacAddress != "00-00-00-00-00-00") { newDevice.MacAddress = nbResult.MacAddress; newDevice.Vendor = _ouiService.GetVendor(nbResult.MacAddress); } if (!string.IsNullOrEmpty(nbResult.Hostname)) { newDevice.Hostname = nbResult.Hostname; } DeviceUpdated?.Invoke(newDevice); } // 2. Try DNS (PTR) var name = await _nameService.ResolveHostnameAsync(ipStr); if (!string.IsNullOrEmpty(name)) { // DNS name often overrides NetBIOS name (FQDN vs Short) // Or keeps NetBIOS if DNS fails newDevice.Hostname = name; } // Re-classify newDevice.DeviceTypeHint = _classifier.Classify(newDevice, gatewayIp); DeviceUpdated?.Invoke(newDevice); }); } } }, token); } } catch (OperationCanceledException) { StatusChanged?.Invoke("Scan Aborted"); } finally { _cts = null; _passiveSniffer.Stop(); StatusChanged?.Invoke("Scan Complete"); ScanCompleted?.Invoke(); } } private void OnPassiveDiscovery(PassiveDiscoveryResult result) { var ipStr = result.Ip.ToString(); bool isNew = !_discoveredDevices.ContainsKey(ipStr); // Re-normalize MAC string formattedMac = result.MacAddress.ToUpper().Replace(":", "-"); // Standardize to "00:11:22..." with colons if needed, but we used dashes earlier? // ArpActiveScanner used BitConverter default which is dash. // Let's stick to dash or colon? Reference image uses dash. // ArpPassiveSniffer.MacAddress is from PacketDotNet which uses Colon format typically? // Actually PhysicalAddress.ToString() is usually dashed. var device = _discoveredDevices.GetOrAdd(ipStr, _ => new DiscoveredDevice { IpAddress = ipStr, FirstSeen = DateTime.Now, DeviceTypeHint = DeviceTypeHint.Unknown }); device.MacAddress = formattedMac; device.Vendor = _ouiService.GetVendor(formattedMac); device.LastSeen = DateTime.Now; // If it's new, fire discovered. if (isNew) { DeviceDiscovered?.Invoke(device); } else { // Just update timestamp DeviceUpdated?.Invoke(device); } } public void StopScan() { _cts?.Cancel(); _passiveSniffer.Stop(); } } }