Pārlūkot izejas kodu

Harden network discovery: Windows ARP, shared-MAC hosts, ansible fit

Three integration fixes from reviewing how the scan sits with the rest
of the platform:

- Windows ARP support: arp.exe prints dash-separated MACs in a three-
  column table and only knows `arp -a` — the parser now reads that
  format (normalising to the same colon form as Linux/macOS, so the
  same machine hashes to the same id from any platform) and the probe
  falls back from `arp -an` to `arp -a`. Without this, every scan from
  the shipped win-x64 binary silently degraded to IP-seeded identity.

- One MAC answering on several addresses (a gateway's VIPs/aliases) now
  folds the address into each card's id seed instead of emitting
  duplicate ids, which the import rejects with a misleading machine-id
  hint. Deterministic per (mac, ip).

- The Ansible exporter now reads a System's own ip after the address
  labels, exactly like the ssh and hosts exporters already do — so
  discovered hosts are addressable in inventories without hand-adding
  labels, and an explicit ansible_host label still wins.

Verified against a real /24 that all previously-emitted discovery ids
are byte-identical after the mapper change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Tim Jones 11 stundas atpakaļ
vecāks
revīzija
e5ed5cf6de

+ 20 - 9
RackPeek.Domain/Discovery/ArpTableParser.cs

@@ -49,27 +49,38 @@ public static class ArpTableParser {
             return mac == null ? null : (ip, mac);
             return mac == null ? null : (ip, mac);
         }
         }
 
 
-        // Linux /proc/net/arp: "192.168.1.1  0x1  0x2  a4:91:b1:4e:3c:20  *  eth0"
         var columns = line.Split(' ', '\t', StringSplitOptions.RemoveEmptyEntries);
         var columns = line.Split(' ', '\t', StringSplitOptions.RemoveEmptyEntries);
 
 
-        if (columns.Length < 4 || !IsIpv4(columns[0]))
+        if (columns.Length < 2 || !IsIpv4(columns[0]))
             return null;
             return null;
 
 
-        // Flags 0x0 marks an entry the kernel gave up resolving.
-        if (columns[2] == "0x0")
-            return null;
+        // Linux /proc/net/arp: "192.168.1.1  0x1  0x2  a4:91:b1:4e:3c:20  *  eth0"
+        if (columns.Length >= 4 && columns[1].StartsWith("0x", StringComparison.Ordinal)) {
+            // Flags 0x0 marks an entry the kernel gave up resolving.
+            if (columns[2] == "0x0")
+                return null;
+
+            var linuxMac = NormaliseMac(columns[3]);
+
+            return linuxMac == null ? null : (columns[0], linuxMac);
+        }
 
 
-        var linuxMac = NormaliseMac(columns[3]);
+        // Windows arp -a: "192.168.1.1           a4-91-b1-4e-3c-20     dynamic"
+        var windowsMac = NormaliseMac(columns[1]);
 
 
-        return linuxMac == null ? null : (columns[0], linuxMac);
+        return windowsMac == null ? null : (columns[0], windowsMac);
     }
     }
 
 
