فهرست منبع

Make every open port a Service, and name labels for what they are

An open port was landing in an `open-ports` label: a comma-separated string
nothing could link to, filter on, or hang a note from. RackPeek already has a
resource for "a thing listening on an address and a port", so each open port
now becomes a Service with its own stable id, running on the host that serves
it — nebula-ssh, nebula-https, nebula-proxmox.

What a service said about itself still beats what its port number implies,
because a port is a convention and an answer is evidence. Where nothing
answered the name falls back to the host plus the port's usual service, and an
unrecognised port keeps its number as nebula-tcp-9987 rather than guessing. An
appliance whose management page only says its own name back is named for the
port instead, so a firewall called opnsense gains an opnsense-https rather than
a second card called opnsense.

Two labels change with it. `vendor` becomes `nic-vendor`, because the OUI
identifies whoever owns the network interface, which is not the same claim as
who made the machine — a Proxmox guest's virtual NIC reads Proxmox while the
box underneath it is a Dell. And `segment` goes: it described the firewall's
wiring rather than the machine, and changed whenever anything was re-cabled.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Tim Jones 16 ساعت پیش
والد
کامیت
a5143e3662

+ 34 - 25
RackPeek.Domain/Discovery/NetworkScanMapper.cs

@@ -45,15 +45,11 @@ public static class NetworkScanMapper {
             if (host.Mac != null)
                 system.Labels["mac"] = host.Mac;
 
+            // Named for what it is: the organisation that owns the NIC's OUI, which is
+            // not the same claim as who made the machine — a Proxmox guest's NIC says
+            // Proxmox while the box underneath it is a Dell.
             if (host.Vendor != null)
-                system.Labels["vendor"] = host.Vendor;
-
-            // The ports are observations, not conclusions: "554 is open" is a fact, while
-            // "this is a camera" is an inference the reader is far better placed to make
-            // than the scanner. Recording them keeps the evidence without inventing a
-            // service that may not be what the port number conventionally implies.
-            if (host.OpenPorts.Count > 0)
-                system.Labels["open-ports"] = string.Join(",", host.OpenPorts);
+                system.Labels["nic-vendor"] = host.Vendor;
 
             // Kept even when the name came from somewhere else: it records what the host
             // actually said, which is how someone judges whether the name is trustworthy.
@@ -66,35 +62,48 @@ public static class NetworkScanMapper {
 
             resources.Add(system);
 
-            // An application that named itself over HTTP is a fact about what the host
-            // runs, not about what the host is, so it becomes a Service hanging off the
-            // card rather than renaming it.
-            foreach (ServiceIdentity found in host.Services) {
-                // An appliance's own management UI is not a service running on it: a
-                // firewall whose page says "OPNsense" on a card already called opnsense
-                // would otherwise get a second card named opnsense-<hash>, which says
-                // nothing the first one did not.
-                if (DiscoveryNaming.Slug(DiscoveryNaming.HostLabel(found.Name))
-                    .Equals(system.Name, StringComparison.OrdinalIgnoreCase))
-                    continue;
+            // Something listening on a port is a service, and a service is a resource in
+            // its own right rather than a note on the host. The host says where it is;
+            // each port says what it serves.
+            IReadOnlyDictionary<int, ServiceIdentity> identified =
+                host.Services.ToDictionary(s => s.Port);
+
+            // A port that answered a banner probe is open by definition, so the two lists
+            // agree in practice — but an identified service must not go missing if they
+            // ever disagree.
+            IEnumerable<int> ports = host.OpenPorts
+                .Concat(identified.Keys)
+                .Distinct();
 
+            foreach (var port in ports) {
                 var serviceId = DiscoveryId.Create(
                     DiscoveryId.NetworkScheme,
-                    $"{host.Mac ?? $"ip:{host.Ip}"}:{found.Port}");
+                    $"{host.Mac ?? $"ip:{host.Ip}"}:{port}");
+
+                // What the service said about itself beats what its port number implies,
+                // because a port is a convention and an answer is evidence. The exception
+                // is an appliance whose management page just says its own name back: a
+                // second card called opnsense tells no one anything, where an opnsense-https
+                // sitting on opnsense says exactly what it is.
+                var announced = identified.TryGetValue(port, out ServiceIdentity? found)
+                    ? DiscoveryNaming.HostLabel(found.Name)
+                    : string.Empty;
+
+                var serviceLabel = announced.Length > 0
+                                   && !announced.Equals(system.Name, StringComparison.OrdinalIgnoreCase)
+                    ? announced
+                    : $"{system.Name}-{WellKnownPorts.NameFor(port)}";
 
                 resources.Add(new Service {
                     Kind = Service.KindLabel,
                     Name = DiscoveryNaming.Unique(
-                        DiscoveryNaming.Suggest(
-                            DiscoveryNaming.HostLabel(found.Name),
-                            "service",
-                            serviceId),
+                        DiscoveryNaming.Suggest(serviceLabel, "service", serviceId),
                         serviceId,
                         taken),
                     DiscoveryId = serviceId,
                     Network = new Network {
                         Ip = host.Ip,
-                        Port = found.Port,
+                        Port = port,
                         Protocol = "TCP"
                     },
                     RunsOn = [system.Name]

+ 3 - 6
RackPeek.Domain/Discovery/OpnsenseDiscovery.cs

@@ -82,13 +82,10 @@ public static class OpnsenseDiscovery {
             // prefixes the curated table leaves out.
             var vendor = MacVendorLookup.Lookup(neighbour.Mac) ?? neighbour.Manufacturer;
 
+            // The organisation that owns the NIC's OUI — not a claim about who made the
+            // machine, which is a different thing entirely.
             if (vendor != null)
-                system.Labels["vendor"] = vendor;
-
-            // Which leg of the firewall saw it — the closest thing to a physical location
-            // the firewall can offer, and the thing that says which VLAN a host is on.
-            if (neighbour.Interface != null)
-                system.Labels["segment"] = neighbour.Interface;
+                system.Labels["nic-vendor"] = vendor;
 
             resources.Add(system);
         }

+ 47 - 0
RackPeek.Domain/Discovery/WellKnownPorts.cs

@@ -47,4 +47,51 @@ public static class WellKnownPorts {
         11434, // ollama
         32400 // plex
     ];
+
+    /// <summary>
+    ///     What a port conventionally carries, for naming the service found on it. A port
+    ///     number is a convention rather than a guarantee, so this only ever supplies a
+    ///     name — anything a service actually said about itself wins over it.
+    /// </summary>
+    private static readonly Dictionary<int, string> _names = new() {
+        [21] = "ftp",
+        [22] = "ssh",
+        [23] = "telnet",
+        [25] = "smtp",
+        [53] = "dns",
+        [80] = "http",
+        [443] = "https",
+        [445] = "smb",
+        [554] = "rtsp",
+        [631] = "ipp",
+        [1883] = "mqtt",
+        [2375] = "docker",
+        [2376] = "docker",
+        [3000] = "http",
+        [3306] = "mysql",
+        [3389] = "rdp",
+        [5000] = "http",
+        [5432] = "postgres",
+        [5900] = "vnc",
+        [6379] = "redis",
+        [7860] = "http",
+        [8000] = "http",
+        [8006] = "proxmox",
+        [8080] = "http",
+        [8096] = "jellyfin",
+        [8123] = "home-assistant",
+        [8443] = "https",
+        [9000] = "http",
+        [9090] = "http",
+        [9100] = "jetdirect",
+        [11434] = "ollama",
+        [32400] = "plex"
+    };
+
+    /// <summary>
+    ///     The conventional name for a port, or <c>tcp-1234</c> when nobody curated one —
+    ///     which still says more than the bare number, and stays stable across runs.
+    /// </summary>
+    public static string NameFor(int port) =>
+        _names.TryGetValue(port, out var name) ? name : $"tcp-{port}";
 }

+ 6 - 5
Tests.Discovery/NetworkIdentityTests.cs

@@ -124,9 +124,10 @@ public class NetworkIdentityTests {
     }
 
     [Fact]
-    public void An_appliances_own_management_page_is_not_a_service_on_itself() {
+    public void An_appliances_own_management_page_is_named_for_the_port_it_serves() {
         // A firewall whose page says "OPNsense" on a card already called opnsense would
-        // otherwise gain a second card named opnsense-<hash> saying nothing new.
+        // otherwise gain a second card named opnsense-<hash>, saying nothing new. The
+        // port is what distinguishes the service from the box it runs on.
         List<Resource> cards = NetworkScanMapper.ToResources([
             Host(
                 "192.0.2.101",
@@ -135,7 +136,7 @@ public class NetworkIdentityTests {
         ]);
 
         Assert.Equal("opnsense", Assert.Single(cards.OfType<SystemResource>()).Name);
-        Assert.Empty(cards.OfType<Service>());
+        Assert.Equal("opnsense-http", Assert.Single(cards.OfType<Service>()).Name);
     }
 
     [Fact]
@@ -159,7 +160,7 @@ public class NetworkIdentityTests {
     public void A_vendor_is_labelled_when_the_mac_is_known() {
         SystemResource card = Single(Host("192.0.2.64", "80:f3:da:00:1a:06", vendor: "Espressif"));
 
-        Assert.Equal("Espressif", card.Labels["vendor"]);
+        Assert.Equal("Espressif", card.Labels["nic-vendor"]);
         Assert.Equal("80:f3:da:00:1a:06", card.Labels["mac"]);
     }
 
@@ -167,7 +168,7 @@ public class NetworkIdentityTests {
     public void No_vendor_label_is_invented_when_none_is_known() {
         SystemResource card = Single(Host("192.0.2.111", "00:00:00:11:22:33"));
 
-        Assert.False(card.Labels.ContainsKey("vendor"));
+        Assert.False(card.Labels.ContainsKey("nic-vendor"));
     }
 
     [Fact]

+ 115 - 0
Tests.Discovery/OpenPortServiceTests.cs

@@ -0,0 +1,115 @@
+using RackPeek.Domain.Discovery;
+using RackPeek.Domain.Resources;
+using RackPeek.Domain.Resources.Services;
+using RackPeek.Domain.Resources.SystemResources;
+
+namespace Tests.Discovery;
+
+/// <summary>
+///     An open port is a service, not a note on the host.
+///     <para>
+///         It used to land in an "open-ports" label — a comma-separated string that
+///         nothing could link to, filter on, or hang a note from. RackPeek already has a
+///         resource for "a thing listening on an address and a port", so a sweep that
+///         finds 22 open on nebula should produce a Service called nebula-ssh running on
+///         nebula, exactly as if someone had written it in by hand.
+///     </para>
+/// </summary>
+public class OpenPortServiceTests {
+    private static List<Resource> Scan(string ip, string? mac, params int[] ports) =>
+        NetworkScanMapper.ToResources([
+            new NetworkHostFact(ip, mac, "nebula", true, ports)
+        ]);
+
+    private static List<Service> Services(params int[] ports) =>
+        Scan("192.0.2.20", "bc:24:11:00:2a:01", ports).OfType<Service>().ToList();
+
+    [Fact]
+    public void An_open_port_is_no_longer_a_label() {
+        SystemResource host = Scan("192.0.2.20", "bc:24:11:00:2a:01", 22, 80)
+            .OfType<SystemResource>()
+            .Single();
+
+        Assert.False(host.Labels.ContainsKey("open-ports"));
+    }
+
+    [Fact]
+    public void Each_open_port_becomes_a_service_on_the_host() {
+        List<Service> services = Services(22, 80);
+
+        Assert.Equal(2, services.Count);
+        Assert.All(services, s => Assert.Equal(["nebula"], s.RunsOn));
+    }
+
+    [Fact]
+    public void A_service_is_named_for_the_host_and_what_the_port_serves() {
+        Service ssh = Assert.Single(Services(22));
+
+        Assert.Equal("nebula-ssh", ssh.Name);
+    }
+
+    [Theory]
+    [InlineData(443, "nebula-https")]
+    [InlineData(445, "nebula-smb")]
+    [InlineData(1883, "nebula-mqtt")]
+    [InlineData(8006, "nebula-proxmox")]
+    [InlineData(32400, "nebula-plex")]
+    public void Well_known_ports_are_named_by_what_they_serve(int port, string expected) =>
+        Assert.Equal(expected, Assert.Single(Services(port)).Name);
+
+    [Fact]
+    // Better an honest tcp-9987 than a guess: the number is the only fact available.
+    public void An_unrecognised_port_keeps_its_number() =>
+        Assert.Equal("nebula-tcp-9987", Assert.Single(Services(9987)).Name);
+
+    [Fact]
+    public void A_service_that_named_itself_beats_what_its_port_implies() {
+        // A port is a convention and an answer is evidence. Home Assistant on 8123 is
+        // the convention; a page that says "Forgejo" is the machine telling you.
+        List<Resource> resources = NetworkScanMapper.ToResources([
+            new NetworkHostFact("192.0.2.21", "bc:24:11:00:2a:02", "nebula", true, [8123]) {
+                Services = [new ServiceIdentity("Forgejo", IdentitySource.Http, 8123)]
+            }
+        ]);
+
+        Assert.Equal("forgejo", Assert.Single(resources.OfType<Service>()).Name);
+    }
+
+    [Fact]
+    public void A_service_records_where_it_is_listening() {
+        Service ssh = Assert.Single(Services(22));
+
+        Assert.Equal("192.0.2.20", ssh.Network?.Ip);
+        Assert.Equal(22, ssh.Network?.Port);
+        Assert.Equal("TCP", ssh.Network?.Protocol);
+    }
+
+    [Fact]
+    public void A_services_identity_is_its_hosts_identity_and_the_port() {
+        // Stable across rescans, and distinct per port, so a rescan updates the same two
+        // cards rather than inventing a pair every time.
+        var first = Services(22, 80).Select(s => s.DiscoveryId).ToList();
+        var second = Services(22, 80).Select(s => s.DiscoveryId).ToList();
+
+        Assert.Equal(first, second);
+        Assert.Equal(2, first.Distinct().Count());
+    }
+
+    [Fact]
+    public void Two_hosts_running_the_same_thing_get_distinct_cards() {
+        // Naming after the host is what keeps these apart — "ssh" alone would collide on
+        // every machine in the rack.
+        List<Resource> resources = NetworkScanMapper.ToResources([
+            new NetworkHostFact("192.0.2.30", "bc:24:11:00:2a:03", "nebula", true, [22]),
+            new NetworkHostFact("192.0.2.31", "bc:24:11:00:2a:04", "orion", true, [22])
+        ]);
+
+        Assert.Equal(
+            ["nebula-ssh", "orion-ssh"],
+            resources.OfType<Service>().Select(s => s.Name).Order());
+    }
+
+    [Fact]
+    public void A_host_with_nothing_listening_yields_no_services() =>
+        Assert.Empty(Scan("192.0.2.40", "bc:24:11:00:2a:05").OfType<Service>());
+}

+ 12 - 4
Tests.Discovery/OpnsenseDiscoveryTests.cs

@@ -95,15 +95,23 @@ public class OpnsenseDiscoveryTests {
     }
 
     [Fact]
-    public void A_card_carries_the_mac_the_vendor_and_the_leg_it_was_seen_on() {
+    public void A_card_carries_the_mac_and_who_made_the_nic() {
         SystemResource card = Discover().OfType<SystemResource>().Single(s => s.Name == "forgejo");
 
         Assert.Equal("bc:24:11:00:1a:04", card.Labels["mac"]);
-        Assert.Equal("Proxmox", card.Labels["vendor"]);
-        Assert.Equal("HomeServices", card.Labels["segment"]);
+        Assert.Equal("Proxmox", card.Labels["nic-vendor"]);
         Assert.Equal("192.168.50.105", card.Ip);
     }
 
+    [Fact]
+    public void Which_leg_of_the_firewall_it_answered_on_is_not_recorded() {
+        // It describes the firewall's wiring, not the machine, and it changes the moment
+        // anything is re-cabled or a VLAN is renamed.
+        SystemResource card = Discover().OfType<SystemResource>().Single(s => s.Name == "forgejo");
+
+        Assert.False(card.Labels.ContainsKey("segment"));
+    }
+
     [Fact]
     public void The_firewalls_own_vendor_lookup_fills_the_gaps_in_ours() {
         // Ours is a curated subset, so it answers "Proxmox" where the firewall says
@@ -112,7 +120,7 @@ public class OpnsenseDiscoveryTests {
         List<Resource> cards = Discover(true);
 
         SystemResource wan = cards.OfType<SystemResource>().Single(s => s.Ip == "198.51.100.7");
-        Assert.Equal("Cisco Systems", wan.Labels["vendor"]);
+        Assert.Equal("Cisco Systems", wan.Labels["nic-vendor"]);
     }
 
     [Fact]