NetworkScannerTests.cs 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386
  1. using RackPeek.Domain.Discovery;
  2. using RackPeek.Domain.Resources.Services.Networking;
  3. namespace Tests.Discovery;
  4. /// <summary>
  5. /// The sweep's decisions, driven through a scripted probe: what counts as alive,
  6. /// what IO happens for dead hosts, and that the concurrency cap actually caps.
  7. /// The probe is the IO seam — everything above it is what these tests own.
  8. /// </summary>
  9. public class NetworkScannerTests {
  10. // Identification is off unless a test asks for it, so the liveness tests keep
  11. // measuring only liveness.
  12. private static NetworkScanOptions Options(string cidr = "10.0.0.0/30", params int[] ports) =>
  13. new() {
  14. Cidr = Cidr.Parse(cidr),
  15. Ports = ports.Length > 0 ? ports : [22, 80],
  16. PingTimeout = TimeSpan.FromMilliseconds(5),
  17. PortTimeout = TimeSpan.FromMilliseconds(5),
  18. IdentifyServices = false
  19. };
  20. private static NetworkScanOptions IdentifyingOptions(string cidr = "10.0.0.0/30") =>
  21. Options(cidr) with {
  22. IdentifyServices = true,
  23. IdentifyTimeout = TimeSpan.FromMilliseconds(5)
  24. };
  25. [Fact]
  26. public async Task The_port_list_the_liveness_check_cut_short_is_finished_for_the_living() {
  27. var probe = new ScriptedProbe { OpenPorts = [("10.0.0.2", 22), ("10.0.0.2", 80)] };
  28. IReadOnlyList<NetworkHostFact> hosts = await NetworkScanner.ScanAsync(
  29. probe,
  30. IdentifyingOptions() with { Ports = [22, 80] });
  31. // Liveness stopped at 22; the interrogation pass goes back for the rest, so the
  32. // card records what the host actually serves rather than the first thing tried.
  33. Assert.Equal([22, 80], Assert.Single(hosts).OpenPorts);
  34. }
  35. [Fact]
  36. public async Task A_host_that_answered_ping_still_gets_its_ports_inventoried() {
  37. // Liveness skips port probing entirely once ping answers, which used to leave
  38. // every pingable host with no port evidence at all.
  39. var probe = new ScriptedProbe {
  40. PingReplies = ["10.0.0.1"],
  41. OpenPorts = [("10.0.0.1", 80)]
  42. };
  43. IReadOnlyList<NetworkHostFact> hosts = await NetworkScanner.ScanAsync(
  44. probe,
  45. IdentifyingOptions() with { Ports = [22, 80] });
  46. Assert.Equal([80], Assert.Single(hosts).OpenPorts);
  47. }
  48. [Fact]
  49. public async Task A_dead_host_is_never_interrogated() {
  50. var probe = new ScriptedProbe();
  51. await NetworkScanner.ScanAsync(probe, IdentifyingOptions() with { Ports = [22, 80] });
  52. Assert.Empty(probe.IdentityProbes);
  53. }
  54. [Fact]
  55. public async Task Only_living_hosts_are_asked_what_they_are() {
  56. // 443 has to be open for it to be asked: only ports the sweep found listening
  57. // are interrogated, so a closed port costs no handshake.
  58. var probe = new ScriptedProbe {
  59. PingReplies = ["10.0.0.1"],
  60. OpenPorts = [("10.0.0.1", 443)],
  61. TlsSubjects = { [("10.0.0.1", 443)] = "CN=router.lan" }
  62. };
  63. IReadOnlyList<NetworkHostFact> hosts = await NetworkScanner.ScanAsync(probe, IdentifyingOptions());
  64. Assert.Equal("router.lan", Assert.Single(hosts).Identity?.Name);
  65. // 10.0.0.2 answered nothing, so it was never asked.
  66. Assert.DoesNotContain(probe.IdentityProbes, p => p.Ip == "10.0.0.2");
  67. }
  68. [Fact]
  69. public async Task A_certificate_names_the_host_even_when_other_ports_also_answer() {
  70. var probe = new ScriptedProbe {
  71. PingReplies = ["10.0.0.1"],
  72. OpenPorts = [("10.0.0.1", 443), ("10.0.0.1", 22)],
  73. TlsSubjects = { [("10.0.0.1", 443)] = "CN=pve-node-01.example.com" },
  74. Banners = { [("10.0.0.1", 22)] = "SSH-2.0-OpenSSH_9.6" }
  75. };
  76. IReadOnlyList<NetworkHostFact> hosts = await NetworkScanner.ScanAsync(probe, IdentifyingOptions());
  77. NetworkHostFact host = Assert.Single(hosts);
  78. // Every open port is asked — an SSH greeting is still worth recording — but a
  79. // certificate is the only answer that is a claim about the machine itself.
  80. Assert.Equal("pve-node-01.example.com", host.Identity?.Name);
  81. Assert.Equal(IdentitySource.TlsCertificate, host.Identity?.Source);
  82. }
  83. [Fact]
  84. public async Task A_page_title_outranks_an_ssh_greeting() {
  85. var probe = new ScriptedProbe {
  86. PingReplies = ["10.0.0.1"],
  87. OpenPorts = [("10.0.0.1", 22), ("10.0.0.1", 80)],
  88. Banners = { [("10.0.0.1", 22)] = "SSH-2.0-dropbear" },
  89. HttpHeads = { [("10.0.0.1", 80)] = "HTTP/1.0 200 OK\r\n\r\n<title>Home Assistant</title>" }
  90. };
  91. IReadOnlyList<NetworkHostFact> hosts = await NetworkScanner.ScanAsync(probe, IdentifyingOptions());
  92. // Every Linux box answers SSH with the same daemon name; the title says which
  93. // box this actually is, so SSH is asked last.
  94. Assert.Equal("Home Assistant", Assert.Single(hosts).Identity?.Name);
  95. }
  96. [Fact]
  97. public async Task An_open_port_the_sweep_found_is_also_asked() {
  98. // The liveness sweep already paid to learn 8123 was open. A service on a port
  99. // nobody curated is exactly the one worth asking.
  100. var probe = new ScriptedProbe {
  101. OpenPorts = [("10.0.0.2", 8123)],
  102. HttpHeads = { [("10.0.0.2", 8123)] = "HTTP/1.0 200 OK\r\n\r\n<title>Home Assistant</title>" }
  103. };
  104. IReadOnlyList<NetworkHostFact> hosts = await NetworkScanner.ScanAsync(
  105. probe,
  106. IdentifyingOptions() with { Ports = [8123] });
  107. NetworkHostFact host = Assert.Single(hosts);
  108. Assert.Equal("Home Assistant", host.Identity?.Name);
  109. Assert.Equal(8123, host.Identity?.Port);
  110. }
  111. [Fact]
  112. public async Task A_host_that_says_nothing_is_still_reported() {
  113. var probe = new ScriptedProbe { PingReplies = ["10.0.0.1"] };
  114. IReadOnlyList<NetworkHostFact> hosts = await NetworkScanner.ScanAsync(probe, IdentifyingOptions());
  115. NetworkHostFact host = Assert.Single(hosts);
  116. Assert.Null(host.Identity);
  117. Assert.Equal("10.0.0.1", host.Ip);
  118. }
  119. [Fact]
  120. public async Task Turning_identification_off_asks_nothing() {
  121. var probe = new ScriptedProbe {
  122. PingReplies = ["10.0.0.1"],
  123. TlsSubjects = { [("10.0.0.1", 443)] = "CN=router.lan" }
  124. };
  125. IReadOnlyList<NetworkHostFact> hosts = await NetworkScanner.ScanAsync(probe, Options());
  126. Assert.Null(Assert.Single(hosts).Identity);
  127. Assert.Empty(probe.IdentityProbes);
  128. }
  129. [Fact]
  130. public async Task A_hosts_vendor_comes_from_its_arp_mac() {
  131. var probe = new ScriptedProbe {
  132. PingReplies = ["10.0.0.1"],
  133. Arp = "? (10.0.0.1) at bc:24:11:00:1a:01 on en0 ifscope [ethernet]"
  134. };
  135. IReadOnlyList<NetworkHostFact> hosts = await NetworkScanner.ScanAsync(probe, Options());
  136. Assert.Equal("Proxmox", Assert.Single(hosts).Vendor);
  137. }
  138. [Fact]
  139. public async Task A_host_with_no_arp_entry_has_no_vendor() {
  140. var probe = new ScriptedProbe { PingReplies = ["10.0.0.1"] };
  141. IReadOnlyList<NetworkHostFact> hosts = await NetworkScanner.ScanAsync(probe, Options());
  142. Assert.Null(Assert.Single(hosts).Vendor);
  143. }
  144. [Fact]
  145. public async Task A_host_that_answers_nothing_is_not_reported() {
  146. var probe = new ScriptedProbe();
  147. IReadOnlyList<NetworkHostFact> hosts = await NetworkScanner.ScanAsync(probe, Options());
  148. Assert.Empty(hosts);
  149. }
  150. [Fact]
  151. public async Task A_ping_reply_alone_makes_a_host_alive_and_skips_its_port_probes() {
  152. var probe = new ScriptedProbe { PingReplies = ["10.0.0.1"] };
  153. IReadOnlyList<NetworkHostFact> hosts = await NetworkScanner.ScanAsync(probe, Options());
  154. NetworkHostFact host = Assert.Single(hosts);
  155. Assert.Equal("10.0.0.1", host.Ip);
  156. Assert.True(host.AnsweredPing);
  157. // Liveness is already proven; knocking on ports would just be noise on the wire.
  158. Assert.DoesNotContain(probe.PortProbes, p => p.Ip == "10.0.0.1");
  159. }
  160. [Fact]
  161. public async Task A_host_that_drops_ping_but_serves_tcp_is_still_alive() {
  162. var probe = new ScriptedProbe { OpenPorts = [("10.0.0.2", 80)] };
  163. IReadOnlyList<NetworkHostFact> hosts = await NetworkScanner.ScanAsync(probe, Options());
  164. NetworkHostFact host = Assert.Single(hosts);
  165. Assert.Equal("10.0.0.2", host.Ip);
  166. Assert.False(host.AnsweredPing);
  167. Assert.Equal([80], host.OpenPorts);
  168. }
  169. [Fact]
  170. public async Task Port_probing_stops_at_the_first_answer() {
  171. var probe = new ScriptedProbe { OpenPorts = [("10.0.0.2", 22), ("10.0.0.2", 80)] };
  172. await NetworkScanner.ScanAsync(probe, Options());
  173. // 22 answered, so 80 was never asked: the sweep proves liveness, not a port map.
  174. Assert.Equal([("10.0.0.2", 22)], probe.PortProbes.Where(p => p.Ip == "10.0.0.2"));
  175. }
  176. [Fact]
  177. public async Task The_arp_table_is_read_after_the_sweep_and_names_resolve_only_for_the_living() {
  178. var probe = new ScriptedProbe {
  179. PingReplies = ["10.0.0.1"],
  180. Arp = "? (10.0.0.1) at a4:91:b1:4e:3c:20 on en0 ifscope [ethernet]",
  181. Names = { ["10.0.0.1"] = "router.lan" }
  182. };
  183. IReadOnlyList<NetworkHostFact> hosts = await NetworkScanner.ScanAsync(probe, Options());
  184. Assert.True(probe.ArpReadAfterSweep,
  185. "ARP must be read after the sweep — the sweep's own probes populate it.");
  186. Assert.Equal("a4:91:b1:4e:3c:20", hosts[0].Mac);
  187. Assert.Equal("router.lan", hosts[0].Hostname);
  188. Assert.Equal(["10.0.0.1"], probe.DnsLookups); // dead hosts get no PTR queries
  189. }
  190. [Fact]
  191. public async Task Results_come_back_in_address_order_whatever_order_probes_finished() {
  192. var probe = new ScriptedProbe { PingReplies = ["10.0.0.2", "10.0.0.1"] };
  193. IReadOnlyList<NetworkHostFact> hosts = await NetworkScanner.ScanAsync(probe, Options());
  194. Assert.Equal(["10.0.0.1", "10.0.0.2"], hosts.Select(h => h.Ip));
  195. }
  196. [Fact]
  197. public async Task No_more_hosts_are_probed_at_once_than_the_options_allow() {
  198. var probe = new ScriptedProbe { PingDelay = TimeSpan.FromMilliseconds(20) };
  199. NetworkScanOptions options = Options("10.0.0.0/24") with { Concurrency = 4 };
  200. await NetworkScanner.ScanAsync(probe, options);
  201. Assert.True(probe.MaxInFlight <= 4,
  202. $"{probe.MaxInFlight} hosts were probed at once; the cap was 4.");
  203. }
  204. [Fact]
  205. public async Task A_block_wider_than_the_cap_is_refused_wherever_it_came_from() {
  206. // The floor lives in the scanner, not a front end: an auto-detected VPN /10
  207. // must hit the same wall a typed --cidr does.
  208. await Assert.ThrowsAsync<ArgumentOutOfRangeException>(() =>
  209. NetworkScanner.ScanAsync(new ScriptedProbe(), Options("10.0.0.0/8")));
  210. }
  211. /// <summary>Scripted IO: answers what it is told to, records what was asked of it.</summary>
  212. private sealed class ScriptedProbe : INetworkProbe {
  213. private readonly Lock _lock = new();
  214. private int _inFlight;
  215. private bool _sweepDone;
  216. public List<string> PingReplies { get; init; } = [];
  217. public List<(string Ip, int Port)> OpenPorts { get; init; } = [];
  218. public string? Arp { get; init; }
  219. public Dictionary<string, string> Names { get; } = [];
  220. public TimeSpan PingDelay { get; init; } = TimeSpan.Zero;
  221. public Dictionary<(string Ip, int Port), string> TlsSubjects { get; } = [];
  222. public Dictionary<(string Ip, int Port), string> Banners { get; } = [];
  223. public Dictionary<(string Ip, int Port), string> HttpHeads { get; } = [];
  224. public List<(string Ip, int Port)> PortProbes { get; } = [];
  225. public List<string> DnsLookups { get; } = [];
  226. public List<(string Ip, int Port, string Kind)> IdentityProbes { get; } = [];
  227. public bool IsSupported => true;
  228. public int MaxInFlight { get; private set; }
  229. public bool ArpReadAfterSweep { get; private set; }
  230. public async Task<bool> PingAsync(string ip, TimeSpan timeout, CancellationToken cancellationToken = default) {
  231. lock (_lock) {
  232. _inFlight++;
  233. MaxInFlight = Math.Max(MaxInFlight, _inFlight);
  234. }
  235. try {
  236. if (PingDelay > TimeSpan.Zero)
  237. await Task.Delay(PingDelay, cancellationToken);
  238. return PingReplies.Contains(ip);
  239. }
  240. finally {
  241. lock (_lock) {
  242. _inFlight--;
  243. }
  244. }
  245. }
  246. public Task<bool> TryConnectAsync(
  247. string ip,
  248. int port,
  249. TimeSpan timeout,
  250. CancellationToken cancellationToken = default) {
  251. lock (_lock) {
  252. PortProbes.Add((ip, port));
  253. }
  254. return Task.FromResult(OpenPorts.Contains((ip, port)));
  255. }
  256. public Task<string?> ReadArpAsync(CancellationToken cancellationToken = default) {
  257. lock (_lock) {
  258. _sweepDone = true;
  259. ArpReadAfterSweep = _inFlight == 0;
  260. }
  261. return Task.FromResult(Arp);
  262. }
  263. public Task<string?> ReverseDnsAsync(
  264. string ip,
  265. TimeSpan timeout,
  266. CancellationToken cancellationToken = default) {
  267. lock (_lock) {
  268. if (!_sweepDone)
  269. throw new InvalidOperationException("Reverse DNS ran before the sweep finished.");
  270. DnsLookups.Add(ip);
  271. }
  272. return Task.FromResult(Names.GetValueOrDefault(ip));
  273. }
  274. public Task<string?> ReadTlsSubjectAsync(
  275. string ip,
  276. int port,
  277. TimeSpan timeout,
  278. CancellationToken cancellationToken = default) {
  279. lock (_lock) {
  280. IdentityProbes.Add((ip, port, "tls"));
  281. }
  282. return Task.FromResult(TlsSubjects.GetValueOrDefault((ip, port)));
  283. }
  284. public Task<string?> ReadTcpBannerAsync(
  285. string ip,
  286. int port,
  287. TimeSpan timeout,
  288. CancellationToken cancellationToken = default) {
  289. lock (_lock) {
  290. IdentityProbes.Add((ip, port, "banner"));
  291. }
  292. return Task.FromResult(Banners.GetValueOrDefault((ip, port)));
  293. }
  294. public Task<string?> ReadHttpHeadAsync(
  295. string ip,
  296. int port,
  297. bool tls,
  298. TimeSpan timeout,
  299. CancellationToken cancellationToken = default) {
  300. lock (_lock) {
  301. IdentityProbes.Add((ip, port, "http"));
  302. }
  303. return Task.FromResult(HttpHeads.GetValueOrDefault((ip, port)));
  304. }
  305. public Cidr? LocalSubnet() => null;
  306. }
  307. }