NetworkIdentityTests.cs 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217
  1. using RackPeek.Domain.Discovery;
  2. using RackPeek.Domain.Resources;
  3. using RackPeek.Domain.Resources.Services;
  4. using RackPeek.Domain.Resources.SystemResources;
  5. namespace Tests.Discovery;
  6. /// <summary>
  7. /// How a swept host gets a name and a vendor. Sweeping a homelab that has no PTR
  8. /// records used to produce a page of "host-&lt;hash&gt;" cards; these tests own the
  9. /// rules that turn those into recognisable machines.
  10. /// </summary>
  11. public class NetworkIdentityTests {
  12. private static NetworkHostFact Host(
  13. string ip,
  14. string? mac = null,
  15. string? hostname = null,
  16. ServiceIdentity? identity = null,
  17. string? vendor = null,
  18. IReadOnlyList<ServiceIdentity>? services = null) =>
  19. new(ip, mac, hostname, true, []) {
  20. Identity = identity,
  21. Vendor = vendor,
  22. Services = services ?? []
  23. };
  24. private static SystemResource Single(params NetworkHostFact[] hosts) =>
  25. Assert.IsType<SystemResource>(Assert.Single(NetworkScanMapper.ToResources(hosts)));
  26. [Fact]
  27. public void A_certificate_name_becomes_the_card_name() {
  28. SystemResource card = Single(Host(
  29. "192.0.2.13",
  30. identity: new ServiceIdentity("pve-node-01.example.com", IdentitySource.TlsCertificate, 8006)));
  31. // The label is the host part, as it is for a PTR name.
  32. Assert.Equal("pve-node-01", card.Name);
  33. }
  34. [Fact]
  35. public void A_ptr_record_still_wins_over_a_service_banner() {
  36. // The network's own answer beats whatever a certificate happens to say — a
  37. // stale or copied certificate must never rename a host DNS already knows.
  38. SystemResource card = Single(Host(
  39. "192.0.2.13",
  40. hostname: "pve-01.lan",
  41. identity: new ServiceIdentity("pve-node-01.example.com", IdentitySource.TlsCertificate, 8006)));
  42. Assert.Equal("pve-01", card.Name);
  43. }
  44. [Fact]
  45. public void Without_any_name_the_card_still_falls_back_to_the_hash() {
  46. SystemResource card = Single(Host("192.0.2.111"));
  47. Assert.StartsWith("host-", card.Name);
  48. }
  49. [Fact]
  50. public void What_the_host_said_is_recorded_even_when_it_named_the_card() {
  51. SystemResource card = Single(Host(
  52. "192.0.2.204",
  53. identity: new ServiceIdentity("Home Assistant", IdentitySource.Http, 8123)));
  54. // Whoever reads the card can see the name came from an HTTP title on 8123 and
  55. // judge it accordingly, rather than trusting a name of unknown provenance.
  56. Assert.Equal("http:8123 Home Assistant", card.Labels["identified-by"]);
  57. }
  58. [Fact]
  59. public void An_ssh_greeting_annotates_but_never_names() {
  60. // Every Linux box on a subnet runs the same daemon. Naming from the greeting
  61. // produced five cards called "openssh" on a real sweep — no more use than five
  62. // called "host-<hash>", and misleading about what was actually identified.
  63. SystemResource card = Single(Host(
  64. "192.0.2.209",
  65. identity: new ServiceIdentity("OpenSSH", IdentitySource.SshBanner, 22)));
  66. Assert.StartsWith("host-", card.Name);
  67. Assert.Equal("ssh:22 OpenSSH", card.Labels["identified-by"]);
  68. }
  69. [Fact]
  70. public void An_application_that_named_itself_over_http_becomes_a_service_on_its_host() {
  71. List<Resource> cards = NetworkScanMapper.ToResources([
  72. Host("192.0.2.204", services: [new ServiceIdentity("Home Assistant", IdentitySource.Http, 8123)])
  73. ]);
  74. SystemResource host = Assert.Single(cards.OfType<SystemResource>());
  75. Service service = Assert.Single(cards.OfType<Service>());
  76. Assert.Equal("home-assistant", service.Name);
  77. Assert.Equal([host.Name], service.RunsOn);
  78. Assert.Equal("192.0.2.204", service.Network?.Ip);
  79. Assert.Equal(8123, service.Network?.Port);
  80. }
  81. [Fact]
  82. public void A_page_title_names_the_service_and_leaves_the_host_a_hash() {
  83. // A host may run several applications, so naming the machine after whichever
  84. // answered first is arbitrary. Only a certificate names the machine itself.
  85. List<Resource> cards = NetworkScanMapper.ToResources([
  86. Host("192.0.2.204", services: [new ServiceIdentity("Home Assistant", IdentitySource.Http, 8123)])
  87. ]);
  88. Assert.StartsWith("host-", Assert.Single(cards.OfType<SystemResource>()).Name);
  89. }
  90. [Fact]
  91. public void Several_applications_on_one_host_each_get_their_own_service() {
  92. List<Resource> cards = NetworkScanMapper.ToResources([
  93. Host("192.0.2.105", services: [
  94. new ServiceIdentity("Portainer", IdentitySource.Http, 9000),
  95. new ServiceIdentity("Grafana", IdentitySource.Http, 3000)
  96. ])
  97. ]);
  98. var services = cards.OfType<Service>().ToList();
  99. Assert.Equal(2, services.Count);
  100. Assert.Equal([9000, 3000], services.Select(s => s.Network!.Port));
  101. // Each port is its own thing, so each keeps its own stable id.
  102. Assert.Equal(2, services.Select(s => s.DiscoveryId).Distinct().Count());
  103. }
  104. [Fact]
  105. public void An_appliances_own_management_page_is_named_for_the_port_it_serves() {
  106. // A firewall whose page says "OPNsense" on a card already called opnsense would
  107. // otherwise gain a second card named opnsense-<hash>, saying nothing new. The
  108. // port is what distinguishes the service from the box it runs on.
  109. List<Resource> cards = NetworkScanMapper.ToResources([
  110. Host(
  111. "192.0.2.101",
  112. identity: new ServiceIdentity("OPNsense.localdomain", IdentitySource.TlsCertificate, 443),
  113. services: [new ServiceIdentity("OPNsense", IdentitySource.Http, 80)])
  114. ]);
  115. Assert.Equal("opnsense", Assert.Single(cards.OfType<SystemResource>()).Name);
  116. Assert.Equal("opnsense-http", Assert.Single(cards.OfType<Service>()).Name);
  117. }
  118. [Fact]
  119. public void A_services_id_survives_a_rescan_and_differs_per_port() {
  120. NetworkHostFact host = Host(
  121. "192.0.2.105",
  122. "bc:24:11:00:1a:01",
  123. services: [
  124. new ServiceIdentity("Portainer", IdentitySource.Http, 9000),
  125. new ServiceIdentity("Grafana", IdentitySource.Http, 3000)
  126. ]);
  127. var first = NetworkScanMapper.ToResources([host]).OfType<Service>().Select(s => s.DiscoveryId).ToList();
  128. var second = NetworkScanMapper.ToResources([host]).OfType<Service>().Select(s => s.DiscoveryId).ToList();
  129. Assert.Equal(first, second);
  130. Assert.Equal(2, first.Distinct().Count());
  131. }
  132. [Fact]
  133. public void A_vendor_is_labelled_when_the_mac_is_known() {
  134. SystemResource card = Single(Host("192.0.2.64", "80:f3:da:00:1a:06", vendor: "Espressif"));
  135. Assert.Equal("Espressif", card.Labels["nic-vendor"]);
  136. Assert.Equal("80:f3:da:00:1a:06", card.Labels["mac"]);
  137. }
  138. [Fact]
  139. public void No_vendor_label_is_invented_when_none_is_known() {
  140. SystemResource card = Single(Host("192.0.2.111", "00:00:00:11:22:33"));
  141. Assert.False(card.Labels.ContainsKey("nic-vendor"));
  142. }
  143. [Fact]
  144. public void The_card_stays_sparse_whatever_identification_found() {
  145. // The sparseness contract: a scan sees an address, never an OS or a core count.
  146. // Anything written here would overwrite the real values on the next rescan of a
  147. // card an agent collector has since filled in.
  148. SystemResource card = Single(Host(
  149. "192.0.2.13",
  150. "bc:24:11:00:1a:01",
  151. identity: new ServiceIdentity("pve-node-01.example.com", IdentitySource.TlsCertificate, 8006),
  152. vendor: "Proxmox"));
  153. Assert.Null(card.Type);
  154. Assert.Null(card.Os);
  155. Assert.Null(card.Cores);
  156. Assert.Null(card.Ram);
  157. Assert.Equal("192.0.2.13", card.Ip);
  158. }
  159. [Fact]
  160. public void Identity_does_not_move_a_hosts_discovery_id() {
  161. // Identity is seeded on the MAC; a name learned from a service must not change
  162. // it, or every card would be recreated the day someone fixes a certificate.
  163. NetworkHostFact withoutName = Host("192.0.2.13", "bc:24:11:00:1a:01");
  164. NetworkHostFact withName = Host(
  165. "192.0.2.13",
  166. "bc:24:11:00:1a:01",
  167. identity: new ServiceIdentity("pve-node-01", IdentitySource.TlsCertificate, 8006));
  168. Assert.Equal(
  169. Single(withoutName).DiscoveryId,
  170. Single(withName).DiscoveryId);
  171. }
  172. [Fact]
  173. public void Two_hosts_naming_themselves_the_same_still_get_distinct_names() {
  174. // Every OPNsense VLAN gateway presents the same certificate CN.
  175. List<Resource> cards = NetworkScanMapper.ToResources([
  176. Host("192.0.2.101", identity: new ServiceIdentity("OPNsense.localdomain", IdentitySource.TlsCertificate, 443)),
  177. Host("192.0.2.141", identity: new ServiceIdentity("OPNsense.localdomain", IdentitySource.TlsCertificate, 443))
  178. ]);
  179. Assert.Equal(2, cards.Select(c => c.Name).Distinct(StringComparer.OrdinalIgnoreCase).Count());
  180. }
  181. }