NetworkScanner.cs 4.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118
  1. using RackPeek.Domain.Resources.Services.Networking;
  2. namespace RackPeek.Domain.Discovery;
  3. /// <summary>
  4. /// Sweeps a subnet and reports the hosts that answered. A host counts as alive when
  5. /// it answers ping OR accepts a TCP connect on any probed port — plenty of gear
  6. /// drops ICMP, and plenty of gear with ICMP open runs no interesting service, so
  7. /// neither signal alone is enough. All IO goes through <see cref="INetworkProbe" />.
  8. /// </summary>
  9. public static class NetworkScanner {
  10. /// <summary>
  11. /// The widest block a sweep accepts, wherever the block came from — typed by the
  12. /// user or auto-detected off a NIC. Wider than this is 65k+ hosts: a typo or a
  13. /// CGNAT/VPN prefix, not a homelab.
  14. /// </summary>
  15. public const int MinPrefix = 16;
  16. /// <summary>
  17. /// Every address worth probing in the block: hosts only, so the network and
  18. /// broadcast addresses are skipped — except in /31 (RFC 3021 point-to-point)
  19. /// and /32, where every address is a host.
  20. /// </summary>
  21. public static IEnumerable<string> EnumerateTargets(Cidr cidr) {
  22. // 64-bit throughout: 1u << 32 wraps under C#'s masked shift, and a block that
  23. // touches 255.255.255.255 would overflow the loop bound in 32 bits.
  24. var size = 1UL << (32 - cidr.Prefix);
  25. var first = cidr.Prefix >= 31 ? cidr.Network : (ulong)cidr.Network + 1;
  26. var last = cidr.Prefix >= 31
  27. ? cidr.Network + size - 1
  28. : cidr.Network + size - 2;
  29. for (var ip = first; ip <= last; ip++)
  30. yield return IpHelper.ToIp((uint)ip);
  31. }
  32. public static async Task<IReadOnlyList<NetworkHostFact>> ScanAsync(
  33. INetworkProbe probe,
  34. NetworkScanOptions options,
  35. CancellationToken cancellationToken = default) {
  36. // Enforced here rather than only at a front end, so every caller — CLI flag,
  37. // auto-detected subnet, future MCP tool — hits the same wall.
  38. if (options.Cidr.Prefix < MinPrefix)
  39. throw new ArgumentOutOfRangeException(
  40. nameof(options),
  41. $"/{options.Cidr.Prefix} is more than 65,534 hosts. Narrow the sweep to /{MinPrefix} or smaller.");
  42. var targets = EnumerateTargets(options.Cidr).ToList();
  43. using var gate = new SemaphoreSlim(options.Concurrency);
  44. (string Ip, bool Ping, List<int> Open)?[] swept = await Task.WhenAll(
  45. targets.Select(ip => SweepHostAsync(probe, options, ip, gate, cancellationToken)));
  46. var alive = swept.Where(h => h != null).Select(h => h!.Value).ToList();
  47. // Read the ARP table only after the sweep: it is the sweep's own pings and
  48. // connects that put the neighbours into it.
  49. IReadOnlyDictionary<string, string> macByIp =
  50. ArpTableParser.Parse(await probe.ReadArpAsync(cancellationToken));
  51. // Names resolve in parallel too — a resolver that drops PTR queries burns the
  52. // full timeout per lookup, and paying that once beats paying it per host.
  53. var names = await Task.WhenAll(alive.Select(async h => {
  54. await gate.WaitAsync(cancellationToken);
  55. try {
  56. return await probe.ReverseDnsAsync(h.Ip, options.DnsTimeout, cancellationToken);
  57. }
  58. finally {
  59. gate.Release();
  60. }
  61. }));
  62. var facts = new List<NetworkHostFact>(alive.Count);
  63. for (var i = 0; i < alive.Count; i++) {
  64. (var ip, var ping, List<int> open) = alive[i];
  65. facts.Add(new NetworkHostFact(ip, macByIp.GetValueOrDefault(ip), names[i], ping, open));
  66. }
  67. return facts
  68. .OrderBy(f => IpHelper.ToUInt32(f.Ip))
  69. .ToList();
  70. }
  71. /// <summary>One host's liveness check; null when nothing answered.</summary>
  72. private static async Task<(string Ip, bool Ping, List<int> Open)?> SweepHostAsync(
  73. INetworkProbe probe,
  74. NetworkScanOptions options,
  75. string ip,
  76. SemaphoreSlim gate,
  77. CancellationToken cancellationToken) {
  78. await gate.WaitAsync(cancellationToken);
  79. try {
  80. var ping = await probe.PingAsync(ip, options.PingTimeout, cancellationToken);
  81. var open = new List<int>();
  82. // Liveness needs one answer, not a port inventory: a ping reply skips the
  83. // port probes entirely, and probing stops at the first open port.
  84. if (!ping)
  85. foreach (var port in options.Ports) {
  86. if (!await probe.TryConnectAsync(ip, port, options.PortTimeout, cancellationToken))
  87. continue;
  88. open.Add(port);
  89. break;
  90. }
  91. return ping || open.Count > 0 ? (ip, ping, open) : null;
  92. }
  93. finally {
  94. gate.Release();
  95. }
  96. }
  97. }