using RackPeek.Domain.Resources.Services.Networking;
namespace RackPeek.Domain.Discovery;
///
/// Sweeps a subnet and reports the hosts that answered. A host counts as alive when
/// it answers ping OR accepts a TCP connect on any probed port — plenty of gear
/// drops ICMP, and plenty of gear with ICMP open runs no interesting service, so
/// neither signal alone is enough. All IO goes through .
///
public static class NetworkScanner {
///
/// The widest block a sweep accepts, wherever the block came from — typed by the
/// user or auto-detected off a NIC. Wider than this is 65k+ hosts: a typo or a
/// CGNAT/VPN prefix, not a homelab.
///
public const int MinPrefix = 16;
///
/// Every address worth probing in the block: hosts only, so the network and
/// broadcast addresses are skipped — except in /31 (RFC 3021 point-to-point)
/// and /32, where every address is a host.
///
public static IEnumerable EnumerateTargets(Cidr cidr) {
// 64-bit throughout: 1u << 32 wraps under C#'s masked shift, and a block that
// touches 255.255.255.255 would overflow the loop bound in 32 bits.
var size = 1UL << (32 - cidr.Prefix);
var first = cidr.Prefix >= 31 ? cidr.Network : (ulong)cidr.Network + 1;
var last = cidr.Prefix >= 31
? cidr.Network + size - 1
: cidr.Network + size - 2;
for (var ip = first; ip <= last; ip++)
yield return IpHelper.ToIp((uint)ip);
}
public static async Task> ScanAsync(
INetworkProbe probe,
NetworkScanOptions options,
CancellationToken cancellationToken = default) {
// Enforced here rather than only at a front end, so every caller — CLI flag,
// auto-detected subnet, future MCP tool — hits the same wall.
if (options.Cidr.Prefix < MinPrefix)
throw new ArgumentOutOfRangeException(
nameof(options),
$"/{options.Cidr.Prefix} is more than 65,534 hosts. Narrow the sweep to /{MinPrefix} or smaller.");
var targets = EnumerateTargets(options.Cidr).ToList();
using var gate = new SemaphoreSlim(options.Concurrency);
(string Ip, bool Ping, List Open)?[] swept = await Task.WhenAll(
targets.Select(ip => SweepHostAsync(probe, options, ip, gate, cancellationToken)));
var alive = swept.Where(h => h != null).Select(h => h!.Value).ToList();
// Read the ARP table only after the sweep: it is the sweep's own pings and
// connects that put the neighbours into it.
IReadOnlyDictionary macByIp =
ArpTableParser.Parse(await probe.ReadArpAsync(cancellationToken));
// Names resolve in parallel too — a resolver that drops PTR queries burns the
// full timeout per lookup, and paying that once beats paying it per host.
var names = await Task.WhenAll(alive.Select(async h => {
await gate.WaitAsync(cancellationToken);
try {
return await probe.ReverseDnsAsync(h.Ip, options.DnsTimeout, cancellationToken);
}
finally {
gate.Release();
}
}));
var facts = new List(alive.Count);
for (var i = 0; i < alive.Count; i++) {
(var ip, var ping, List open) = alive[i];
facts.Add(new NetworkHostFact(ip, macByIp.GetValueOrDefault(ip), names[i], ping, open));
}
return facts
.OrderBy(f => IpHelper.ToUInt32(f.Ip))
.ToList();
}
/// One host's liveness check; null when nothing answered.
private static async Task<(string Ip, bool Ping, List Open)?> SweepHostAsync(
INetworkProbe probe,
NetworkScanOptions options,
string ip,
SemaphoreSlim gate,
CancellationToken cancellationToken) {
await gate.WaitAsync(cancellationToken);
try {
var ping = await probe.PingAsync(ip, options.PingTimeout, cancellationToken);
var open = new List();
// Liveness needs one answer, not a port inventory: a ping reply skips the
// port probes entirely, and probing stops at the first open port.
if (!ping)
foreach (var port in options.Ports) {
if (!await probe.TryConnectAsync(ip, port, options.PortTimeout, cancellationToken))
continue;
open.Add(port);
break;
}
return ping || open.Count > 0 ? (ip, ping, open) : null;
}
finally {
gate.Release();
}
}
}