-    /// <summary>Lowercase, zero-padded, or null for anything that is not a usable MAC.</summary>
+    /// <summary>
+    ///     Lowercase, colon-separated, zero-padded — or null for anything that is not a
+    ///     usable MAC. Accepts Windows' dash separators so the same machine hashes the
+    ///     same from every platform's ARP output.
+    /// </summary>
     public static string? NormaliseMac(string? raw) {
     public static string? NormaliseMac(string? raw) {
         if (string.IsNullOrWhiteSpace(raw))
         if (string.IsNullOrWhiteSpace(raw))
             return null;
             return null;
 
 
-        var parts = raw.Trim().Split(':');
+        var parts = raw.Trim().Split(':', '-');
 
 
         if (parts.Length != 6)
         if (parts.Length != 6)
             return null;
             return null;

+ 4 - 1
RackPeek.Domain/Discovery/NetworkProbe.cs

@@ -41,8 +41,11 @@ public sealed class NetworkProbe : INetworkProbe {
     }
     }
 
 
     public async Task<string?> ReadArpAsync(CancellationToken cancellationToken = default) {
     public async Task<string?> ReadArpAsync(CancellationToken cancellationToken = default) {
+        // Linux reads the kernel's file; BSD/macOS answer `arp -an`; Windows' arp.exe
+        // only knows `-a`. Each failed attempt is null, so the chain just walks on.
         return await SystemProbeCommon.TryReadFileAsync("/proc/net/arp", cancellationToken)
         return await SystemProbeCommon.TryReadFileAsync("/proc/net/arp", cancellationToken)
-               ?? await SystemProbeCommon.TryRunAsync("arp", "-an", cancellationToken);
+               ?? await SystemProbeCommon.TryRunAsync("arp", "-an", cancellationToken)
+               ?? await SystemProbeCommon.TryRunAsync("arp", "-a", cancellationToken);
     }
     }
 
 
     public async Task<string?> ReverseDnsAsync(string ip, CancellationToken cancellationToken = default) {
     public async Task<string?> ReverseDnsAsync(string ip, CancellationToken cancellationToken = default) {

+ 15 - 3
RackPeek.Domain/Discovery/NetworkScanMapper.cs

@@ -9,13 +9,25 @@ public static class NetworkScanMapper {
         var taken = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
         var taken = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
         var resources = new List<Resource>(hosts.Count);
         var resources = new List<Resource>(hosts.Count);
 
 
+        // One MAC answering on several addresses is one box with aliases or VIPs —
+        // gateways do this all the time. Each address still gets its own card, but the
+        // shared MAC alone cannot identify them: the import rejects duplicate ids.
+        var macCounts = hosts
+            .Where(h => h.Mac != null)
+            .GroupBy(h => h.Mac!)
+            .ToDictionary(g => g.Key, g => g.Count(), StringComparer.OrdinalIgnoreCase);
+
         foreach (NetworkHostFact host in hosts) {
         foreach (NetworkHostFact host in hosts) {
             // The MAC is the only identity a scan can see that survives a DHCP re-lease;
             // The MAC is the only identity a scan can see that survives a DHCP re-lease;
             // when ARP could not provide one (a routed subnet, say) the IP has to do,
             // when ARP could not provide one (a routed subnet, say) the IP has to do,
             // and the id changes if the address does — documented in the guide.
             // and the id changes if the address does — documented in the guide.
-            var discoveryId = DiscoveryId.Create(
-                DiscoveryId.NetworkScheme,
-                host.Mac ?? $"ip:{host.Ip}");
+            var seed = host.Mac == null
+                ? $"ip:{host.Ip}"
+                : macCounts[host.Mac] > 1
+                    ? $"{host.Mac}/{host.Ip}"
+                    : host.Mac;
+
+            var discoveryId = DiscoveryId.Create(DiscoveryId.NetworkScheme, seed);
 
 
             var system = new SystemResource {
             var system = new SystemResource {
                 Kind = SystemResource.KindLabel,
                 Kind = SystemResource.KindLabel,

+ 7 - 0
RackPeek.Domain/UseCases/Ansible/AnsibleInventoryGenerator.cs

@@ -1,5 +1,6 @@
 using System.Text;
 using System.Text;
 using RackPeek.Domain.Resources;
 using RackPeek.Domain.Resources;
+using RackPeek.Domain.Resources.SystemResources;
 
 
 namespace RackPeek.Domain.UseCases.Ansible;
 namespace RackPeek.Domain.UseCases.Ansible;
 
 
@@ -180,6 +181,12 @@ public static class AnsibleInventoryGenerator {
         if (r.Labels.TryGetValue("hostname", out var hn) && !string.IsNullOrWhiteSpace(hn))
         if (r.Labels.TryGetValue("hostname", out var hn) && !string.IsNullOrWhiteSpace(hn))
             return hn;
             return hn;
 
 
+        // A System's own address, the way the ssh and hosts exporters already read it —
+        // this is what makes discovered hosts addressable without hand-adding a label.
+        // Labels stay first: an explicit ansible_host must always win.
+        if (r is SystemResource { Ip: not null } system && !string.IsNullOrWhiteSpace(system.Ip))
+            return system.Ip;
+
         return null;
         return null;
     }
     }
 
 

+ 9 - 4
Shared.Rcl/wwwroot/raw_docs/ansible-generator-guide.md

@@ -21,10 +21,15 @@ Without this, the resource will not appear in inventory.
 
 
 RackPeek will also accept these alternatives if `ansible_host` is not provided:
 RackPeek will also accept these alternatives if `ansible_host` is not provided:
 
 
-| Label      | Used As      |
-| ---------- | ------------ |
-| `ip`       | ansible_host |
-| `hostname` | ansible_host |
+| Source              | Used As      |
+| ------------------- | ------------ |
+| `ip` label          | ansible_host |
+| `hostname` label    | ansible_host |
+| a System's own `ip` | ansible_host |
+
+So a System that carries an address — hand-written or found by
+[`rpk discover network`](/docs/discovery-guide) — is addressable without any labels;
+an explicit `ansible_host` label always wins when both are present.
 
 
 Example:
 Example:
 
 

+ 12 - 0
Tests.Discovery/ArpTableParserTests.cs

@@ -29,6 +29,17 @@ public class ArpTableParserTests {
         Assert.Equal(linux["192.168.1.20"], macos["192.168.1.20"]);
         Assert.Equal(linux["192.168.1.20"], macos["192.168.1.20"]);
     }
     }
 
 
+    [Fact]
+    public void The_windows_arp_output_parses_to_the_same_macs_as_linux() {
+        IReadOnlyDictionary<string, string> linux = ArpTableParser.Parse(Fixture.Read("linux-arp-table"));
+        IReadOnlyDictionary<string, string> windows = ArpTableParser.Parse(Fixture.Read("windows-arp-output"));
+
+        // Windows prints dashes and uppercase; the interface/header lines parse to nothing.
+        Assert.Equal(linux["192.168.1.1"], windows["192.168.1.1"]);
+        Assert.Equal(linux["192.168.1.20"], windows["192.168.1.20"]);
+        Assert.False(windows.ContainsKey("Interface:"));
+    }
+
     [Fact]
     [Fact]
     public void Unresolved_neighbours_contribute_nothing() {
     public void Unresolved_neighbours_contribute_nothing() {
         IReadOnlyDictionary<string, string> linux = ArpTableParser.Parse(Fixture.Read("linux-arp-table"));
         IReadOnlyDictionary<string, string> linux = ArpTableParser.Parse(Fixture.Read("linux-arp-table"));
@@ -53,6 +64,7 @@ public class ArpTableParserTests {
     [InlineData("A4:91:B1:4E:3C:20", "a4:91:b1:4e:3c:20")]
     [InlineData("A4:91:B1:4E:3C:20", "a4:91:b1:4e:3c:20")]
     [InlineData("1:0:5e:0:0:fb", "01:00:5e:00:00:fb")]
     [InlineData("1:0:5e:0:0:fb", "01:00:5e:00:00:fb")]
     [InlineData("dc:a6:32:f:11:22", "dc:a6:32:0f:11:22")]
     [InlineData("dc:a6:32:f:11:22", "dc:a6:32:0f:11:22")]
+    [InlineData("A4-91-B1-4E-3C-20", "a4:91:b1:4e:3c:20")] // Windows separators
     public void Macs_normalise_to_lowercase_padded_octets(string raw, string expected) =>
     public void Macs_normalise_to_lowercase_padded_octets(string raw, string expected) =>
         Assert.Equal(expected, ArpTableParser.NormaliseMac(raw));
         Assert.Equal(expected, ArpTableParser.NormaliseMac(raw));
 
 

+ 6 - 0
Tests.Discovery/Fixtures/windows-arp-output

@@ -0,0 +1,6 @@
+Interface: 192.168.1.100 --- 0xb
+  Internet Address      Physical Address      Type
+  192.168.1.1           a4-91-b1-4e-3c-20     dynamic
+  192.168.1.20          DC-A6-32-0F-11-22     dynamic
+  224.0.0.251           01-00-5e-00-00-fb     static
+  192.168.1.255         ff-ff-ff-ff-ff-ff     static

+ 18 - 0
Tests.Discovery/NetworkScanMapperTests.cs

@@ -77,6 +77,24 @@ public class NetworkScanMapperTests {
         Assert.StartsWith("router-", resources[1].Name);
         Assert.StartsWith("router-", resources[1].Name);
     }
     }
 
 
+    [Fact]
+    public void One_mac_answering_on_several_addresses_yields_distinct_stable_identities() {
+        // Gateways answer on VIPs and aliases all the time: one MAC, many addresses.
+        // The shared MAC alone cannot identify the cards — the import rejects duplicate
+        // ids — so each address folds into the seed, deterministically.
+        NetworkHostFact[] swept = [
+            Host(ip: "192.168.1.1", hostname: "gw.lan"),
+            Host(ip: "192.168.1.2", hostname: null)
+        ];
+
+        List<Resource> resources = NetworkScanMapper.ToResources(swept);
+
+        Assert.Equal(2, resources.Select(r => r.DiscoveryId).Distinct().Count());
+        Assert.Equal(
+            resources.Select(r => r.DiscoveryId),
+            NetworkScanMapper.ToResources(swept).Select(r => r.DiscoveryId));
+    }
+
     [Fact]
     [Fact]
     public void The_emitted_document_conforms_to_the_published_schema() {
     public void The_emitted_document_conforms_to_the_published_schema() {
         List<Resource> resources = NetworkScanMapper.ToResources([
         List<Resource> resources = NetworkScanMapper.ToResources([

+ 29 - 0
Tests/EndToEnd/ExporterTests/AnsibleInventoryWorkflowTests.cs

@@ -85,6 +85,35 @@ public class AnsibleInventoryWorkflowTests(
                      """, output);
                      """, output);
     }
     }
 
 
+    [Fact]
+    public async Task a_system_with_only_its_ip_field_is_still_addressable() {
+        // Discovered hosts carry an ip but no address labels; like the ssh and hosts
+        // exporters, the inventory reads the System's own address. An explicit
+        // ansible_host label still wins when both are present.
+        await File.WriteAllTextAsync(Path.Combine(fs.Root, "config.yaml"), """
+                                                                           version: 4
+                                                                           resources:
+                                                                           - kind: System
+                                                                             name: scanned-host
+                                                                             ip: 10.0.20.150
+                                                                             tags:
+                                                                             - lan
+                                                                           - kind: System
+                                                                             name: labelled-host
+                                                                             ip: 10.0.20.151
+                                                                             tags:
+                                                                             - lan
+                                                                             labels:
+                                                                               ansible_host: vpn.example.com
+
+                                                                           """);
+
+        (var output, var _) = await ExecuteAsync("ansible", "inventory", "--group-tags", "lan");
+
+        Assert.Contains("scanned-host ansible_host=10.0.20.150", output);
+        Assert.Contains("labelled-host ansible_host=vpn.example.com", output);
+    }
+
     [Fact]
     [Fact]
     public async Task ansible_inventory_yaml_output_test() {
     public async Task ansible_inventory_yaml_output_test() {
         await File.WriteAllTextAsync(Path.Combine(fs.Root, "config.yaml"), """
         await File.WriteAllTextAsync(Path.Combine(fs.Root, "config.yaml"), """