OpnsenseDiscoveryTests.cs 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177
  1. using RackPeek.Domain.Discovery;
  2. using RackPeek.Domain.Resources;
  3. using RackPeek.Domain.Resources.SystemResources;
  4. namespace Tests.Discovery;
  5. /// <summary>
  6. /// A firewall's neighbour table in, System resources out.
  7. /// <para>
  8. /// This is the collector for everything a sweep can see but not identify. ARP is
  9. /// link-local, so sweeping from one host yields a MAC for that host's own segment
  10. /// and an address for everything else — and an address alone cannot survive a
  11. /// DHCP re-lease or be matched to anything. The firewall routes every subnet, so
  12. /// its table has the MAC, the name it handed out, and which leg it was seen on.
  13. /// </para>
  14. /// </summary>
  15. public class OpnsenseDiscoveryTests {
  16. private static List<OpnsenseNeighbour> Neighbours() =>
  17. OpnsenseResponseParser.ParseArp(Fixture.Read("opnsense-arp.json"));
  18. private static List<Resource> Discover(bool includePublic = false) =>
  19. OpnsenseDiscovery.ToResources(Neighbours(), includePublic);
  20. [Fact]
  21. public void The_table_yields_one_neighbour_per_machine() {
  22. List<OpnsenseNeighbour> neighbours = Neighbours();
  23. // Of seven rows: one is the firewall's own address, one has aged out, one is the
  24. // broadcast address, and one is a second sighting of a host that answers on two
  25. // of the firewall's legs.
  26. Assert.Equal(3, neighbours.Count);
  27. }
  28. [Fact]
  29. // Every routed subnet contributes one, and they are all the same box — which is a
  30. // Firewall, not the handful of Systems this would otherwise invent.
  31. public void An_address_the_firewall_holds_itself_is_not_a_neighbour() =>
  32. Assert.DoesNotContain(Neighbours(), n => n.Mac == "00:1b:21:00:1a:02");
  33. [Fact]
  34. // It says where something used to be, which is not evidence that it is there now.
  35. public void An_entry_that_has_aged_out_is_not_a_neighbour() =>
  36. Assert.DoesNotContain(Neighbours(), n => n.Hostname == "gone-away");
  37. [Fact]
  38. public void The_broadcast_address_is_not_a_machine() =>
  39. Assert.DoesNotContain(Neighbours(), n => n.Mac.StartsWith("ff:", StringComparison.Ordinal));
  40. [Fact]
  41. // Its identity is the MAC, so two sightings must not become two machines.
  42. public void A_host_seen_on_two_legs_is_still_one_card() =>
  43. Assert.Single(Discover().OfType<SystemResource>(), s => s.Labels["mac"] == "1c:6a:1b:00:1a:03");
  44. [Fact]
  45. // It is not the user's infrastructure, and recording it would put a public address
  46. // into a file people commit.
  47. public void The_isps_equipment_on_the_wan_leg_is_left_out_by_default() =>
  48. Assert.DoesNotContain(Discover().OfType<SystemResource>(), s => s.Ip == "198.51.100.7");
  49. [Fact]
  50. // A fleet documented on public addresses is a real case; it just is not the default.
  51. public void Public_neighbours_can_be_asked_for() =>
  52. Assert.Contains(Discover(true).OfType<SystemResource>(), s => s.Ip == "198.51.100.7");
  53. [Theory]
  54. [InlineData("10.0.0.5")]
  55. [InlineData("172.16.4.9")]
  56. [InlineData("172.31.255.254")]
  57. [InlineData("192.168.1.1")]
  58. [InlineData("100.64.0.1")] // carrier-grade NAT
  59. public void Private_and_carrier_ranges_count_as_ones_own_network(string ip) =>
  60. Assert.True(OpnsenseDiscovery.IsPrivate(ip));
  61. [Theory]
  62. [InlineData("198.51.100.7")]
  63. [InlineData("8.8.8.8")]
  64. [InlineData("172.15.0.1")] // just below the private block
  65. [InlineData("172.32.0.1")] // just above it
  66. [InlineData("100.63.0.1")] // just below the carrier block
  67. [InlineData("not-an-address")]
  68. public void Everything_else_does_not(string ip) =>
  69. Assert.False(OpnsenseDiscovery.IsPrivate(ip));
  70. [Fact]
  71. // The whole point: a sweep of another subnet can only call this host-<hash>.
  72. public void The_name_the_firewall_handed_out_becomes_the_card_name() =>
  73. Assert.Contains(Discover().OfType<SystemResource>(), s => s.Name == "forgejo");
  74. [Fact]
  75. public void A_neighbour_with_no_name_still_gets_a_stable_one() {
  76. List<Resource> cards = Discover(true);
  77. SystemResource wan = cards.OfType<SystemResource>().Single(s => s.Ip == "198.51.100.7");
  78. Assert.StartsWith("host-", wan.Name);
  79. }
  80. [Fact]
  81. public void A_card_carries_the_mac_and_who_made_the_nic() {
  82. SystemResource card = Discover().OfType<SystemResource>().Single(s => s.Name == "forgejo");
  83. Assert.Equal("bc:24:11:00:1a:04", card.Labels["mac"]);
  84. Assert.Equal("Proxmox", card.Labels["nic-vendor"]);
  85. Assert.Equal("192.168.50.105", card.Ip);
  86. }
  87. [Fact]
  88. public void Which_leg_of_the_firewall_it_answered_on_is_not_recorded() {
  89. // It describes the firewall's wiring, not the machine, and it changes the moment
  90. // anything is re-cabled or a VLAN is renamed.
  91. SystemResource card = Discover().OfType<SystemResource>().Single(s => s.Name == "forgejo");
  92. Assert.False(card.Labels.ContainsKey("segment"));
  93. }
  94. [Fact]
  95. public void The_firewalls_own_vendor_lookup_fills_the_gaps_in_ours() {
  96. // Ours is a curated subset, so it answers "Proxmox" where the firewall says
  97. // "Proxmox Server Solutions GmbH" — but the firewall carries the whole registry
  98. // and answers for prefixes ours has never heard of.
  99. List<Resource> cards = Discover(true);
  100. SystemResource wan = cards.OfType<SystemResource>().Single(s => s.Ip == "198.51.100.7");
  101. Assert.Equal("Cisco Systems", wan.Labels["nic-vendor"]);
  102. }
  103. [Fact]
  104. public void A_card_is_seeded_exactly_as_a_sweep_would_seed_it() {
  105. // The contract that makes the two collectors interchangeable: same evidence, same
  106. // identity, so a host the firewall knows and a host a sweep found are one card
  107. // whichever ran first — with no special case anywhere to say so.
  108. SystemResource fromFirewall = Discover().OfType<SystemResource>().Single(s => s.Name == "forgejo");
  109. SystemResource fromSweep = NetworkScanMapper.ToResources([
  110. new NetworkHostFact("192.168.50.105", "bc:24:11:00:1a:04", null, true, [])
  111. ]).OfType<SystemResource>().Single();
  112. Assert.Equal(fromSweep.DiscoveryId, fromFirewall.DiscoveryId);
  113. }
  114. [Fact]
  115. public void The_cards_stay_sparse() {
  116. // The firewall knows where a machine is and what its NIC is, never what runs on
  117. // it. A guess here would overwrite the real values on the next run of a collector
  118. // that does know.
  119. SystemResource card = Discover().OfType<SystemResource>().Single(s => s.Name == "forgejo");
  120. Assert.Null(card.Type);
  121. Assert.Null(card.Os);
  122. Assert.Null(card.Cores);
  123. Assert.Null(card.Ram);
  124. }
  125. [Fact]
  126. public void The_paginated_shape_reads_the_same_as_the_flat_one() {
  127. // The search wrapper returns {rows: [...]} while the direct call returns a bare
  128. // array; a caller should not have to know which endpoint answered.
  129. var rows = $$"""{"rows": {{Fixture.Read("opnsense-arp.json")}}, "total": 7}""";
  130. Assert.Equal(
  131. Neighbours().Select(n => n.Mac),
  132. OpnsenseResponseParser.ParseArp(rows).Select(n => n.Mac));
  133. }
  134. [Theory]
  135. [InlineData("""[{"mac":"bc:24:11:00:1a:04","ip":"192.168.50.105","permanent":"1"}]""")]
  136. [InlineData("""[{"mac":"bc:24:11:00:1a:04","ip":"192.168.50.105","permanent":1}]""")]
  137. [InlineData("""[{"mac":"bc:24:11:00:1a:04","ip":"192.168.50.105","permanent":true}]""")]
  138. public void A_flag_is_read_however_the_firmware_spells_it(string json) =>
  139. // OPNsense writes these as real booleans in some versions and as "1" in others.
  140. Assert.Empty(OpnsenseResponseParser.ParseArp(json));
  141. [Fact]
  142. public void An_empty_table_is_not_an_error() {
  143. Assert.Empty(OpnsenseResponseParser.ParseArp("[]"));
  144. Assert.Empty(OpnsenseResponseParser.ParseArp("""{"rows":[],"total":0}"""));
  145. }
  146. }