discovery/src/Discovery.Core/Services/Scanner.cs

295 lines
9.6 KiB
C#
Raw Normal View History

2025-09-24 22:10:04 +02:00
using Discovery.Models;
using System.Diagnostics;
using System.Net;
using System.Net.NetworkInformation;
using System.Net.Sockets;
using System.Runtime.InteropServices;
using System.Text.Json;
using System.Text.RegularExpressions;
namespace Discovery.Services;
public partial class Scanner<T> where T : Target
{
public ScannerState State => _state;
private CancellationTokenSource? _cts;
private readonly ScannerState _state = new();
private readonly static Dictionary<string, string>? _vendors = BuildVendorDictionary();
public async Task<ScannerState> ScanAsync(
SynchronizedCollection<T> targets,
ScannerOptions options,
ScannerEvents events,
CancellationToken cancellationToken = default)
{
// cancel if already running
if (_state.Operation == ScanOperation.Scanning && _cts != null)
{
await _cts.CancelAsync();
return _state;
}
// renew state
_state.Operation = ScanOperation.Scanning;
_state.Progress = 0;
// set cts
_cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
var orderedTargets = targets.InternalList.OrderBy(p => p.Index);
var start = Stopwatch.GetTimestamp();
try
{
var tasks = new List<Task>(targets.InternalList.Count);
var con = new SemaphoreSlim(options.Concurrency);
var pingOptions = new PingOptions
{
DontFragment = options.DontFragment,
Ttl = options.Ttl
};
foreach (var target in targets.InternalList)
{
tasks.Add(Task.Run(async () =>
{
try
{
await con.WaitAsync(cancellationToken).ConfigureAwait(false);
target.Ping = await PingAsync(target.IPAddress, options.PingTimeout, null, pingOptions, cancellationToken).ConfigureAwait(false);
target.Mac = await ArpAsync(target.IPAddress, options.ArpTimeout, cancellationToken).ConfigureAwait(false);
if (target.Mac != null)
{
target.Arp = true;
target.Vendor = ResolveVendor(target.Mac!);
}
// local resolve fix
string? sAddress = null;
if (TryGetPrimaryInterface(out var pInterface, out var pAddress))
sAddress = pAddress?.ToString();
if (target.Address == sAddress)
{
target.Hostname = Dns.GetHostName();
}
else if (options.Nameservers == null)
{
target.Hostname = "N/A";
}
else
{
if (target.Online || (!target.Online && options.ResolveAll))
{
var result = await ResolveHostnameAsync(target.IPAddress, options.Nameservers, cancellationToken).ConfigureAwait(false);
if (result != null)
target.Hostname = result;
}
}
}
catch (OperationCanceledException) { }
finally
{
_state.Progress += (100 / (double)targets.InternalList.Count);
if (events.OnStateUpdate != null)
await events.OnStateUpdate(_state);
con.Release();
}
}, cancellationToken));
}
await Task.WhenAll(tasks);
_state.Operation = ScanOperation.Idle;
}
catch (OperationCanceledException)
{
_state.Operation = ScanOperation.Aborted;
}
finally
{
_state.Progress = 100;
_state.Elapsed = Stopwatch.GetElapsedTime(start);
_cts = null;
}
return _state;
}
public async Task CancelAsync()
{
if (_cts == null)
return;
await _cts.CancelAsync().ConfigureAwait(false);
_state.Operation = ScanOperation.Aborted;
}
public bool TryGetPrimaryInterface(out NetworkInterface? pInterface, out IPAddress? pAddress)
{
pInterface = null;
pAddress = null;
try
{
using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, 0);
socket.Connect("8.8.8.8", 65530);
var ep = socket.LocalEndPoint as IPEndPoint;
if (ep?.Address == null)
return false;
pAddress = ep.Address;
pInterface = NetworkInterface
.GetAllNetworkInterfaces()
.FirstOrDefault(p => p
.GetIPProperties().UnicastAddresses.Any(x => x.Address.ToString() == ep.Address.ToString()));
if (pInterface == null)
return false;
return true;
}
catch
{
return false;
}
}
public NetworkInfo? GetPrimaryNetwork()
{
if (!TryGetPrimaryInterface(out var pInterface, out var pAddress))
return null;
var pUnicast = pInterface!.GetIPProperties().UnicastAddresses.Single(p => p.Address.ToString() == pAddress!.ToString());
if (!IPNetwork2.TryParse(pAddress!, pUnicast!.IPv4Mask, out var net))
return null;
var nameservers = pInterface!.GetIPProperties().DnsAddresses;
return new NetworkInfo(pAddress!, net.Network, net.Cidr, [.. nameservers]);
}
private static Dictionary<string, string> BuildVendorDictionary()
{
var dictionary = new Dictionary<string, string>();
using var fileStream = new FileStream("vendors.json", FileMode.Open, FileAccess.Read);
var vendors = JsonSerializer.Deserialize(
fileStream,
VendorsJsonContext.Default.VendorArray
) ?? throw new InvalidOperationException("vendors.json");
foreach (var item in vendors)
{
if (item is null)
continue;
if (item.MacPrefix == null || item.VendorName == null)
continue;
dictionary.Add(item.MacPrefix, item.VendorName);
}
return dictionary;
}
private static async Task<PingReply?> PingAsync(IPAddress address, TimeSpan timeout, byte[]? buffer, PingOptions? options, CancellationToken cancellationToken)
{
if (cancellationToken.IsCancellationRequested)
return null;
using var ping = new Ping();
var reply = await ping.SendPingAsync(address, timeout, buffer, options, cancellationToken: cancellationToken).ConfigureAwait(false);
ping.SendAsyncCancel();
return reply;
}
private static async Task<string?> ArpAsync(IPAddress address, TimeSpan timeout, CancellationToken cancellationToken)
{
if (cancellationToken.IsCancellationRequested) return null;
if (address.AddressFamily != AddressFamily.InterNetwork) return null; // IPv4 only
// 1) Try ARP cache (GetIpNetTable)
if (WinArpInterop.TryGetFromArpCache(address, out var mac))
return mac;
// 2) Active probe on a dedicated thread; still honor the timeout
using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
var probeTask = Task.Factory.StartNew(
() => WinArpInterop.ProbeWithSendArp(address),
linked.Token,
TaskCreationOptions.LongRunning | TaskCreationOptions.DenyChildAttach,
TaskScheduler.Default);
var completed = await Task.WhenAny(probeTask, Task.Delay(timeout, linked.Token)).ConfigureAwait(false);
if (completed != probeTask) return null;
linked.Cancel(); // cancel the delay if still pending
return await probeTask.ConfigureAwait(false);
}
private static async Task<string?> ResolveHostnameAsync(IPAddress address, List<IPAddress> nameservers, CancellationToken cancellationToken)
{
if (cancellationToken.IsCancellationRequested)
return null;
string? hostname = null;
for (int i = 0; i < nameservers.Count; i++)
{
using var process = new Process
{
StartInfo = new ProcessStartInfo
{
FileName = "nslookup",
Arguments = $"{address} {nameservers[i]}",
RedirectStandardOutput = true,
UseShellExecute = false,
CreateNoWindow = true
}
};
process.Start();
var output = await process.StandardOutput
.ReadToEndAsync(cancellationToken)
.ConfigureAwait(false);
await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
var match = NameRegex().Match(output);
if (match.Success)
hostname = match.Groups["Name"].Value;
if (hostname != null)
break;
}
return hostname;
}
private static string? ResolveVendor(string mac)
{
if (!_vendors!.TryGetValue(mac[..8].ToUpperInvariant(), out string? vendor))
return null;
return vendor;
}
[GeneratedRegex(@"Name:\s*(?<Name>[^\s]+)")]
private static partial Regex NameRegex();
}