using RackPeek.Domain.Discovery;
using RackPeek.Domain.Resources.Services.Networking;
namespace Tests.Discovery;
///
/// The sweep's decisions, driven through a scripted probe: what counts as alive,
/// what IO happens for dead hosts, and that the concurrency cap actually caps.
/// The probe is the IO seam — everything above it is what these tests own.
///
public class NetworkScannerTests {
// Identification is off unless a test asks for it, so the liveness tests keep
// measuring only liveness.
private static NetworkScanOptions Options(string cidr = "10.0.0.0/30", params int[] ports) =>
new() {
Cidr = Cidr.Parse(cidr),
Ports = ports.Length > 0 ? ports : [22, 80],
PingTimeout = TimeSpan.FromMilliseconds(5),
PortTimeout = TimeSpan.FromMilliseconds(5),
IdentifyServices = false
};
private static NetworkScanOptions IdentifyingOptions(string cidr = "10.0.0.0/30") =>
Options(cidr) with {
IdentifyServices = true,
IdentifyTimeout = TimeSpan.FromMilliseconds(5)
};
[Fact]
public async Task The_port_list_the_liveness_check_cut_short_is_finished_for_the_living() {
var probe = new ScriptedProbe { OpenPorts = [("10.0.0.2", 22), ("10.0.0.2", 80)] };
IReadOnlyList hosts = await NetworkScanner.ScanAsync(
probe,
IdentifyingOptions() with { Ports = [22, 80] });
// Liveness stopped at 22; the interrogation pass goes back for the rest, so the
// card records what the host actually serves rather than the first thing tried.
Assert.Equal([22, 80], Assert.Single(hosts).OpenPorts);
}
[Fact]
public async Task A_host_that_answered_ping_still_gets_its_ports_inventoried() {
// Liveness skips port probing entirely once ping answers, which used to leave
// every pingable host with no port evidence at all.
var probe = new ScriptedProbe {
PingReplies = ["10.0.0.1"],
OpenPorts = [("10.0.0.1", 80)]
};
IReadOnlyList hosts = await NetworkScanner.ScanAsync(
probe,
IdentifyingOptions() with { Ports = [22, 80] });
Assert.Equal([80], Assert.Single(hosts).OpenPorts);
}
[Fact]
public async Task A_dead_host_is_never_interrogated() {
var probe = new ScriptedProbe();
await NetworkScanner.ScanAsync(probe, IdentifyingOptions() with { Ports = [22, 80] });
Assert.Empty(probe.IdentityProbes);
}
[Fact]
public async Task Only_living_hosts_are_asked_what_they_are() {
// 443 has to be open for it to be asked: only ports the sweep found listening
// are interrogated, so a closed port costs no handshake.
var probe = new ScriptedProbe {
PingReplies = ["10.0.0.1"],
OpenPorts = [("10.0.0.1", 443)],
TlsSubjects = { [("10.0.0.1", 443)] = "CN=router.lan" }
};
IReadOnlyList hosts = await NetworkScanner.ScanAsync(probe, IdentifyingOptions());
Assert.Equal("router.lan", Assert.Single(hosts).Identity?.Name);
// 10.0.0.2 answered nothing, so it was never asked.
Assert.DoesNotContain(probe.IdentityProbes, p => p.Ip == "10.0.0.2");
}
[Fact]
public async Task A_certificate_names_the_host_even_when_other_ports_also_answer() {
var probe = new ScriptedProbe {
PingReplies = ["10.0.0.1"],
OpenPorts = [("10.0.0.1", 443), ("10.0.0.1", 22)],
TlsSubjects = { [("10.0.0.1", 443)] = "CN=pve-node-01.example.com" },
Banners = { [("10.0.0.1", 22)] = "SSH-2.0-OpenSSH_9.6" }
};
IReadOnlyList hosts = await NetworkScanner.ScanAsync(probe, IdentifyingOptions());
NetworkHostFact host = Assert.Single(hosts);
// Every open port is asked — an SSH greeting is still worth recording — but a
// certificate is the only answer that is a claim about the machine itself.
Assert.Equal("pve-node-01.example.com", host.Identity?.Name);
Assert.Equal(IdentitySource.TlsCertificate, host.Identity?.Source);
}
[Fact]
public async Task A_page_title_outranks_an_ssh_greeting() {
var probe = new ScriptedProbe {
PingReplies = ["10.0.0.1"],
OpenPorts = [("10.0.0.1", 22), ("10.0.0.1", 80)],
Banners = { [("10.0.0.1", 22)] = "SSH-2.0-dropbear" },
HttpHeads = { [("10.0.0.1", 80)] = "HTTP/1.0 200 OK\r\n\r\nHome Assistant" }
};
IReadOnlyList hosts = await NetworkScanner.ScanAsync(probe, IdentifyingOptions());
// Every Linux box answers SSH with the same daemon name; the title says which
// box this actually is, so SSH is asked last.
Assert.Equal("Home Assistant", Assert.Single(hosts).Identity?.Name);
}
[Fact]
public async Task An_open_port_the_sweep_found_is_also_asked() {
// The liveness sweep already paid to learn 8123 was open. A service on a port
// nobody curated is exactly the one worth asking.
var probe = new ScriptedProbe {
OpenPorts = [("10.0.0.2", 8123)],
HttpHeads = { [("10.0.0.2", 8123)] = "HTTP/1.0 200 OK\r\n\r\nHome Assistant" }
};
IReadOnlyList hosts = await NetworkScanner.ScanAsync(
probe,
IdentifyingOptions() with { Ports = [8123] });
NetworkHostFact host = Assert.Single(hosts);
Assert.Equal("Home Assistant", host.Identity?.Name);
Assert.Equal(8123, host.Identity?.Port);
}
[Fact]
public async Task A_host_that_says_nothing_is_still_reported() {
var probe = new ScriptedProbe { PingReplies = ["10.0.0.1"] };
IReadOnlyList hosts = await NetworkScanner.ScanAsync(probe, IdentifyingOptions());
NetworkHostFact host = Assert.Single(hosts);
Assert.Null(host.Identity);
Assert.Equal("10.0.0.1", host.Ip);
}
[Fact]
public async Task Turning_identification_off_asks_nothing() {
var probe = new ScriptedProbe {
PingReplies = ["10.0.0.1"],
TlsSubjects = { [("10.0.0.1", 443)] = "CN=router.lan" }
};
IReadOnlyList hosts = await NetworkScanner.ScanAsync(probe, Options());
Assert.Null(Assert.Single(hosts).Identity);
Assert.Empty(probe.IdentityProbes);
}
[Fact]
public async Task A_hosts_vendor_comes_from_its_arp_mac() {
var probe = new ScriptedProbe {
PingReplies = ["10.0.0.1"],
Arp = "? (10.0.0.1) at bc:24:11:00:1a:01 on en0 ifscope [ethernet]"
};
IReadOnlyList hosts = await NetworkScanner.ScanAsync(probe, Options());
Assert.Equal("Proxmox", Assert.Single(hosts).Vendor);
}
[Fact]
public async Task A_host_with_no_arp_entry_has_no_vendor() {
var probe = new ScriptedProbe { PingReplies = ["10.0.0.1"] };
IReadOnlyList hosts = await NetworkScanner.ScanAsync(probe, Options());
Assert.Null(Assert.Single(hosts).Vendor);
}
[Fact]
public async Task A_host_that_answers_nothing_is_not_reported() {
var probe = new ScriptedProbe();
IReadOnlyList hosts = await NetworkScanner.ScanAsync(probe, Options());
Assert.Empty(hosts);
}
[Fact]
public async Task A_ping_reply_alone_makes_a_host_alive_and_skips_its_port_probes() {
var probe = new ScriptedProbe { PingReplies = ["10.0.0.1"] };
IReadOnlyList hosts = await NetworkScanner.ScanAsync(probe, Options());
NetworkHostFact host = Assert.Single(hosts);
Assert.Equal("10.0.0.1", host.Ip);
Assert.True(host.AnsweredPing);
// Liveness is already proven; knocking on ports would just be noise on the wire.
Assert.DoesNotContain(probe.PortProbes, p => p.Ip == "10.0.0.1");
}
[Fact]
public async Task A_host_that_drops_ping_but_serves_tcp_is_still_alive() {
var probe = new ScriptedProbe { OpenPorts = [("10.0.0.2", 80)] };
IReadOnlyList hosts = await NetworkScanner.ScanAsync(probe, Options());
NetworkHostFact host = Assert.Single(hosts);
Assert.Equal("10.0.0.2", host.Ip);
Assert.False(host.AnsweredPing);
Assert.Equal([80], host.OpenPorts);
}
[Fact]
public async Task Port_probing_stops_at_the_first_answer() {
var probe = new ScriptedProbe { OpenPorts = [("10.0.0.2", 22), ("10.0.0.2", 80)] };
await NetworkScanner.ScanAsync(probe, Options());
// 22 answered, so 80 was never asked: the sweep proves liveness, not a port map.
Assert.Equal([("10.0.0.2", 22)], probe.PortProbes.Where(p => p.Ip == "10.0.0.2"));
}
[Fact]
public async Task The_arp_table_is_read_after_the_sweep_and_names_resolve_only_for_the_living() {
var probe = new ScriptedProbe {
PingReplies = ["10.0.0.1"],
Arp = "? (10.0.0.1) at a4:91:b1:4e:3c:20 on en0 ifscope [ethernet]",
Names = { ["10.0.0.1"] = "router.lan" }
};
IReadOnlyList hosts = await NetworkScanner.ScanAsync(probe, Options());
Assert.True(probe.ArpReadAfterSweep,
"ARP must be read after the sweep — the sweep's own probes populate it.");
Assert.Equal("a4:91:b1:4e:3c:20", hosts[0].Mac);
Assert.Equal("router.lan", hosts[0].Hostname);
Assert.Equal(["10.0.0.1"], probe.DnsLookups); // dead hosts get no PTR queries
}
[Fact]
public async Task Results_come_back_in_address_order_whatever_order_probes_finished() {
var probe = new ScriptedProbe { PingReplies = ["10.0.0.2", "10.0.0.1"] };
IReadOnlyList hosts = await NetworkScanner.ScanAsync(probe, Options());
Assert.Equal(["10.0.0.1", "10.0.0.2"], hosts.Select(h => h.Ip));
}
[Fact]
public async Task No_more_hosts_are_probed_at_once_than_the_options_allow() {
var probe = new ScriptedProbe { PingDelay = TimeSpan.FromMilliseconds(20) };
NetworkScanOptions options = Options("10.0.0.0/24") with { Concurrency = 4 };
await NetworkScanner.ScanAsync(probe, options);
Assert.True(probe.MaxInFlight <= 4,
$"{probe.MaxInFlight} hosts were probed at once; the cap was 4.");
}
[Fact]
public async Task A_block_wider_than_the_cap_is_refused_wherever_it_came_from() {
// The floor lives in the scanner, not a front end: an auto-detected VPN /10
// must hit the same wall a typed --cidr does.
await Assert.ThrowsAsync(() =>
NetworkScanner.ScanAsync(new ScriptedProbe(), Options("10.0.0.0/8")));
}
/// Scripted IO: answers what it is told to, records what was asked of it.
private sealed class ScriptedProbe : INetworkProbe {
private readonly Lock _lock = new();
private int _inFlight;
private bool _sweepDone;
public List PingReplies { get; init; } = [];
public List<(string Ip, int Port)> OpenPorts { get; init; } = [];
public string? Arp { get; init; }
public Dictionary Names { get; } = [];
public TimeSpan PingDelay { get; init; } = TimeSpan.Zero;
public Dictionary<(string Ip, int Port), string> TlsSubjects { get; } = [];
public Dictionary<(string Ip, int Port), string> Banners { get; } = [];
public Dictionary<(string Ip, int Port), string> HttpHeads { get; } = [];
public List<(string Ip, int Port)> PortProbes { get; } = [];
public List DnsLookups { get; } = [];
public List<(string Ip, int Port, string Kind)> IdentityProbes { get; } = [];
public bool IsSupported => true;
public int MaxInFlight { get; private set; }
public bool ArpReadAfterSweep { get; private set; }
public async Task PingAsync(string ip, TimeSpan timeout, CancellationToken cancellationToken = default) {
lock (_lock) {
_inFlight++;
MaxInFlight = Math.Max(MaxInFlight, _inFlight);
}
try {
if (PingDelay > TimeSpan.Zero)
await Task.Delay(PingDelay, cancellationToken);
return PingReplies.Contains(ip);
}
finally {
lock (_lock) {
_inFlight--;
}
}
}
public Task TryConnectAsync(
string ip,
int port,
TimeSpan timeout,
CancellationToken cancellationToken = default) {
lock (_lock) {
PortProbes.Add((ip, port));
}
return Task.FromResult(OpenPorts.Contains((ip, port)));
}
public Task ReadArpAsync(CancellationToken cancellationToken = default) {
lock (_lock) {
_sweepDone = true;
ArpReadAfterSweep = _inFlight == 0;
}
return Task.FromResult(Arp);
}
public Task ReverseDnsAsync(
string ip,
TimeSpan timeout,
CancellationToken cancellationToken = default) {
lock (_lock) {
if (!_sweepDone)
throw new InvalidOperationException("Reverse DNS ran before the sweep finished.");
DnsLookups.Add(ip);
}
return Task.FromResult(Names.GetValueOrDefault(ip));
}
public Task ReadTlsSubjectAsync(
string ip,
int port,
TimeSpan timeout,
CancellationToken cancellationToken = default) {
lock (_lock) {
IdentityProbes.Add((ip, port, "tls"));
}
return Task.FromResult(TlsSubjects.GetValueOrDefault((ip, port)));
}
public Task ReadTcpBannerAsync(
string ip,
int port,
TimeSpan timeout,
CancellationToken cancellationToken = default) {
lock (_lock) {
IdentityProbes.Add((ip, port, "banner"));
}
return Task.FromResult(Banners.GetValueOrDefault((ip, port)));
}
public Task ReadHttpHeadAsync(
string ip,
int port,
bool tls,
TimeSpan timeout,
CancellationToken cancellationToken = default) {
lock (_lock) {
IdentityProbes.Add((ip, port, "http"));
}
return Task.FromResult(HttpHeads.GetValueOrDefault((ip, port)));
}
public Cidr? LocalSubnet() => null;
}
}