Просмотр исходного кода

Merge pull request #340 from Timmoth/feature/network-discovery

Feature/network discovery
Tim Jones 1 день назад
Родитель
Сommit
4624ffaa5d
46 измененных файлов с 2264 добавлено и 44 удалено
  1. 107 0
      RackPeek.Domain/Discovery/ArpTableParser.cs
  2. 11 0
      RackPeek.Domain/Discovery/DiscoveryId.cs
  3. 100 3
      RackPeek.Domain/Discovery/DiscoveryIdResolver.cs
  4. 40 0
      RackPeek.Domain/Discovery/INetworkProbe.cs
  5. 127 0
      RackPeek.Domain/Discovery/NetworkProbe.cs
  6. 34 0
      RackPeek.Domain/Discovery/NetworkScanFacts.cs
  7. 94 0
      RackPeek.Domain/Discovery/NetworkScanMapper.cs
  8. 118 0
      RackPeek.Domain/Discovery/NetworkScanner.cs
  9. 51 3
      RackPeek.Domain/Discovery/ProxmoxModels.cs
  10. 8 1
      RackPeek.Domain/Discovery/ProxmoxResourceMapper.cs
  11. 13 1
      RackPeek.Domain/Discovery/SystemFacts.cs
  12. 17 0
      RackPeek.Domain/Discovery/SystemFactsParser.cs
  13. 16 1
      RackPeek.Domain/Discovery/SystemProbeCommon.cs
  14. 8 1
      RackPeek.Domain/Discovery/SystemResourceMapper.cs
  15. 23 0
      RackPeek.Domain/Discovery/WellKnownPorts.cs
  16. 17 0
      RackPeek.Domain/Resources/Services/Networking/Cidr.cs
  17. 12 5
      RackPeek.Domain/Resources/Services/Networking/IpHelper.cs
  18. 1 6
      RackPeek.Domain/Resources/Services/UseCases/ServiceSubnetsUseCase.cs
  19. 1 0
      RackPeek.Domain/ServiceCollectionExtensions.cs
  20. 7 0
      RackPeek.Domain/UseCases/Ansible/AnsibleInventoryGenerator.cs
  21. 0 6
      RackPeek.Web.Viewer/wwwroot/schemas/v4/schema.v4.json
  22. 0 6
      RackPeek.Web/wwwroot/schemas/v4/schema.v4.json
  23. 6 0
      Shared.Rcl/CliBootstrap.cs
  24. 153 0
      Shared.Rcl/Commands/Discovery/DiscoverNetworkCommand.cs
  25. 2 1
      Shared.Rcl/Commands/Discovery/DiscoverProxmoxCommand.cs
  26. 9 4
      Shared.Rcl/wwwroot/raw_docs/ansible-generator-guide.md
  27. 1 0
      Shared.Rcl/wwwroot/raw_docs/cli-commands-index.md
  28. 32 0
      Shared.Rcl/wwwroot/raw_docs/cli-commands.md
  29. 86 0
      Shared.Rcl/wwwroot/raw_docs/discovery-guide.md
  30. 81 0
      Tests.Discovery/ArpTableParserTests.cs
  31. 35 0
      Tests.Discovery/CidrParsingTests.cs
  32. 5 0
      Tests.Discovery/Fixtures/linux-arp-table
  33. 5 0
      Tests.Discovery/Fixtures/macos-arp-output
  34. 6 0
      Tests.Discovery/Fixtures/windows-arp-output
  35. 155 0
      Tests.Discovery/MacUnificationTests.cs
  36. 192 0
      Tests.Discovery/NetworkDiscoveryMergeTests.cs
  37. 92 0
      Tests.Discovery/NetworkProbeLoopbackTests.cs
  38. 120 0
      Tests.Discovery/NetworkScanMapperTests.cs
  39. 54 0
      Tests.Discovery/NetworkScanTargetTests.cs
  40. 183 0
      Tests.Discovery/NetworkScannerTests.cs
  41. 121 0
      Tests.Discovery/ProxmoxMacBridgeTests.cs
  42. 61 0
      Tests/EndToEnd/DiscoveryTests/DiscoverNetworkValidationTests.cs
  43. 29 0
      Tests/EndToEnd/ExporterTests/AnsibleInventoryWorkflowTests.cs
  44. 4 0
      Tests/Tests.csproj
  45. 27 0
      Tests/Yaml/SchemaTests.cs
  46. 0 6
      schemas/v4/schema.v4.json

+ 107 - 0
RackPeek.Domain/Discovery/ArpTableParser.cs

@@ -0,0 +1,107 @@
+using System.Net;
+using System.Net.Sockets;
+
+namespace RackPeek.Domain.Discovery;
+
+/// <summary>
+///     Reads an ARP table into ip → MAC, from either format the probe can produce:
+///     Linux's <c>/proc/net/arp</c> or BSD/macOS <c>arp -an</c> output. MACs are
+///     normalised (lowercase, zero-padded octets) because macOS prints <c>1:0:5e:…</c>
+///     where Linux prints <c>01:00:5e:…</c> — and the MAC seeds the discovery id, so
+///     the same machine must hash the same from every workstation. Pure; never throws.
+/// </summary>
+public static class ArpTableParser {
+    public static IReadOnlyDictionary<string, string> Parse(string? text) {
+        var result = new Dictionary<string, string>(StringComparer.Ordinal);
+
+        if (string.IsNullOrWhiteSpace(text))
+            return result;
+
+        foreach (var line in text.Split('\n')) {
+            (string Ip, string Mac)? entry = ParseLine(line.Trim());
+
+            if (entry != null)
+                result.TryAdd(entry.Value.Ip, entry.Value.Mac);
+        }
+
+        return result;
+    }
+
+    private static (string Ip, string Mac)? ParseLine(string line) {
+        if (line.Length == 0)
+            return null;
+
+        // BSD/macOS: "? (192.168.1.1) at a4:91:b1:4e:3c:20 on en0 ifscope [ethernet]"
+        var open = line.IndexOf('(');
+        var close = line.IndexOf(')');
+
+        if (open >= 0 && close > open) {
+            var ip = line[(open + 1)..close];
+            var at = line.IndexOf(" at ", close, StringComparison.Ordinal);
+
+            if (at < 0 || !IsIpv4(ip))
+                return null;
+
+            var rest = line[(at + 4)..];
+            var end = rest.IndexOf(' ');
+            var mac = NormaliseMac(end > 0 ? rest[..end] : rest);
+
+            return mac == null ? null : (ip, mac);
+        }
+
+        var columns = line.Split(' ', '\t', StringSplitOptions.RemoveEmptyEntries);
+
+        if (columns.Length < 2 || !IsIpv4(columns[0]))
+            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);
+        }
+
+        // Windows arp -a: "192.168.1.1           a4-91-b1-4e-3c-20     dynamic"
+        var windowsMac = NormaliseMac(columns[1]);
+
+        return windowsMac == null ? null : (columns[0], windowsMac);
+    }
+
+    /// <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) {
+        if (string.IsNullOrWhiteSpace(raw))
+            return null;
+
+        var parts = raw.Trim().Split(':', '-');
+
+        if (parts.Length != 6)
+            return null;
+
+        var octets = new string[6];
+
+        for (var i = 0; i < 6; i++) {
+            var part = parts[i];
+
+            if (part.Length is 0 or > 2 || !part.All(Uri.IsHexDigit))
+                return null;
+
+            octets[i] = part.Length == 1 ? "0" + char.ToLowerInvariant(part[0]) : part.ToLowerInvariant();
+        }
+
+        var mac = string.Join(':', octets);
+
+        // All-zero means the neighbour never answered — no identity there.
+        return mac == "00:00:00:00:00:00" ? null : mac;
+    }
+
+    private static bool IsIpv4(string value) =>
+        IPAddress.TryParse(value, out IPAddress? ip) && ip.AddressFamily == AddressFamily.InterNetwork;
+}

+ 11 - 0
RackPeek.Domain/Discovery/DiscoveryId.cs

@@ -15,6 +15,7 @@ public static class DiscoveryId {
     public const string Prefix = "rpk1";
     public const string SystemScheme = "sys";
     public const string DockerScheme = "docker";
+    public const string NetworkScheme = "net";
 
     public static string Create(string scheme, string seed) {
         if (string.IsNullOrWhiteSpace(scheme))
@@ -28,6 +29,16 @@ public static class DiscoveryId {
         return $"{Prefix}:{scheme}:{Convert.ToHexString(hash, 0, 8).ToLowerInvariant()}";
     }
 
+    /// <summary>The scheme segment of an id — "sys" for rpk1:sys:… — or null for anything malformed.</summary>
+    public static string? Scheme(string? discoveryId) {
+        if (string.IsNullOrWhiteSpace(discoveryId))
+            return null;
+
+        var parts = discoveryId.Split(':');
+
+        return parts.Length == 3 ? parts[1] : null;
+    }
+
     /// <summary>Short, stable fragment used to disambiguate generated names.</summary>
     public static string ShortSuffix(string discoveryId) {
         var lastColon = discoveryId.LastIndexOf(':');

+ 100 - 3
RackPeek.Domain/Discovery/DiscoveryIdResolver.cs

@@ -38,6 +38,8 @@ public static class DiscoveryIdResolver {
             .Where(r => !string.IsNullOrWhiteSpace(r.DiscoveryId))
             .ToDictionary(r => r.DiscoveryId!, r => r, StringComparer.OrdinalIgnoreCase);
 
+        Dictionary<string, Resource> existingByMac = BuildMacMap(existing);
+
         // Tolerant of a hand-edited file that managed to get two resources of the
         // same name: the first wins, rather than crashing the import.
         var existingByName = new Dictionary<string, Resource>(StringComparer.OrdinalIgnoreCase);
@@ -48,7 +50,7 @@ public static class DiscoveryIdResolver {
         var renames = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
 
         foreach (Resource resource in incomingWithId) {
-            var resolved = ResolveName(resource, existingById, existingByName);
+            var resolved = ResolveName(resource, existingById, existingByName, existingByMac);
 
             if (resolved.Equals(resource.Name, StringComparison.OrdinalIgnoreCase))
                 continue;
@@ -81,8 +83,11 @@ public static class DiscoveryIdResolver {
         var incomingNames = new HashSet<string>(incoming.Select(r => r.Name), StringComparer.OrdinalIgnoreCase);
 
         foreach (Resource resource in incomingWithId) {
+            // MAC unification may have nulled a scan card's id so the merge cannot
+            // downgrade the stored identity — such a card has nothing to look up here.
             if (resource.RunsOn.Count == 0
-                || !existingById.TryGetValue(resource.DiscoveryId!, out Resource? stored)
+                || string.IsNullOrWhiteSpace(resource.DiscoveryId)
+                || !existingById.TryGetValue(resource.DiscoveryId, out Resource? stored)
                 || stored.RunsOn.Count == 0)
                 continue;
 
@@ -97,11 +102,17 @@ public static class DiscoveryIdResolver {
     private static string ResolveName(
         Resource resource,
         Dictionary<string, Resource> existingById,
-        Dictionary<string, Resource> existingByName) {
+        Dictionary<string, Resource> existingByName,
+        Dictionary<string, Resource> existingByMac) {
         // Known id: the stored resource wins on name, whatever the user has renamed it to.
         if (existingById.TryGetValue(resource.DiscoveryId!, out Resource? matched))
             return matched.Name;
 
+        // Unknown id, but a MAC in common with exactly one stored card: the same
+        // physical machine seen by two collectors, unified onto the stored card.
+        if (TryUnifyByMac(resource, existingByMac, out var unifiedName))
+            return unifiedName;
+
         // Unknown id and the name is free: nothing to reconcile.
         if (!existingByName.TryGetValue(resource.Name, out Resource? sameName))
             return resource.Name;
@@ -128,6 +139,92 @@ public static class DiscoveryIdResolver {
         return resource.Name;
     }
 
+    /// <summary>
+    ///     The bridge between collectors that cannot derive each other's ids: the agent
+    ///     records the machine's MACs (a "macs" label), the scan identifies it by one (a
+    ///     "mac" label). A shared MAC on a stored card of the same kind means the same
+    ///     box — the incoming card adopts the stored card's name so the merge lands on
+    ///     it, and the stronger identity wins: an agent id replaces a scan id, a scan id
+    ///     never replaces anything (it is nulled here so the merge cannot downgrade).
+    ///     Ids from two agent-grade collectors sharing a MAC (cloned VMs, or Proxmox's
+    ///     view of a guest) are never unified — that is what machine-ids are for.
+    /// </summary>
+    private static bool TryUnifyByMac(
+        Resource resource,
+        Dictionary<string, Resource> existingByMac,
+        out string unifiedName) {
+        unifiedName = string.Empty;
+
+        foreach (var mac in MacsOf(resource)) {
+            if (!existingByMac.TryGetValue(mac, out Resource? stored))
+                continue;
+
+            // The box the user documented as a Server and the OS a scan saw on it are
+            // different cards on purpose; unification is for same-kind cards only.
+            if (stored.GetType() != resource.GetType())
+                continue;
+
+            var incomingIsNet = DiscoveryId.Scheme(resource.DiscoveryId) == DiscoveryId.NetworkScheme;
+
+            // A stored card with a MAC but no id yet: adoption, same as the name-based
+            // adoption case — the incoming id gets stamped onto it by the merge.
+            if (string.IsNullOrWhiteSpace(stored.DiscoveryId)) {
+                unifiedName = stored.Name;
+
+                return true;
+            }
+
+            var storedIsNet = DiscoveryId.Scheme(stored.DiscoveryId) == DiscoveryId.NetworkScheme;
+
+            // Both scan-grade or both agent-grade: not safe to unify on a MAC alone.
+            if (incomingIsNet == storedIsNet)
+                continue;
+
+            if (incomingIsNet)
+                resource.DiscoveryId = null;
+
+            unifiedName = stored.Name;
+
+            return true;
+        }
+
+        return false;
+    }
+
+    /// <summary>The MACs a resource claims, from its "mac" and "macs" labels, normalised.</summary>
+    private static IEnumerable<string> MacsOf(Resource resource) {
+        IEnumerable<string?> raw = [
+            resource.Labels.GetValueOrDefault("mac"),
+            .. (resource.Labels.GetValueOrDefault("macs") ?? string.Empty).Split(
+                ',',
+                StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
+        ];
+
+        return raw
+            .Select(ArpTableParser.NormaliseMac)
+            .Where(mac => mac != null)
+            .Select(mac => mac!)
+            .Distinct();
+    }
+
+    /// <summary>
+    ///     mac → the one stored resource claiming it. A MAC claimed by two stored
+    ///     resources identifies nothing and is dropped: ambiguity never unifies.
+    /// </summary>
+    private static Dictionary<string, Resource> BuildMacMap(IReadOnlyList<Resource> existing) {
+        var map = new Dictionary<string, Resource>(StringComparer.OrdinalIgnoreCase);
+        var ambiguous = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
+
+        foreach (Resource resource in existing)
+            foreach (var mac in MacsOf(resource))
+                if (!ambiguous.Contains(mac) && !map.TryAdd(mac, resource) && !ReferenceEquals(map[mac], resource)) {
+                    map.Remove(mac);
+                    ambiguous.Add(mac);
+                }
+
+        return map;
+    }
+
     private static void RewriteRunsOn(IReadOnlyList<Resource> incoming, Dictionary<string, string> renames) {
         foreach (Resource resource in incoming)
             for (var i = 0; i < resource.RunsOn.Count; i++)

+ 40 - 0
RackPeek.Domain/Discovery/INetworkProbe.cs

@@ -0,0 +1,40 @@
+using RackPeek.Domain.Resources.Services.Networking;
+
+namespace RackPeek.Domain.Discovery;
+
+/// <summary>
+///     Network IO for the sweep. The IO half of network discovery, mirroring
+///     <see cref="IDockerClient" /> / <see cref="IProxmoxClient" />: everything here is
+///     untestable-by-design plumbing, and every decision made about what comes back
+///     lives in <see cref="NetworkScanner" /> and the pure parsers.
+/// </summary>
+public interface INetworkProbe {
+    /// <summary>
+    ///     True when this platform can sweep at all. The browser (WASM viewer) cannot —
+    ///     its sockets are sandboxed — and without this guard a scan there would report
+    ///     an empty network instead of the truth. Mirrors <see cref="ISystemProbe.IsSupported" />.
+    /// </summary>
+    bool IsSupported { get; }
+
+    /// <summary>True when the host answers an ICMP echo within the timeout.</summary>
+    Task<bool> PingAsync(string ip, TimeSpan timeout, CancellationToken cancellationToken = default);
+
+    /// <summary>True when a TCP connect to the port completes within the timeout.</summary>
+    Task<bool> TryConnectAsync(string ip, int port, TimeSpan timeout, CancellationToken cancellationToken = default);
+
+    /// <summary>
+    ///     The host's ARP table, raw, in whichever format this platform produces — the
+    ///     sweep's pings populate it, and <see cref="ArpTableParser" /> reads either
+    ///     format. Null when it cannot be read; MACs are an enrichment, not a requirement.
+    /// </summary>
+    Task<string?> ReadArpAsync(CancellationToken cancellationToken = default);
+
+    /// <summary>The host's reverse-DNS name, or null when it has none worth keeping.</summary>
+    Task<string?> ReverseDnsAsync(string ip, TimeSpan timeout, CancellationToken cancellationToken = default);
+
+    /// <summary>
+    ///     The subnet of the first up, non-loopback IPv4 interface with a gateway — what
+    ///     `--cidr` defaults to. Null when the machine has no such interface.
+    /// </summary>
+    Cidr? LocalSubnet();
+}

+ 127 - 0
RackPeek.Domain/Discovery/NetworkProbe.cs

@@ -0,0 +1,127 @@
+using System.Net;
+using System.Net.NetworkInformation;
+using System.Net.Sockets;
+using RackPeek.Domain.Resources.Services.Networking;
+
+namespace RackPeek.Domain.Discovery;
+
+/// <summary>The real network IO. Deliberately dumb; see <see cref="INetworkProbe" />.</summary>
+public sealed class NetworkProbe : INetworkProbe {
+    public bool IsSupported => !OperatingSystem.IsBrowser();
+
+    public async Task<bool> PingAsync(string ip, TimeSpan timeout, CancellationToken cancellationToken = default) {
+        try {
+            using var ping = new Ping();
+            PingReply reply = await ping.SendPingAsync(ip, timeout, cancellationToken: cancellationToken);
+
+            return reply.Status == IPStatus.Success;
+        }
+        catch {
+            // No ICMP privilege, unreachable network, bad address — all mean "no answer".
+            return false;
+        }
+    }
+
+    public async Task<bool> TryConnectAsync(
+        string ip,
+        int port,
+        TimeSpan timeout,
+        CancellationToken cancellationToken = default) {
+        try {
+            using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
+            using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+            cts.CancelAfter(timeout);
+
+            await socket.ConnectAsync(IPAddress.Parse(ip), port, cts.Token);
+
+            return true;
+        }
+        catch {
+            // Refused, timed out, filtered — for liveness they are all the same "no".
+            return false;
+        }
+    }
+
+    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`. A source only wins if it yields entries the parser can use —
+        // an exit code alone is not proof (arp.exe printing usage text could exit 0),
+        // and trusting one would silently cost every host its MAC identity.
+        foreach (Func<Task<string?>> read in new Func<Task<string?>>[] {
+                     () => SystemProbeCommon.TryReadFileAsync("/proc/net/arp", cancellationToken),
+                     () => SystemProbeCommon.TryRunAsync("arp", "-an", cancellationToken),
+                     () => SystemProbeCommon.TryRunAsync("arp", "-a", cancellationToken)
+                 }) {
+            var text = await read();
+
+            if (text != null && ArpTableParser.Parse(text).Count > 0)
+                return text;
+        }
+
+        return null;
+    }
+
+    public async Task<string?> ReverseDnsAsync(
+        string ip,
+        TimeSpan timeout,
+        CancellationToken cancellationToken = default) {
+        try {
+            // A resolver with a dead PTR zone can sit on the query far longer than the
+            // whole sweep took; the cap keeps a pile of dead lookups from stalling it.
+            using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+            cts.CancelAfter(timeout);
+
+            IPHostEntry entry = await Dns.GetHostEntryAsync(ip, cts.Token);
+
+            // Some resolvers answer a PTR miss by echoing the address back.
+            return string.IsNullOrWhiteSpace(entry.HostName) || entry.HostName == ip
+                ? null
+                : entry.HostName;
+        }
+        catch {
+            return null;
+        }
+    }
+
+    public Cidr? LocalSubnet() {
+        try {
+            foreach (NetworkInterface nic in NetworkInterface.GetAllNetworkInterfaces()) {
+                if (nic.OperationalStatus != OperationalStatus.Up
+                    || nic.NetworkInterfaceType == NetworkInterfaceType.Loopback)
+                    continue;
+
+                IPInterfaceProperties properties = nic.GetIPProperties();
+
+                var hasGateway = properties.GatewayAddresses.Any(g =>
+                    g.Address.AddressFamily == AddressFamily.InterNetwork
+                    && !g.Address.Equals(IPAddress.Any));
+
+                if (!hasGateway)
+                    continue;
+
+                // Skip 169.254/16 self-assigned addresses: a NIC mid-DHCP-renewal can
+                // carry one alongside its real address, and sweeping that block finds
+                // nothing by definition.
+                UnicastIPAddressInformation? address = properties.UnicastAddresses.FirstOrDefault(a =>
+                    a.Address.AddressFamily == AddressFamily.InterNetwork
+                    && !IsLinkLocal(a.Address));
+
+                if (address == null)
+                    continue;
+
+                return Cidr.Parse($"{address.Address}/{address.PrefixLength}");
+            }
+        }
+        catch {
+            // Fall through: the caller asks the user for --cidr instead.
+        }
+
+        return null;
+    }
+
+    private static bool IsLinkLocal(IPAddress address) {
+        var bytes = address.GetAddressBytes();
+
+        return bytes.Length == 4 && bytes[0] == 169 && bytes[1] == 254;
+    }
+}

+ 34 - 0
RackPeek.Domain/Discovery/NetworkScanFacts.cs

@@ -0,0 +1,34 @@
+using RackPeek.Domain.Resources.Services.Networking;
+
+namespace RackPeek.Domain.Discovery;
+
+/// <summary>
+///     One responding host, as the sweep saw it. <see cref="OpenPorts" /> records only
+///     what liveness probing happened to touch — port probing stops at the first answer,
+///     so this is evidence the host is alive, never a port inventory.
+/// </summary>
+public sealed record NetworkHostFact(
+    string Ip,
+    string? Mac,
+    string? Hostname,
+    bool AnsweredPing,
+    IReadOnlyList<int> OpenPorts);
+
+/// <summary>How to sweep. The defaults suit a quiet home /24.</summary>
+public sealed record NetworkScanOptions {
+    public required Cidr Cidr { get; init; }
+
+    /// <summary>TCP ports probed to catch hosts that do not answer ping.</summary>
+    public IReadOnlyList<int> Ports { get; init; } = WellKnownPorts.Defaults;
+
+    public TimeSpan PingTimeout { get; init; } = TimeSpan.FromMilliseconds(300);
+
+    public TimeSpan PortTimeout { get; init; } = TimeSpan.FromMilliseconds(500);
+
+    /// <summary>Cap on each alive host's reverse-DNS lookup — resolvers that silently
+    /// drop PTR queries would otherwise stall the whole result on the OS default.</summary>
+    public TimeSpan DnsTimeout { get; init; } = TimeSpan.FromSeconds(2);
+
+    /// <summary>How many hosts are probed at once.</summary>
+    public int Concurrency { get; init; } = 128;
+}

+ 94 - 0
RackPeek.Domain/Discovery/NetworkScanMapper.cs

@@ -0,0 +1,94 @@
+using RackPeek.Domain.Resources;
+using RackPeek.Domain.Resources.Services.Networking;
+using RackPeek.Domain.Resources.SystemResources;
+
+namespace RackPeek.Domain.Discovery;
+
+/// <summary>Maps swept hosts onto the System resources RackPeek stores. Pure.</summary>
+public static class NetworkScanMapper {
+    public static List<Resource> ToResources(IReadOnlyList<NetworkHostFact> hosts) {
+        var taken = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
+        var resources = new List<Resource>();
+
+        foreach ((NetworkHostFact host, IReadOnlyList<string> allIps) in Collapse(hosts)) {
+            // 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,
+            // and the id changes if the address does — documented in the guide.
+            var discoveryId = DiscoveryId.Create(
+                DiscoveryId.NetworkScheme,
+                host.Mac ?? $"ip:{host.Ip}");
+
+            var system = new SystemResource {
+                Kind = SystemResource.KindLabel,
+                Name = DiscoveryNaming.Unique(
+                    DiscoveryNaming.Suggest(
+                        DiscoveryNaming.HostLabel(host.Hostname),
+                        "host",
+                        discoveryId),
+                    discoveryId,
+                    taken),
+                DiscoveryId = discoveryId,
+                // Deliberately sparse: a scan sees an address, not an OS or a type, and
+                // whatever it wrote here would overwrite the real values on every rescan
+                // of a card the user (or an agent collector) has since filled in.
+                Ip = host.Ip
+            };
+
+            if (host.Mac != null)
+                system.Labels["mac"] = host.Mac;
+
+            if (allIps.Count > 1)
+                system.Labels["ips"] = string.Join(",", allIps);
+
+            resources.Add(system);
+        }
+
+        return resources;
+    }
+
+    /// <summary>
+    ///     One MAC answering on several addresses — a gateway's VIPs and aliases — is
+    ///     still one machine, so it becomes one card: the lowest address as the card's
+    ///     ip (deterministic), every address in an "ips" label. Anything else would make
+    ///     the machine's identity depend on how many of its addresses happened to answer
+    ///     a particular scan, and identity must never move between scans.
+    /// </summary>
+    private static IEnumerable<(NetworkHostFact Host, IReadOnlyList<string> AllIps)> Collapse(
+        IReadOnlyList<NetworkHostFact> hosts) {
+        var byMac = new Dictionary<string, List<NetworkHostFact>>(StringComparer.OrdinalIgnoreCase);
+
+        foreach (NetworkHostFact host in hosts)
+            if (host.Mac != null) {
+                if (!byMac.TryGetValue(host.Mac, out List<NetworkHostFact>? group))
+                    byMac[host.Mac] = group = [];
+
+                group.Add(host);
+            }
+
+        var emitted = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
+
+        foreach (NetworkHostFact host in hosts) {
+            if (host.Mac == null) {
+                yield return (host, [host.Ip]);
+
+                continue;
+            }
+
+            if (!emitted.Add(host.Mac))
+                continue;
+
+            var group = byMac[host.Mac]
+                .OrderBy(h => IpHelper.ToUInt32(h.Ip))
+                .ToList();
+
+            NetworkHostFact primary = group[0];
+
+            // Any name in the group beats none: a VIP rarely has its own PTR record.
+            var hostname = group.Select(h => h.Hostname).FirstOrDefault(n => n != null);
+
+            yield return (
+                primary with { Hostname = hostname },
+                group.Select(h => h.Ip).ToList());
+        }
+    }
+}

+ 118 - 0
RackPeek.Domain/Discovery/NetworkScanner.cs

@@ -0,0 +1,118 @@
+using RackPeek.Domain.Resources.Services.Networking;
+
+namespace RackPeek.Domain.Discovery;
+
+/// <summary>
+///     Sweeps a subnet and reports the hosts that answered. A host counts as alive when
+///     it answers ping OR accepts a TCP connect on any probed port — plenty of gear
+///     drops ICMP, and plenty of gear with ICMP open runs no interesting service, so
+///     neither signal alone is enough. All IO goes through <see cref="INetworkProbe" />.
+/// </summary>
+public static class NetworkScanner {
+    /// <summary>
+    ///     The widest block a sweep accepts, wherever the block came from — typed by the
+    ///     user or auto-detected off a NIC. Wider than this is 65k+ hosts: a typo or a
+    ///     CGNAT/VPN prefix, not a homelab.
+    /// </summary>
+    public const int MinPrefix = 16;
+
+    /// <summary>
+    ///     Every address worth probing in the block: hosts only, so the network and
+    ///     broadcast addresses are skipped — except in /31 (RFC 3021 point-to-point)
+    ///     and /32, where every address is a host.
+    /// </summary>
+    public static IEnumerable<string> EnumerateTargets(Cidr cidr) {
+        // 64-bit throughout: 1u << 32 wraps under C#'s masked shift, and a block that
+        // touches 255.255.255.255 would overflow the loop bound in 32 bits.
+        var size = 1UL << (32 - cidr.Prefix);
+
+        var first = cidr.Prefix >= 31 ? cidr.Network : (ulong)cidr.Network + 1;
+        var last = cidr.Prefix >= 31
+            ? cidr.Network + size - 1
+            : cidr.Network + size - 2;
+
+        for (var ip = first; ip <= last; ip++)
+            yield return IpHelper.ToIp((uint)ip);
+    }
+
+    public static async Task<IReadOnlyList<NetworkHostFact>> ScanAsync(
+        INetworkProbe probe,
+        NetworkScanOptions options,
+        CancellationToken cancellationToken = default) {
+        // Enforced here rather than only at a front end, so every caller — CLI flag,
+        // auto-detected subnet, future MCP tool — hits the same wall.
+        if (options.Cidr.Prefix < MinPrefix)
+            throw new ArgumentOutOfRangeException(
+                nameof(options),
+                $"/{options.Cidr.Prefix} is more than 65,534 hosts. Narrow the sweep to /{MinPrefix} or smaller.");
+
+        var targets = EnumerateTargets(options.Cidr).ToList();
+
+        using var gate = new SemaphoreSlim(options.Concurrency);
+
+        (string Ip, bool Ping, List<int> Open)?[] swept = await Task.WhenAll(
+            targets.Select(ip => SweepHostAsync(probe, options, ip, gate, cancellationToken)));
+
+        var alive = swept.Where(h => h != null).Select(h => h!.Value).ToList();
+
+        // Read the ARP table only after the sweep: it is the sweep's own pings and
+        // connects that put the neighbours into it.
+        IReadOnlyDictionary<string, string> macByIp =
+            ArpTableParser.Parse(await probe.ReadArpAsync(cancellationToken));
+
+        // Names resolve in parallel too — a resolver that drops PTR queries burns the
+        // full timeout per lookup, and paying that once beats paying it per host.
+        var names = await Task.WhenAll(alive.Select(async h => {
+            await gate.WaitAsync(cancellationToken);
+
+            try {
+                return await probe.ReverseDnsAsync(h.Ip, options.DnsTimeout, cancellationToken);
+            }
+            finally {
+                gate.Release();
+            }
+        }));
+
+        var facts = new List<NetworkHostFact>(alive.Count);
+
+        for (var i = 0; i < alive.Count; i++) {
+            (var ip, var ping, List<int> open) = alive[i];
+            facts.Add(new NetworkHostFact(ip, macByIp.GetValueOrDefault(ip), names[i], ping, open));
+        }
+
+        return facts
+            .OrderBy(f => IpHelper.ToUInt32(f.Ip))
+            .ToList();
+    }
+
+    /// <summary>One host's liveness check; null when nothing answered.</summary>
+    private static async Task<(string Ip, bool Ping, List<int> Open)?> SweepHostAsync(
+        INetworkProbe probe,
+        NetworkScanOptions options,
+        string ip,
+        SemaphoreSlim gate,
+        CancellationToken cancellationToken) {
+        await gate.WaitAsync(cancellationToken);
+
+        try {
+            var ping = await probe.PingAsync(ip, options.PingTimeout, cancellationToken);
+            var open = new List<int>();
+
+            // Liveness needs one answer, not a port inventory: a ping reply skips the
+            // port probes entirely, and probing stops at the first open port.
+            if (!ping)
+                foreach (var port in options.Ports) {
+                    if (!await probe.TryConnectAsync(ip, port, options.PortTimeout, cancellationToken))
+                        continue;
+
+                    open.Add(port);
+                    break;
+                }
+
+            return ping || open.Count > 0 ? (ip, ping, open) : null;
+        }
+        finally {
+            gate.Release();
+        }
+    }
+}

+ 51 - 3
RackPeek.Domain/Discovery/ProxmoxModels.cs

@@ -66,6 +66,12 @@ public sealed record ProxmoxGuest {
     public string? Os { get; init; }
 
     public string? Ip { get; init; }
+
+    /// <summary>
+    ///     The guest's NIC MACs, from its config's netN lines — the bridge that lets a
+    ///     network scan and this collector agree they are looking at the same guest.
+    /// </summary>
+    public IReadOnlyList<string> Macs { get; init; } = [];
 }
 
 /// <summary>
@@ -218,15 +224,56 @@ public static class ProxmoxResponseParser {
         using var document = JsonDocument.Parse(json);
 
         if (!document.RootElement.TryGetProperty("data", out JsonElement data))
-            return new ProxmoxGuestConfig(null, null, [], []);
+            return new ProxmoxGuestConfig(null, null, [], [], []);
 
         return new ProxmoxGuestConfig(
             DescribeOs(GetString(data, "ostype")),
             ParseStaticIp(GetString(data, "net0")),
             ParseDiskSizes(data),
-            ParsePassthrough(data));
+            ParsePassthrough(data),
+            ParseMacs(data));
     }
 
+    /// <summary>
+    ///     The MACs in a guest's netN lines. QEMU spells them as the NIC model's value
+    ///     (<c>virtio=BC:24:11:…</c>), containers as <c>hwaddr=BC:24:11:…</c> — so any
+    ///     part whose value normalises to a MAC counts, and nothing else can (bridge
+    ///     names, ip=, tags never survive normalisation). Normalised by the same code
+    ///     that reads ARP tables, so a scan and this collector always agree.
+    /// </summary>
+    public static List<string> ParseMacs(JsonElement config) {
+        var macs = new List<string>();
+
+        foreach (JsonProperty property in config.EnumerateObject()) {
+            if (!IsNetSlot(property.Name))
+                continue;
+
+            var value = property.Value.ValueKind == JsonValueKind.String ? property.Value.GetString() : null;
+
+            if (value == null)
+                continue;
+
+            foreach (var part in value.Split(',', StringSplitOptions.TrimEntries)) {
+                var separator = part.IndexOf('=');
+
+                if (separator <= 0)
+                    continue;
+
+                var mac = ArpTableParser.NormaliseMac(part[(separator + 1)..]);
+
+                if (mac != null)
+                    macs.Add(mac);
+            }
+        }
+
+        return macs.Distinct().ToList();
+    }
+
+    private static bool IsNetSlot(string key) =>
+        key.StartsWith("net", StringComparison.OrdinalIgnoreCase)
+        && key.Length > 3
+        && key[3..].All(char.IsAsciiDigit);
+
     /// <summary>
     ///     Every disk attached to a guest. The guest list only carries <c>maxdisk</c>,
     ///     which is the boot disk alone — a VM with a small root and a large data volume
@@ -424,4 +471,5 @@ public sealed record ProxmoxGuestConfig(
     string? Os,
     string? Ip,
     IReadOnlyList<long> DiskBytes,
-    IReadOnlyList<string> PassthroughAddresses);
+    IReadOnlyList<string> PassthroughAddresses,
+    IReadOnlyList<string>? Macs = null);

+ 8 - 1
RackPeek.Domain/Discovery/ProxmoxResourceMapper.cs

@@ -146,6 +146,13 @@ public static class ProxmoxResourceMapper {
         // between nodes, which is exactly what an identity needs to do.
         var discoveryId = DiscoveryId.Create(Scheme, $"{scope}/{guest.VmId}");
 
+        Dictionary<string, string> labels = PassthroughLabels(guest, gpusByNode);
+
+        // The bridge to network discovery: a scan identifies this guest by one of
+        // these, so carrying them lets the resolver land both collectors on one card.
+        if (guest.Macs.Count > 0)
+            labels["macs"] = string.Join(",", guest.Macs);
+
         return new SystemResource {
             Kind = SystemResource.KindLabel,
             Name = DiscoveryNaming.Unique(
@@ -160,7 +167,7 @@ public static class ProxmoxResourceMapper {
             Ip = guest.Ip,
             Drives = ToGuestDrives(guest),
             Tags = guest.Tags.ToArray(),
-            Labels = PassthroughLabels(guest, gpusByNode),
+            Labels = labels,
             RunsOn = hypervisorNames.TryGetValue(guest.Node, out var hypervisor) ? [hypervisor] : []
         };
     }

+ 13 - 1
RackPeek.Domain/Discovery/SystemFacts.cs

@@ -15,7 +15,13 @@ public static class DiscoveryUnits {
 public sealed record BlockDeviceFact(string Name, long SizeBytes, bool Rotational);
 
 /// <summary>A network interface, reduced to the parts that pick a primary address.</summary>
-public sealed record NicFact(string Name, bool IsUp, bool IsLoopback, bool HasGateway, string? Ipv4);
+public sealed record NicFact(
+    string Name,
+    bool IsUp,
+    bool IsLoopback,
+    bool HasGateway,
+    string? Ipv4,
+    string? Mac = null);
 
 /// <summary>
 ///     Everything a probe managed to read off the host, still in its raw form.
@@ -64,6 +70,12 @@ public sealed record SystemFacts {
 
     public string? Ip { get; init; }
     public IReadOnlyList<DriveFact> Drives { get; init; } = [];
+
+    /// <summary>
+    ///     The machine's physical-NIC MACs, normalised. What lets an agent-discovered
+    ///     card and a network-scanned card of the same box find each other.
+    /// </summary>
+    public IReadOnlyList<string> Macs { get; init; } = [];
 }
 
 public sealed record DriveFact(string Type, int SizeGb);

+ 17 - 0
RackPeek.Domain/Discovery/SystemFactsParser.cs

@@ -28,6 +28,7 @@ public static class SystemFactsParser {
             RamGb = ParseRamGb(raw),
             Type = type,
             Ip = SelectPrimaryIp(raw.Nics),
+            Macs = SelectMacs(raw.Nics),
 
             // A container sees the host's block devices through /sys/block. They belong
             // to the machine underneath it, so reporting them here would attribute
@@ -105,6 +106,22 @@ public static class SystemFactsParser {
                ?? usable.FirstOrDefault()?.Ipv4;
     }
 
+    /// <summary>
+    ///     The MACs a network scan could see this machine by: real interfaces only — a
+    ///     docker bridge's MAC never crosses the wire, so recording it could only cause
+    ///     a false unification. Normalised by the same code that reads ARP tables, so
+    ///     both sides of the bridge always agree on the spelling.
+    /// </summary>
+    internal static List<string> SelectMacs(IReadOnlyList<NicFact> nics) {
+        return nics
+            .Where(n => n is { IsUp: true, IsLoopback: false } && !IsVirtual(n.Name))
+            .Select(n => ArpTableParser.NormaliseMac(n.Mac))
+            .Where(mac => mac != null)
+            .Select(mac => mac!)
+            .Distinct()
+            .ToList();
+    }
+
     internal static bool IsVirtual(string name) {
         string[] prefixes = ["docker", "br-", "veth", "virbr", "tailscale", "utun", "tun", "tap", "cni", "flannel"];
 

+ 16 - 1
RackPeek.Domain/Discovery/SystemProbeCommon.cs

@@ -48,7 +48,22 @@ internal static class SystemProbeCommon {
             nic.OperationalStatus == OperationalStatus.Up,
             nic.NetworkInterfaceType == NetworkInterfaceType.Loopback,
             hasGateway,
-            ipv4);
+            ipv4,
+            FormatMac(nic));
+    }
+
+    /// <summary>Lowercase colon-separated, matching what ARP tables report — or null.</summary>
+    private static string? FormatMac(NetworkInterface nic) {
+        try {
+            var bytes = nic.GetPhysicalAddress().GetAddressBytes();
+
+            return bytes.Length == 6
+                ? string.Join(':', bytes.Select(b => b.ToString("x2")))
+                : null;
+        }
+        catch {
+            return null;
+        }
     }
 
     /// <summary>Reads a file, returning null for anything unreadable rather than throwing.</summary>

+ 8 - 1
RackPeek.Domain/Discovery/SystemResourceMapper.cs

@@ -15,7 +15,7 @@ public static class SystemResourceMapper {
             DiscoveryId.SystemScheme,
             facts.MachineId ?? facts.Hostname);
 
-        return new SystemResource {
+        var resource = new SystemResource {
             Kind = SystemResource.KindLabel,
             Name = DiscoveryNaming.Suggest(
                 nameOverride ?? DiscoveryNaming.HostLabel(facts.Hostname),
@@ -31,5 +31,12 @@ public static class SystemResourceMapper {
                 ? null
                 : facts.Drives.Select(d => new Drive { Type = d.Type, Size = d.SizeGb }).ToList()
         };
+
+        // The bridge to network discovery: a scan identifies this machine by one of
+        // these, so carrying them lets the resolver land both collectors on one card.
+        if (facts.Macs.Count > 0)
+            resource.Labels["macs"] = string.Join(",", facts.Macs);
+
+        return resource;
     }
 }

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

@@ -0,0 +1,23 @@
+namespace RackPeek.Domain.Discovery;
+
+/// <summary>
+///     The TCP ports the sweep knocks on when a host ignores ping. Chosen for what a
+///     homelab actually runs — one open port anywhere in this list is enough to call
+///     the host alive, so breadth matters more than depth.
+/// </summary>
+public static class WellKnownPorts {
+    public static readonly IReadOnlyList<int> Defaults = [
+        22, // ssh — almost everything
+        80, // http
+        443, // https
+        53, // dns — pi-hole, routers
+        445, // smb — nas boxes
+        3389, // rdp — windows
+        631, // ipp — printers
+        8006, // proxmox
+        5000, // synology / registries
+        8080, // alt http
+        8443, // alt https
+        9100 // node-exporter / jetdirect
+    ];
+}

+ 17 - 0
RackPeek.Domain/Resources/Services/Networking/Cidr.cs

@@ -28,4 +28,21 @@ public readonly struct Cidr {
 
         return new Cidr(network, mask, prefix);
     }
+
+    /// <summary>The one definition of "is this a usable CIDR" for validation paths.</summary>
+    public static bool TryParse(string? value, out Cidr cidr) {
+        cidr = default;
+
+        if (string.IsNullOrWhiteSpace(value))
+            return false;
+
+        try {
+            cidr = Parse(value);
+
+            return true;
+        }
+        catch {
+            return false;
+        }
+    }
 }

+ 12 - 5
RackPeek.Domain/Resources/Services/Networking/IpHelper.cs

@@ -6,11 +6,18 @@ public static class IpHelper {
         if (parts.Length != 4)
             throw new ArgumentException($"Invalid IPv4 address: {ip}");
 
-        return (uint)(
-            (int.Parse(parts[0]) << 24) |
-            (int.Parse(parts[1]) << 16) |
-            (int.Parse(parts[2]) << 8) |
-            int.Parse(parts[3]));
+        uint result = 0;
+
+        foreach (var part in parts) {
+            // Range-checked: unchecked shifts would fold 192.168.256.0 into
+            // 192.169.0.0 and quietly point a caller at the wrong network.
+            if (!int.TryParse(part, out var octet) || octet is < 0 or > 255)
+                throw new ArgumentException($"Invalid IPv4 address: {ip}");
+
+            result = (result << 8) | (uint)octet;
+        }
+
+        return result;
     }
 
     public static string ToIp(uint ip) {

+ 1 - 6
RackPeek.Domain/Resources/Services/UseCases/ServiceSubnetsUseCase.cs

@@ -9,13 +9,8 @@ public class ServiceSubnetsUseCase(IResourceCollection repo) : IUseCase {
 
         // If CIDR is provided → filter mode
         if (cidr is not null) {
-            Cidr parsed;
-            try {
-                parsed = Cidr.Parse(cidr);
-            }
-            catch {
+            if (!Cidr.TryParse(cidr, out Cidr parsed))
                 return ServiceSubnetsResult.InvalidCidr(cidr);
-            }
 
             var matches = services
                 .Where(s => s.Network?.Ip != null)

+ 1 - 0
RackPeek.Domain/ServiceCollectionExtensions.cs

@@ -78,6 +78,7 @@ public static class ServiceCollectionExtensions {
         // so an unsupported host fails with a message rather than a missing registration.
         services.AddSingleton<ISystemProbe, LinuxSystemProbe>();
         services.AddSingleton<ISystemProbe, MacSystemProbe>();
+        services.AddSingleton<INetworkProbe, NetworkProbe>();
 
         services.AddScoped(typeof(IAddResourceUseCase<>), typeof(AddResourceUseCase<>));
         services.AddScoped(typeof(IAddLabelUseCase<>), typeof(AddLabelUseCase<>));

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

@@ -1,5 +1,6 @@
 using System.Text;
 using RackPeek.Domain.Resources;
+using RackPeek.Domain.Resources.SystemResources;
 
 namespace RackPeek.Domain.UseCases.Ansible;
 
@@ -180,6 +181,12 @@ public static class AnsibleInventoryGenerator {
         if (r.Labels.TryGetValue("hostname", out var hn) && !string.IsNullOrWhiteSpace(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;
     }
 

+ 0 - 6
RackPeek.Web.Viewer/wwwroot/schemas/v4/schema.v4.json

@@ -663,12 +663,6 @@
         },
         {
           "type": "object",
-          "required": [
-            "type",
-            "os",
-            "cores",
-            "ram"
-          ],
           "properties": {
             "kind": {
               "const": "System"

+ 0 - 6
RackPeek.Web/wwwroot/schemas/v4/schema.v4.json

@@ -663,12 +663,6 @@
         },
         {
           "type": "object",
-          "required": [
-            "type",
-            "os",
-            "cores",
-            "ram"
-          ],
           "properties": {
             "kind": {
               "const": "System"

+ 6 - 0
Shared.Rcl/CliBootstrap.cs

@@ -798,6 +798,12 @@ public static class CliBootstrap {
                     .WithDescription("Read a Proxmox cluster and emit its nodes and guests as Systems.")
                     .WithExample("discover", "proxmox", "--host", "https://pve.lan:8006", "--insecure")
                     .WithExample("discover", "proxmox", "--host", "pve.lan", "--push");
+
+                discover.AddCommand<DiscoverNetworkCommand>("network")
+                    .WithDescription("Sweep a subnet and emit every answering host as a System resource.")
+                    .WithExample("discover", "network")
+                    .WithExample("discover", "network", "--cidr", "192.168.1.0/24")
+                    .WithExample("discover", "network", "--cidr", "10.0.0.0/24", "--ports", "22,80,443", "--push");
             });
 
             config.AddBranch("ansible", ansible => {

+ 153 - 0
Shared.Rcl/Commands/Discovery/DiscoverNetworkCommand.cs

@@ -0,0 +1,153 @@
+using System.ComponentModel;
+using RackPeek.Domain.Discovery;
+using RackPeek.Domain.Resources;
+using RackPeek.Domain.Resources.Services.Networking;
+using Spectre.Console;
+using Spectre.Console.Cli;
+using NetworkCidr = RackPeek.Domain.Resources.Services.Networking.Cidr;
+
+namespace Shared.Rcl.Commands.Discovery;
+
+public sealed class DiscoverNetworkSettings : DiscoverSettings {
+    private IReadOnlyList<int>? _resolvedPorts;
+
+    [CommandOption("--cidr <CIDR>")]
+    [Description("Subnet to sweep, e.g. 192.168.1.0/24. Defaults to this machine's own subnet.")]
+    public string? Cidr { get; init; }
+
+    [CommandOption("--ports <LIST>")]
+    [Description("TCP ports probed to catch hosts that ignore ping, e.g. 22,80,443. " +
+                 "Defaults to a curated homelab list.")]
+    public string? Ports { get; init; }
+
+    [CommandOption("--timeout <MS>")]
+    [Description("Milliseconds to wait on each port probe.")]
+    public int Timeout { get; init; } = 500;
+
+    [CommandOption("--parallel <N>")]
+    [Description("How many hosts to probe at once.")]
+    public int Parallel { get; init; } = 128;
+
+    /// <summary>The parsed --cidr, or null when it was omitted or does not parse.</summary>
+    public NetworkCidr? ParsedCidr =>
+        NetworkCidr.TryParse(Cidr, out NetworkCidr parsed) ? parsed : null;
+
+    public IReadOnlyList<int> ResolvedPorts =>
+        _resolvedPorts ??= string.IsNullOrWhiteSpace(Ports)
+            ? WellKnownPorts.Defaults
+            : ParsePorts(Ports) ?? WellKnownPorts.Defaults;
+
+    public override ValidationResult Validate() {
+        if (Cidr != null) {
+            if (ParsedCidr is not { } parsed)
+                return ValidationResult.Error(
+                    $"'{Cidr}' is not a usable CIDR block. Use e.g. --cidr 192.168.1.0/24");
+
+            if (parsed.Prefix < NetworkScanner.MinPrefix)
+                return ValidationResult.Error(
+                    $"/{parsed.Prefix} is more than 65,534 hosts. Narrow the sweep to /{NetworkScanner.MinPrefix} or smaller.");
+        }
+
+        if (Ports != null && ParsePorts(Ports) == null)
+            return ValidationResult.Error(
+                $"'{Ports}' is not a usable port list. Use e.g. --ports 22,80,443");
+
+        if (Timeout is < 1 or > 60_000)
+            return ValidationResult.Error("--timeout must be between 1 and 60000 milliseconds.");
+
+        if (Parallel is < 1 or > 1024)
+            return ValidationResult.Error("--parallel must be between 1 and 1024.");
+
+        return base.Validate();
+    }
+
+    private static IReadOnlyList<int>? ParsePorts(string list) {
+        var ports = new List<int>();
+
+        foreach (var part in list.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) {
+            if (!int.TryParse(part, out var port) || port is < 1 or > 65_535)
+                return null;
+
+            ports.Add(port);
+        }
+
+        return ports.Count == 0 ? null : ports;
+    }
+}
+
+/// <summary>
+///     Sweeps a subnet and emits every answering host as a System resource — the
+///     collector for machines nothing else can describe: no agent, no API, just an
+///     address that answers.
+/// </summary>
+public sealed class DiscoverNetworkCommand(INetworkProbe probe)
+    : AsyncCommand<DiscoverNetworkSettings> {
+    protected override async Task<int> ExecuteAsync(
+        CommandContext context,
+        DiscoverNetworkSettings settings,
+        CancellationToken cancellationToken) {
+        if (!probe.IsSupported) {
+            // Without this, the browser console's sandboxed sockets would swallow every
+            // probe and the command would report an empty network as if it were true.
+            AnsiConsole.MarkupLine(
+                "[red]Network scanning is not supported on this platform.[/] " +
+                "Run rpk on a machine attached to the network instead.");
+
+            return 1;
+        }
+
+        Cidr cidr;
+
+        if (settings.ParsedCidr is { } requested) {
+            cidr = requested;
+        }
+        else {
+            Cidr? detected = probe.LocalSubnet();
+
+            if (detected == null) {
+                AnsiConsole.MarkupLine(
+                    "[red]Could not detect this machine's subnet.[/] Pass --cidr, e.g. --cidr 192.168.1.0/24");
+
+                return 1;
+            }
+
+            // The same cap --cidr gets: a VPN or CGNAT interface can carry a /10, and
+            // auto-detection must never be the way around the sweep limit.
+            if (detected.Value.Prefix < NetworkScanner.MinPrefix) {
+                AnsiConsole.MarkupLine(
+                    $"[red]This machine's subnet is {Markup.Escape(detected.Value.ToString())} — more than " +
+                    $"65,534 hosts.[/] Pass --cidr with a narrower block, e.g. --cidr 192.168.1.0/24");
+
+                return 1;
+            }
+
+            cidr = detected.Value;
+        }
+
+        var options = new NetworkScanOptions {
+            Cidr = cidr,
+            Ports = settings.ResolvedPorts,
+            PortTimeout = TimeSpan.FromMilliseconds(settings.Timeout),
+            Concurrency = settings.Parallel
+        };
+
+        var targets = NetworkScanner.EnumerateTargets(cidr).Count();
+
+        AnsiConsole.MarkupLine(
+            $"[grey]Sweeping {Markup.Escape(cidr.ToString())} — {targets} address(es), " +
+            $"ping + {options.Ports.Count} TCP port(s)…[/]");
+
+        IReadOnlyList<NetworkHostFact> hosts = await NetworkScanner.ScanAsync(probe, options, cancellationToken);
+
+        var withoutMac = hosts.Count(h => h.Mac == null);
+
+        if (withoutMac > 0)
+            AnsiConsole.MarkupLine(
+                $"[grey]{withoutMac} host(s) had no ARP entry, so their identity is seeded on the IP " +
+                "address — a DHCP re-lease will make them look like new machines.[/]");
+
+        List<Resource> resources = NetworkScanMapper.ToResources(hosts);
+
+        return await DiscoveryOutput.EmitAsync(resources, settings, cancellationToken);
+    }
+}

+ 2 - 1
Shared.Rcl/Commands/Discovery/DiscoverProxmoxCommand.cs

@@ -131,7 +131,8 @@ public sealed class DiscoverProxmoxCommand : AsyncCommand<DiscoverProxmoxSetting
                         Os = configs[i].Os,
                         Ip = configs[i].Ip,
                         Disks = configs[i].DiskBytes,
-                        PassthroughAddresses = configs[i].PassthroughAddresses
+                        PassthroughAddresses = configs[i].PassthroughAddresses,
+                        Macs = configs[i].Macs ?? []
                     });
             }
         }

+ 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:
 
-| 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:
 

+ 1 - 0
Shared.Rcl/wwwroot/raw_docs/cli-commands-index.md

@@ -242,6 +242,7 @@
     - [system](docs/Commands.md#rpk-discover-system) - Inspect this machine and emit it as a System resource
     - [docker](docs/Commands.md#rpk-discover-docker) - Read the Docker API and emit each published container as a Service on this
     - [proxmox](docs/Commands.md#rpk-discover-proxmox) - Read a Proxmox cluster and emit its nodes and guests as Systems
+    - [network](docs/Commands.md#rpk-discover-network) - Sweep a subnet and emit every answering host as a System resource
   - [ansible](docs/Commands.md#rpk-ansible) - Generate and manage Ansible inventory
     - [inventory](docs/Commands.md#rpk-ansible-inventory) - Generate an Ansible inventory
   - [ssh](docs/Commands.md#rpk-ssh) - Generate SSH configuration from infrastructure

+ 32 - 0
Shared.Rcl/wwwroot/raw_docs/cli-commands.md

@@ -3970,6 +3970,7 @@ COMMANDS:
     docker     Read the Docker API and emit each published container as a       
                Service on this host's System                                    
     proxmox    Read a Proxmox cluster and emit its nodes and guests as Systems  
+    network    Sweep a subnet and emit every answering host as a System resource
 ```
 
 ## `rpk discover system`
@@ -4060,6 +4061,37 @@ OPTIONS:
                                    Proxmox ships with by default                
 ```
 
+## `rpk discover network`
+```
+DESCRIPTION:
+Sweep a subnet and emit every answering host as a System resource
+
+USAGE:
+    rpk discover network [OPTIONS]
+
+EXAMPLES:
+    rpk discover network
+    rpk discover network --cidr 192.168.1.0/24
+    rpk discover network --cidr 10.0.0.0/24 --ports 22,80,443 --push
+
+OPTIONS:
+    -h, --help             Prints help information                              
+        --push             Upload the result to a RackPeek server instead of    
+                           printing it                                          
+        --server <URL>     RackPeek server to upload to. Defaults to the        
+                           RPK_SERVER environment variable                      
+        --api-key <KEY>    API key for the server. Defaults to the RPK_API_KEY  
+                           environment variable                                 
+        --dry-run          Ask the server what would change, without changing   
+                           anything. Implies --push                             
+        --cidr <CIDR>      Subnet to sweep, e.g. 192.168.1.0/24. Defaults to    
+                           this machine's own subnet                            
+        --ports <LIST>     TCP ports probed to catch hosts that ignore ping,    
+                           e.g. 22,80,443. Defaults to a curated homelab list   
+        --timeout <MS>     Milliseconds to wait on each port probe              
+        --parallel <N>     How many hosts to probe at once                      
+```
+
 ## `rpk ansible`
 ```
 DESCRIPTION:

+ 86 - 0
Shared.Rcl/wwwroot/raw_docs/discovery-guide.md

@@ -8,6 +8,7 @@ don't have to type in what the machine already knows about itself.
 | `rpk discover system` | the machine it runs on | one **System** resource |
 | `rpk discover docker` | the Docker Engine API | one **Service** per published container, plus the **System** they run on |
 | `rpk discover proxmox` | a Proxmox VE cluster | a **Server** and **System** per node, a **System** per guest, already wired together |
+| `rpk discover network` | a subnet, from outside | one **System** per host that answers ping or a well-known TCP port |
 
 Both print YAML to standard output by default and change nothing, so it is always safe
 to run one and look at the result first.
@@ -318,6 +319,91 @@ with no cluster uses its node name as the scope instead.
 
 ---
 
+## `rpk discover network`
+
+The collector for machines nothing else can describe: no agent, no API — just an
+address that answers. It sweeps a subnet and emits one **System** per responding host,
+with its IP, its reverse-DNS name, and its MAC address as a label.
+
+```bash
+# Sweep this machine's own subnet and look at the result
+rpk discover network
+
+# Sweep a specific block, then merge it into the server
+rpk discover network --cidr 192.168.1.0/24 --push
+```
+
+### What "answering" means
+
+A host counts as alive when it replies to ping **or** accepts a TCP connection on any
+probed port — plenty of gear drops ICMP, so ping alone would miss half a homelab. The
+default port list is a curated homelab set (ssh, http/https, dns, smb, rdp, ipp,
+proxmox, and friends); `--ports 22,80,443` narrows or widens it. The ports are only a
+liveness check: the sweep records that the host exists, not what it serves — pair it
+with `rpk discover docker` or hand-written Service cards for that.
+
+Sweeps are capped at a /16 (65,534 addresses). `--timeout` and `--parallel` tune how
+patient and how aggressive the sweep is; the defaults finish a quiet /24 in seconds.
+
+A network of several VLANs is several sweeps — each merges into the same inventory,
+and the ids keep re-runs honest:
+
+```bash
+rpk discover network --cidr 10.0.20.0/24 --push   # the LAN
+rpk discover network --cidr 10.0.50.0/24 --push   # the server VLAN
+```
+
+### Identity
+
+A scanned host is identified by its **MAC address**, read from the ARP table the
+sweep itself populates — so a DHCP re-lease updates the same resource's address rather
+than inventing a new machine. One MAC answering on several addresses (a gateway's
+VIPs and aliases) is still one machine and becomes **one card**: the lowest address as
+its `ip`, every address in an `ips` label — so a VIP failing over never moves the
+machine's identity. Two caveats:
+
+- **Hosts beyond the local segment have no ARP entry** (a routed VLAN, a VPN subnet).
+  Their identity falls back to the IP address, and the command says so — a DHCP
+  re-lease will then look like a new machine. Scan from a machine on the same segment
+  when you can. On a statically-addressed subnet — a server VLAN, say — the IP
+  fallback is stable in practice and nothing more is needed.
+- **A scan sees an address, not an operating system.** Scanned cards deliberately carry
+  no type, OS, cores or RAM, so a re-scan can never overwrite the details you (or an
+  agent collector) filled in afterwards.
+
+### One machine, one card — across collectors
+
+`rpk discover system` records the machine's physical MAC addresses (a `macs` label),
+and a scan identifies machines by exactly those MACs — so **the two collectors land on
+the same card**, whichever ran first:
+
+- Scan first: the sweep creates the card; when the agent later runs on that box, it
+  claims the card, fills in the OS/cores/RAM, and upgrades its identity to the
+  machine-id. Every rescan afterwards keeps updating that same card via the MAC.
+- Agent first: a later sweep recognises the box and just refreshes its address —
+  never touching the identity or anything you or the agent wrote.
+
+The card keeps whatever name it already had (names are always user-owned), so a
+scan-first card keeps its generated `host-…` name until you rename it once. A MAC that
+two stored cards both claim unifies nothing — ambiguity always falls back to separate
+cards — and agent-grade identities never unify with each other on a MAC alone (cloned
+VMs can share one; that is what machine-ids are for). The machine running the sweep
+finds itself, and unifies with its own `rpk discover system` card the same way.
+
+`rpk discover proxmox` joins the bridge for **guests**: a guest's config names the
+NIC MACs Proxmox assigned it, so a VM or container found by a sweep and the same guest
+reported by the Proxmox collector become one card too. Nodes stay outside the bridge
+(the API exposes no host MACs we read), and a guest documented both by Proxmox and by
+`rpk discover system` *inside* it remains two cards — vmid and machine-id are both
+agent-grade identities, and MACs alone never unify those.
+
+### Being a good citizen
+
+The sweep is a burst of pings and TCP connection attempts — the polite end of network
+scanning, but scan networks you operate, not networks you merely use.
+
+---
+
 ## Reviewing before you commit to it
 
 `--dry-run` asks the server what would change and writes nothing:

+ 81 - 0
Tests.Discovery/ArpTableParserTests.cs

@@ -0,0 +1,81 @@
+using RackPeek.Domain.Discovery;
+
+namespace Tests.Discovery;
+
+/// <summary>
+///     The ARP table is where a scanned host's identity comes from, and the two
+///     platforms print it differently — most dangerously, macOS drops leading zeros
+///     from MAC octets. If normalisation slips, the same machine gets a different
+///     discovery id depending on which workstation ran the scan.
+/// </summary>
+public class ArpTableParserTests {
+    [Fact]
+    public void The_linux_proc_file_parses_to_normalised_macs() {
+        IReadOnlyDictionary<string, string> table = ArpTableParser.Parse(Fixture.Read("linux-arp-table"));
+
+        Assert.Equal("a4:91:b1:4e:3c:20", table["192.168.1.1"]);
+        // Uppercase in the fixture, stored lowercase.
+        Assert.Equal("dc:a6:32:0f:11:22", table["192.168.1.20"]);
+    }
+
+    [Fact]
+    public void The_macos_arp_output_parses_to_the_same_macs_as_linux() {
+        IReadOnlyDictionary<string, string> linux = ArpTableParser.Parse(Fixture.Read("linux-arp-table"));
+        IReadOnlyDictionary<string, string> macos = ArpTableParser.Parse(Fixture.Read("macos-arp-output"));
+
+        // The macOS fixture prints 192.168.1.20 as dc:a6:32:f:11:22 — unpadded. Identity
+        // must not depend on which of the two formats happened to report the machine.
+        Assert.Equal(linux["192.168.1.1"], macos["192.168.1.1"]);
+        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]
+    public void Unresolved_neighbours_contribute_nothing() {
+        IReadOnlyDictionary<string, string> linux = ArpTableParser.Parse(Fixture.Read("linux-arp-table"));
+        IReadOnlyDictionary<string, string> macos = ArpTableParser.Parse(Fixture.Read("macos-arp-output"));
+
+        // Linux marks failures with flags 0x0 or an all-zero MAC; macOS prints "(incomplete)".
+        Assert.False(linux.ContainsKey("192.168.1.50"));
+        Assert.False(linux.ContainsKey("192.168.1.60"));
+        Assert.False(macos.ContainsKey("192.168.1.50"));
+    }
+
+    [Theory]
+    [InlineData(null)]
+    [InlineData("")]
+    [InlineData("not an arp table at all")]
+    [InlineData("IP address       HW type     Flags       HW address            Mask     Device")]
+    [InlineData("? (garbage at nothing")]
+    public void Garbage_input_is_an_empty_table_not_an_exception(string? text) =>
+        Assert.Empty(ArpTableParser.Parse(text));
+
+    [Theory]
+    [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("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) =>
+        Assert.Equal(expected, ArpTableParser.NormaliseMac(raw));
+
+    [Theory]
+    [InlineData(null)]
+    [InlineData("")]
+    [InlineData("00:00:00:00:00:00")] // the kernel's "never answered"
+    [InlineData("a4:91:b1:4e:3c")] // five octets
+    [InlineData("a4:91:b1:4e:3c:20:ff")] // seven octets
+    [InlineData("zz:91:b1:4e:3c:20")] // not hex
+    [InlineData("(incomplete)")]
+    public void Anything_that_is_not_a_usable_mac_is_null(string? raw) =>
+        Assert.Null(ArpTableParser.NormaliseMac(raw));
+}

+ 35 - 0
Tests.Discovery/CidrParsingTests.cs

@@ -0,0 +1,35 @@
+using RackPeek.Domain.Resources.Services.Networking;
+
+namespace Tests.Discovery;
+
+/// <summary>
+///     CIDR parsing feeds the sweep its targets, so leniency here means probing a
+///     network the user never named: unchecked octet arithmetic used to fold
+///     192.168.256.0 into 192.169.0.0 and call it usable.
+/// </summary>
+public class CidrParsingTests {
+    [Theory]
+    [InlineData("192.168.1.0/24", "192.168.1.0/24")]
+    [InlineData("192.168.1.37/24", "192.168.1.0/24")] // a host address masks down
+    [InlineData("10.0.0.0/8", "10.0.0.0/8")]
+    [InlineData("127.0.0.1/32", "127.0.0.1/32")]
+    public void Valid_blocks_parse_and_mask_to_their_network(string input, string expected) {
+        Assert.True(Cidr.TryParse(input, out Cidr cidr));
+        Assert.Equal(expected, cidr.ToString());
+    }
+
+    [Theory]
+    [InlineData(null)]
+    [InlineData("")]
+    [InlineData("not-a-cidr")]
+    [InlineData("192.168.1.0")] // no prefix
+    [InlineData("192.168.1.0/24/7")]
+    [InlineData("192.168.1.0/notanumber")]
+    [InlineData("192.168.1.0/33")]
+    [InlineData("192.168.256.0/24")] // octet overflow must not wrap into .169
+    [InlineData("192.-1.1.0/24")]
+    [InlineData("300.1.1.1/24")]
+    [InlineData("1.2.3/24")]
+    public void Anything_else_is_refused_rather_than_reinterpreted(string? input) =>
+        Assert.False(Cidr.TryParse(input, out _));
+}

+ 5 - 0
Tests.Discovery/Fixtures/linux-arp-table

@@ -0,0 +1,5 @@
+IP address       HW type     Flags       HW address            Mask     Device
+192.168.1.1      0x1         0x2         a4:91:b1:4e:3c:20     *        eth0
+192.168.1.20     0x1         0x2         DC:A6:32:0F:11:22     *        eth0
+192.168.1.50     0x1         0x0         00:00:00:00:00:00     *        eth0
+192.168.1.60     0x1         0x2         00:00:00:00:00:00     *        eth0

+ 5 - 0
Tests.Discovery/Fixtures/macos-arp-output

@@ -0,0 +1,5 @@
+? (192.168.1.1) at a4:91:b1:4e:3c:20 on en0 ifscope [ethernet]
+? (192.168.1.20) at dc:a6:32:f:11:22 on en0 ifscope [ethernet]
+? (192.168.1.50) at (incomplete) on en0 ifscope [ethernet]
+? (224.0.0.251) at 1:0:5e:0:0:fb on en0 ifscope permanent [ethernet]
+? (192.168.1.255) at ff:ff:ff:ff:ff:ff on en0 ifscope [ethernet]

+ 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

+ 155 - 0
Tests.Discovery/MacUnificationTests.cs

@@ -0,0 +1,155 @@
+using RackPeek.Domain.Discovery;
+using RackPeek.Domain.Resources;
+using RackPeek.Domain.Resources.Servers;
+using RackPeek.Domain.Resources.SystemResources;
+
+namespace Tests.Discovery;
+
+/// <summary>
+///     The MAC bridge between collectors: an agent card carries the machine's MACs
+///     (a "macs" label), a scan card carries the one it was found by (a "mac" label),
+///     and a shared MAC means the same box — so both collectors land on one card,
+///     whichever arrived first. These pin the resolver's side of that contract.
+/// </summary>
+public class MacUnificationTests {
+    private const string _mac = "dc:a6:32:0f:11:22";
+
+    private static SystemResource ScanCard(string name = "host-595109fb", string mac = _mac) => new() {
+        Kind = SystemResource.KindLabel,
+        Name = name,
+        DiscoveryId = DiscoveryId.Create(DiscoveryId.NetworkScheme, mac),
+        Ip = "192.168.1.20",
+        Labels = { ["mac"] = mac }
+    };
+
+    private static SystemResource AgentCard(
+        string name = "nas01",
+        string machineId = "machine-a",
+        string macs = _mac) => new() {
+            Kind = SystemResource.KindLabel,
+            Name = name,
+            DiscoveryId = DiscoveryId.Create(DiscoveryId.SystemScheme, machineId),
+            Type = "baremetal",
+            Os = "Debian",
+            Cores = 12,
+            Labels = { ["macs"] = macs }
+        };
+
+    [Fact]
+    public void An_agent_claims_the_scan_card_and_upgrades_its_identity() {
+        // Scan ran first; now `rpk discover system` reports the same box.
+        List<Resource> existing = [ScanCard()];
+        List<Resource> incoming = [AgentCard()];
+
+        DiscoveryIdResolver.ResolveNames(existing, incoming);
+
+        // The stored card's name wins (names are user-owned), and the agent's id
+        // survives so the merge upgrades the card to the stronger identity.
+        Assert.Equal("host-595109fb", incoming[0].Name);
+        Assert.StartsWith("rpk1:sys:", incoming[0].DiscoveryId);
+    }
+
+    [Fact]
+    public void A_scan_enriches_the_agents_card_without_touching_its_identity() {
+        // Agent ran first; now a sweep sees the same box from outside.
+        List<Resource> existing = [AgentCard()];
+        List<Resource> incoming = [ScanCard()];
+
+        DiscoveryIdResolver.ResolveNames(existing, incoming);
+
+        Assert.Equal("nas01", incoming[0].Name);
+        // The weak scan id is dropped so the merge cannot downgrade the sys id.
+        Assert.Null(incoming[0].DiscoveryId);
+    }
+
+    [Fact]
+    public void The_macs_are_matched_however_each_side_spells_them() {
+        // The agent records padded lowercase; suppose a stored label was hand-edited
+        // to Windows-style dashes — normalisation makes them the same machine anyway.
+        SystemResource stored = ScanCard();
+        stored.Labels["mac"] = "DC-A6-32-0F-11-22";
+        List<Resource> existing = [stored];
+
+        List<Resource> incoming = [AgentCard()];
+
+        DiscoveryIdResolver.ResolveNames(existing, incoming);
+
+        Assert.Equal("host-595109fb", incoming[0].Name);
+    }
+
+    [Fact]
+    public void An_id_match_always_beats_a_mac_match() {
+        // The scan card was renamed by the user; a rescan must follow its own id to
+        // the rename, not rediscover it via the MAC of some other card.
+        SystemResource renamed = ScanCard(name: "storage-primary");
+        List<Resource> existing = [renamed, AgentCard(macs: _mac)];
+        List<Resource> incoming = [ScanCard()];
+
+        DiscoveryIdResolver.ResolveNames(existing, incoming);
+
+        Assert.Equal("storage-primary", incoming[0].Name);
+        Assert.NotNull(incoming[0].DiscoveryId);
+    }
+
+    [Fact]
+    public void A_mac_claimed_by_two_stored_cards_identifies_nothing() {
+        // Ambiguity never unifies: fall through to the ordinary name rules.
+        List<Resource> existing = [
+            AgentCard(name: "clone-a", machineId: "machine-a"),
+            AgentCard(name: "clone-b", machineId: "machine-b")
+        ];
+        List<Resource> incoming = [ScanCard(name: "host-xyz")];
+
+        DiscoveryIdResolver.ResolveNames(existing, incoming);
+
+        Assert.Equal("host-xyz", incoming[0].Name);
+        Assert.NotNull(incoming[0].DiscoveryId);
+    }
+
+    [Fact]
+    public void Two_agent_grade_identities_sharing_a_mac_never_unify() {
+        // Cloned VMs can share a NIC MAC while having distinct machine-ids; the
+        // machine-id is the authority between agent-grade collectors.
+        List<Resource> existing = [AgentCard(name: "vm-a", machineId: "machine-a")];
+        List<Resource> incoming = [AgentCard(name: "vm-b", machineId: "machine-b")];
+
+        DiscoveryIdResolver.ResolveNames(existing, incoming);
+
+        Assert.Equal("vm-b", incoming[0].Name);
+    }
+
+    [Fact]
+    public void A_mac_on_a_different_kind_of_card_is_not_a_bridge() {
+        // The user put a mac label on the Server card describing the box's hardware;
+        // the scan's System card is a different kind of thing and stays separate.
+        var server = new Server {
+            Kind = "Server",
+            Name = "rack-server",
+            Labels = { ["mac"] = _mac }
+        };
+
+        List<Resource> incoming = [ScanCard(name: "host-xyz")];
+
+        DiscoveryIdResolver.ResolveNames([server], incoming);
+
+        Assert.Equal("host-xyz", incoming[0].Name);
+    }
+
+    [Fact]
+    public void A_hand_written_card_with_a_mac_label_is_adopted_like_a_name_match() {
+        // No id on the stored card: whoever arrives first with an identity stamps it.
+        var handWritten = new SystemResource {
+            Kind = SystemResource.KindLabel,
+            Name = "nas01",
+            Type = "baremetal",
+            Labels = { ["mac"] = _mac }
+        };
+
+        List<Resource> incoming = [ScanCard(name: "host-xyz")];
+
+        DiscoveryIdResolver.ResolveNames([handWritten], incoming);
+
+        Assert.Equal("nas01", incoming[0].Name);
+        Assert.NotNull(incoming[0].DiscoveryId); // the scan id gets stamped on
+    }
+}

+ 192 - 0
Tests.Discovery/NetworkDiscoveryMergeTests.cs

@@ -0,0 +1,192 @@
+using RackPeek.Domain.Api;
+using RackPeek.Domain.Discovery;
+using RackPeek.Domain.Resources;
+using RackPeek.Domain.Resources.SystemResources;
+
+namespace Tests.Discovery;
+
+/// <summary>
+///     Network-scan output through the real server: pushed over HTTP, merged by the
+///     real resolver, asserted against what lands on disk. These pin the identity
+///     contracts a scan lives or dies by — idempotent re-runs, renames that stick,
+///     and never stealing the identity of a host another collector documented.
+/// </summary>
+public class NetworkDiscoveryMergeTests {
+    private static string ScanYaml(params NetworkHostFact[] hosts) =>
+        DiscoveryDocument.ToYaml(NetworkScanMapper.ToResources(hosts));
+
+    private static NetworkHostFact Nas(string ip = "192.168.1.20") =>
+        new(ip, "dc:a6:32:0f:11:22", "nas01.lan", true, []);
+
+    [Fact]
+    public async Task A_scan_lands_on_disk_and_a_rescan_changes_nothing() {
+        using var api = new DiscoveryApiFixture();
+
+        ImportYamlResponse first = await api.PublishAsync(ScanYaml(Nas()));
+
+        Assert.Equal(["nas01"], first.Added);
+        Assert.Contains("discoveryId: rpk1:net:", api.StoredYaml);
+        Assert.Contains("mac: dc:a6:32:0f:11:22", api.StoredYaml);
+        Fixture.AssertConformsToSchema(api.StoredYaml);
+
+        ImportYamlResponse second = await api.PublishAsync(ScanYaml(Nas()));
+
+        Assert.Empty(second.Added);
+        Assert.Empty(second.Updated);
+    }
+
+    [Fact]
+    public async Task A_users_rename_survives_the_next_scan() {
+        // The stored card is a previous scan of the same machine that the user has
+        // since renamed — the id stayed with it, as the UI keeps it on a rename.
+        List<Resource> renamed = NetworkScanMapper.ToResources([Nas()]);
+        renamed[0].Name = "storage-primary";
+
+        using var api = new DiscoveryApiFixture(DiscoveryDocument.ToYaml(renamed));
+
+        ImportYamlResponse rescan = await api.PublishAsync(ScanYaml(Nas()));
+
+        Assert.Empty(rescan.Added);
+        Assert.Contains("storage-primary", api.StoredYaml);
+        Assert.DoesNotContain("name: nas01", api.StoredYaml);
+    }
+
+    [Fact]
+    public async Task A_dhcp_move_updates_the_address_of_the_same_machine() {
+        using var api = new DiscoveryApiFixture();
+
+        await api.PublishAsync(ScanYaml(Nas(ip: "192.168.1.20")));
+        ImportYamlResponse moved = await api.PublishAsync(ScanYaml(Nas(ip: "192.168.1.99")));
+
+        Assert.Empty(moved.Added); // same MAC, same machine
+        Assert.Equal(["nas01"], moved.Updated);
+        Assert.Contains("ip: 192.168.1.99", api.StoredYaml);
+        Assert.DoesNotContain("192.168.1.20", api.StoredYaml);
+    }
+
+    [Fact]
+    public async Task A_scan_never_steals_the_identity_of_an_agent_discovered_host() {
+        // nas01 exists with a machine-id identity but WITHOUT the macs label an agent
+        // records (an older agent, or a hand-stripped label) — so there is no MAC
+        // bridge, and the resolver must keep the cards apart rather than guess.
+        var agentDiscovered = DiscoveryDocument.ToYaml([
+            new SystemResource {
+                Kind = SystemResource.KindLabel,
+                Name = "nas01",
+                DiscoveryId = DiscoveryId.Create(DiscoveryId.SystemScheme, "machine-a"),
+                Type = "baremetal",
+                Os = "Debian",
+                Cores = 12
+            }
+        ]);
+
+        using var api = new DiscoveryApiFixture(agentDiscovered);
+
+        ImportYamlResponse response = await api.PublishAsync(ScanYaml(Nas()));
+
+        var scanName = Assert.Single(response.Added);
+        Assert.StartsWith("nas01-", scanName); // suffixed, not adopted
+
+        var stored = api.StoredYaml;
+        Assert.Contains("rpk1:sys:", stored); // the agent identity is intact
+        Assert.Contains("rpk1:net:", stored); // and the scan's card exists beside it
+        Assert.Contains("os: Debian", stored); // nothing on the original was touched
+    }
+
+    [Fact]
+    public async Task An_agent_claims_a_scanned_card_and_every_collector_lands_on_it_after() {
+        // The unification headline, scan-first: the sweep found the box, then
+        // `rpk discover system` runs on it. Same MAC, so it is the same card — the
+        // agent's identity and detail land on the scan's card instead of duplicating.
+        using var api = new DiscoveryApiFixture();
+
+        await api.PublishAsync(ScanYaml(Nas()));
+
+        SystemResource agent = SystemResourceMapper.ToResource(new SystemFacts {
+            Hostname = "nas01.lan",
+            MachineId = "machine-a",
+            Os = "Debian 12",
+            Cores = 12,
+            RamGb = 64,
+            Type = "baremetal",
+            Ip = "192.168.1.20",
+            Macs = ["dc:a6:32:0f:11:22"]
+        });
+
+        ImportYamlResponse claim = await api.PublishAsync(DiscoveryDocument.ToYaml([agent]));
+
+        // Nothing added: the agent updated the scan's card (which keeps its name).
+        Assert.Empty(claim.Added);
+        Assert.Equal(["nas01"], claim.Updated);
+
+        var stored = api.StoredYaml;
+        Assert.Contains("rpk1:sys:", stored); // identity upgraded to the agent's
+        Assert.DoesNotContain("rpk1:net:", stored);
+        Assert.Contains("os: Debian 12", stored);
+        Assert.Contains("mac: dc:a6:32:0f:11:22", stored);
+        Assert.Contains("macs: dc:a6:32:0f:11:22", stored);
+        Fixture.AssertConformsToSchema(stored);
+
+        // ...and a rescan afterwards still lands on that same card via the MAC.
+        ImportYamlResponse rescan = await api.PublishAsync(ScanYaml(Nas(ip: "192.168.1.99")));
+
+        Assert.Empty(rescan.Added);
+        Assert.Contains("rpk1:sys:", api.StoredYaml); // never downgraded
+        Assert.Contains("ip: 192.168.1.99", api.StoredYaml); // but freshly addressed
+    }
+
+    [Fact]
+    public async Task A_scan_enriches_an_agent_discovered_card_instead_of_duplicating_it() {
+        // The reverse order: the agent documented the box first, then a sweep sees it.
+        SystemResource agent = SystemResourceMapper.ToResource(new SystemFacts {
+            Hostname = "nas01",
+            MachineId = "machine-a",
+            Os = "Debian 12",
+            Cores = 12,
+            RamGb = 64,
+            Type = "baremetal",
+            Macs = ["dc:a6:32:0f:11:22"]
+        });
+
+        using var api = new DiscoveryApiFixture(DiscoveryDocument.ToYaml([agent]));
+
+        ImportYamlResponse scan = await api.PublishAsync(ScanYaml(Nas()));
+
+        Assert.Empty(scan.Added);
+        Assert.Equal(["nas01"], scan.Updated);
+
+        var stored = api.StoredYaml;
+        Assert.Contains("rpk1:sys:", stored); // the agent identity is untouched
+        Assert.DoesNotContain("rpk1:net:", stored);
+        Assert.Contains("ip: 192.168.1.20", stored); // the scan contributed the address
+        Assert.Contains("os: Debian 12", stored);
+        Fixture.AssertConformsToSchema(stored);
+    }
+
+    [Fact]
+    public async Task A_scan_adopts_a_hand_written_system_of_the_same_name() {
+        // The inverse case: the user typed the card themselves, so it has no id yet.
+        // The scan stamps its identity onto it and enriches it instead of duplicating.
+        using var api = new DiscoveryApiFixture(
+            """
+            version: 4
+            resources:
+              - kind: System
+                name: nas01
+                type: baremetal
+                os: Debian
+            """);
+
+        ImportYamlResponse response = await api.PublishAsync(ScanYaml(Nas()));
+
+        Assert.Empty(response.Added);
+        Assert.Equal(["nas01"], response.Updated);
+
+        var stored = api.StoredYaml;
+        Assert.Contains("rpk1:net:", stored);
+        Assert.Contains("ip: 192.168.1.20", stored);
+        // The scan card is sparse on purpose, so everything the user wrote survives.
+        Assert.Contains("os: Debian", stored);
+        Assert.Contains("type: baremetal", stored);
+    }
+}

+ 92 - 0
Tests.Discovery/NetworkProbeLoopbackTests.cs

@@ -0,0 +1,92 @@
+using System.Net;
+using System.Net.Sockets;
+using RackPeek.Domain.Discovery;
+using RackPeek.Domain.Resources.Services.Networking;
+
+namespace Tests.Discovery;
+
+/// <summary>
+///     The real probe against loopback — the one network every CI runner has and the
+///     tests are allowed to touch. TCP carries these tests on purpose: ICMP needs
+///     privileges some runners lack, and the scanner's whole point is that liveness
+///     never depends on ping alone.
+/// </summary>
+public class NetworkProbeLoopbackTests {
+    [Fact]
+    public async Task A_listening_port_answers_a_connect_probe() {
+        using var listener = new TcpListener(IPAddress.Loopback, 0);
+        listener.Start();
+        var port = ((IPEndPoint)listener.LocalEndpoint).Port;
+
+        var probe = new NetworkProbe();
+
+        Assert.True(await probe.TryConnectAsync("127.0.0.1", port, TimeSpan.FromSeconds(2)));
+    }
+
+    [Fact]
+    public async Task A_closed_port_says_no_instead_of_throwing() {
+        // Bind-then-close guarantees the port exists and nothing is listening on it.
+        using var listener = new TcpListener(IPAddress.Loopback, 0);
+        listener.Start();
+        var port = ((IPEndPoint)listener.LocalEndpoint).Port;
+        listener.Stop();
+
+        var probe = new NetworkProbe();
+
+        Assert.False(await probe.TryConnectAsync("127.0.0.1", port, TimeSpan.FromSeconds(2)));
+    }
+
+    [Fact]
+    public async Task An_unroutable_address_gives_up_within_the_timeout_budget() {
+        var probe = new NetworkProbe();
+        DateTime started = DateTime.UtcNow;
+
+        // TEST-NET-1 (RFC 5737) is never routed; the connect must die on OUR timer.
+        var open = await probe.TryConnectAsync("192.0.2.1", 9, TimeSpan.FromMilliseconds(250));
+
+        Assert.False(open);
+        Assert.True(DateTime.UtcNow - started < TimeSpan.FromSeconds(5),
+            "The connect ignored the timeout and sat on the OS default instead.");
+    }
+
+    [Fact]
+    public async Task The_whole_scan_pipeline_finds_a_real_listener_on_loopback() {
+        using var listener = new TcpListener(IPAddress.Loopback, 0);
+        listener.Start();
+        var port = ((IPEndPoint)listener.LocalEndpoint).Port;
+
+        IReadOnlyList<NetworkHostFact> hosts = await NetworkScanner.ScanAsync(
+            new NetworkProbe(),
+            new NetworkScanOptions {
+                Cidr = Cidr.Parse("127.0.0.1/32"),
+                Ports = [port],
+                // Loopback ping may be privilege-blocked on the runner; the open port
+                // must carry the verdict alone, so keep the ping window tiny.
+                PingTimeout = TimeSpan.FromMilliseconds(50),
+                PortTimeout = TimeSpan.FromSeconds(2)
+            });
+
+        NetworkHostFact host = Assert.Single(hosts);
+        Assert.Equal("127.0.0.1", host.Ip);
+        Assert.True(host.AnsweredPing || host.OpenPorts.Contains(port));
+    }
+
+    [Fact]
+    public async Task Scanning_a_dead_block_finds_nothing_and_finishes_quickly() {
+        // Loopback cannot play the dead host: on Linux the whole 127/8 answers ping.
+        // TEST-NET-1 (RFC 5737) is reserved and never routed, on every platform.
+        DateTime started = DateTime.UtcNow;
+
+        IReadOnlyList<NetworkHostFact> hosts = await NetworkScanner.ScanAsync(
+            new NetworkProbe(),
+            new NetworkScanOptions {
+                Cidr = Cidr.Parse("192.0.2.0/30"),
+                Ports = [9],
+                PingTimeout = TimeSpan.FromMilliseconds(50),
+                PortTimeout = TimeSpan.FromMilliseconds(250)
+            });
+
+        Assert.Empty(hosts);
+        Assert.True(DateTime.UtcNow - started < TimeSpan.FromSeconds(10));
+    }
+}

+ 120 - 0
Tests.Discovery/NetworkScanMapperTests.cs

@@ -0,0 +1,120 @@
+using RackPeek.Domain.Discovery;
+using RackPeek.Domain.Resources;
+using RackPeek.Domain.Resources.SystemResources;
+
+namespace Tests.Discovery;
+
+/// <summary>
+///     Swept hosts → the System cards RackPeek stores. The contract that matters most
+///     is identity: MAC-seeded, format-independent, IP only as a last resort.
+/// </summary>
+public class NetworkScanMapperTests {
+    private static NetworkHostFact Host(
+        string ip = "192.168.1.20",
+        string? mac = "dc:a6:32:0f:11:22",
+        string? hostname = "nas01.lan") =>
+        new(ip, mac, hostname, true, []);
+
+    [Fact]
+    public void A_host_becomes_a_system_card_with_ip_mac_and_its_dns_name() {
+        List<Resource> resources = NetworkScanMapper.ToResources([Host()]);
+
+        SystemResource system = Assert.IsType<SystemResource>(Assert.Single(resources));
+        Assert.Equal("nas01", system.Name); // first label of nas01.lan
+        Assert.Equal("System", system.Kind);
+        Assert.Equal("192.168.1.20", system.Ip);
+        // Deliberately sparse: anything a scan cannot see stays null so a rescan can
+        // never overwrite what the user or an agent collector filled in.
+        Assert.Null(system.Type);
+        Assert.Null(system.Os);
+        Assert.Null(system.Cores);
+        Assert.Equal("dc:a6:32:0f:11:22", system.Labels["mac"]);
+        Assert.StartsWith("rpk1:net:", system.DiscoveryId);
+    }
+
+    [Fact]
+    public void Identity_rides_on_the_mac_so_a_dhcp_move_is_the_same_machine() {
+        List<Resource> before = NetworkScanMapper.ToResources([Host(ip: "192.168.1.20")]);
+        List<Resource> after = NetworkScanMapper.ToResources([Host(ip: "192.168.1.99")]);
+
+        Assert.Equal(before[0].DiscoveryId, after[0].DiscoveryId);
+    }
+
+    [Fact]
+    public void Without_a_mac_the_ip_seeds_the_identity_instead() {
+        List<Resource> resources = NetworkScanMapper.ToResources([Host(mac: null)]);
+
+        Assert.StartsWith("rpk1:net:", resources[0].DiscoveryId);
+        Assert.False(Assert.IsType<SystemResource>(resources[0]).Labels.ContainsKey("mac"));
+
+        // ...and it is a different identity than the MAC would have produced.
+        Assert.NotEqual(
+            NetworkScanMapper.ToResources([Host()])[0].DiscoveryId,
+            resources[0].DiscoveryId);
+    }
+
+    [Fact]
+    public void A_host_with_no_dns_name_gets_a_deterministic_one_from_its_id() {
+        List<Resource> resources = NetworkScanMapper.ToResources([Host(hostname: null)]);
+
+        Assert.StartsWith("host-", resources[0].Name);
+
+        // Deterministic: the same machine names itself the same way on every run.
+        Assert.Equal(resources[0].Name, NetworkScanMapper.ToResources([Host(hostname: null)])[0].Name);
+    }
+
+    [Fact]
+    public void Two_hosts_answering_to_the_same_dns_name_stay_distinct() {
+        // A lazy resolver that answers every PTR with the router's name must not
+        // collapse the whole network into one card (the import rejects duplicates).
+        List<Resource> resources = NetworkScanMapper.ToResources([
+            Host(ip: "192.168.1.1", mac: "a4:91:b1:4e:3c:20", hostname: "router.lan"),
+            Host(ip: "192.168.1.2", mac: "b0:00:00:00:00:02", hostname: "router.lan")
+        ]);
+
+        Assert.Equal(2, resources.Select(r => r.Name).Distinct(StringComparer.OrdinalIgnoreCase).Count());
+        Assert.Equal("router", resources[0].Name);
+        Assert.StartsWith("router-", resources[1].Name);
+    }
+
+    [Fact]
+    public void One_mac_answering_on_several_addresses_is_one_machine_with_one_card() {
+        // Gateways answer on VIPs and aliases all the time: one MAC, many addresses —
+        // still one box. Collapsing keeps the import happy (duplicate ids are rejected)
+        // AND keeps identity independent of how many addresses answered this scan.
+        List<Resource> resources = NetworkScanMapper.ToResources([
+            Host(ip: "192.168.1.2", hostname: null), // the VIP, deliberately first
+            Host(ip: "192.168.1.1", hostname: "gw.lan")
+        ]);
+
+        SystemResource card = Assert.IsType<SystemResource>(Assert.Single(resources));
+        Assert.Equal("192.168.1.1", card.Ip); // the lowest address, deterministically
+        Assert.Equal("gw", card.Name); // the one name anywhere in the group
+        Assert.Equal("192.168.1.1,192.168.1.2", card.Labels["ips"]);
+    }
+
+    [Fact]
+    public void A_vip_appearing_or_disappearing_never_moves_the_machines_identity() {
+        // The regression that motivated the collapse: an id seeded on scan-local
+        // address counts flips when a keepalived VIP fails over. MAC alone, always.
+        List<Resource> alone = NetworkScanMapper.ToResources([
+            Host(ip: "192.168.1.1", hostname: "gw.lan")
+        ]);
+        List<Resource> withVip = NetworkScanMapper.ToResources([
+            Host(ip: "192.168.1.1", hostname: "gw.lan"),
+            Host(ip: "192.168.1.2", hostname: null)
+        ]);
+
+        Assert.Equal(alone[0].DiscoveryId, Assert.Single(withVip).DiscoveryId);
+    }
+
+    [Fact]
+    public void The_emitted_document_conforms_to_the_published_schema() {
+        List<Resource> resources = NetworkScanMapper.ToResources([
+            Host(),
+            Host(ip: "192.168.1.30", mac: null, hostname: null)
+        ]);
+
+        Fixture.AssertConformsToSchema(DiscoveryDocument.ToYaml(resources));
+    }
+}

+ 54 - 0
Tests.Discovery/NetworkScanTargetTests.cs

@@ -0,0 +1,54 @@
+using RackPeek.Domain.Discovery;
+using RackPeek.Domain.Resources.Services.Networking;
+
+namespace Tests.Discovery;
+
+/// <summary>
+///     Which addresses a block actually sweeps. Getting the edges wrong either wastes
+///     probes on the network/broadcast addresses or — worse — skips real hosts on the
+///     point-to-point prefixes where every address is a host.
+/// </summary>
+public class NetworkScanTargetTests {
+    private static List<string> Targets(string cidr) =>
+        NetworkScanner.EnumerateTargets(Cidr.Parse(cidr)).ToList();
+
+    [Fact]
+    public void A_24_sweeps_the_254_host_addresses() {
+        List<string> targets = Targets("192.168.1.0/24");
+
+        Assert.Equal(254, targets.Count);
+        Assert.Equal("192.168.1.1", targets.First());
+        Assert.Equal("192.168.1.254", targets.Last());
+        Assert.DoesNotContain("192.168.1.0", targets);
+        Assert.DoesNotContain("192.168.1.255", targets);
+    }
+
+    [Fact]
+    public void A_30_has_two_hosts_between_network_and_broadcast() =>
+        Assert.Equal(["10.0.0.1", "10.0.0.2"], Targets("10.0.0.0/30"));
+
+    [Fact]
+    public void A_31_is_point_to_point_where_both_addresses_are_hosts() =>
+        // RFC 3021: /31 has no network or broadcast address.
+        Assert.Equal(["10.0.0.0", "10.0.0.1"], Targets("10.0.0.0/31"));
+
+    [Fact]
+    public void A_32_is_exactly_the_one_address() =>
+        Assert.Equal(["127.0.0.1"], Targets("127.0.0.1/32"));
+
+    [Fact]
+    public void A_16_sweeps_the_full_65534_hosts() =>
+        Assert.Equal(65_534, Targets("10.20.0.0/16").Count);
+
+    [Fact]
+    public void A_block_at_the_top_of_the_address_space_does_not_wrap() {
+        List<string> targets = Targets("255.255.255.252/30");
+
+        Assert.Equal(["255.255.255.253", "255.255.255.254"], targets);
+    }
+
+    [Fact]
+    public void The_offered_ip_need_not_be_the_network_address() =>
+        // People type their own address plus a prefix; Cidr.Parse masks it down.
+        Assert.Equal(254, Targets("192.168.1.37/24").Count);
+}

+ 183 - 0
Tests.Discovery/NetworkScannerTests.cs

@@ -0,0 +1,183 @@
+using RackPeek.Domain.Discovery;
+using RackPeek.Domain.Resources.Services.Networking;
+
+namespace Tests.Discovery;
+
+/// <summary>
+///     The sweep's decisions, driven through a scripted probe: what counts as alive,
+///     what IO happens for dead hosts, and that the concurrency cap actually caps.
+///     The probe is the IO seam — everything above it is what these tests own.
+/// </summary>
+public class NetworkScannerTests {
+    private static NetworkScanOptions Options(string cidr = "10.0.0.0/30", params int[] ports) =>
+        new() {
+            Cidr = Cidr.Parse(cidr),
+            Ports = ports.Length > 0 ? ports : [22, 80],
+            PingTimeout = TimeSpan.FromMilliseconds(5),
+            PortTimeout = TimeSpan.FromMilliseconds(5)
+        };
+
+    [Fact]
+    public async Task A_host_that_answers_nothing_is_not_reported() {
+        var probe = new ScriptedProbe();
+
+        IReadOnlyList<NetworkHostFact> hosts = await NetworkScanner.ScanAsync(probe, Options());
+
+        Assert.Empty(hosts);
+    }
+
+    [Fact]
+    public async Task A_ping_reply_alone_makes_a_host_alive_and_skips_its_port_probes() {
+        var probe = new ScriptedProbe { PingReplies = ["10.0.0.1"] };
+
+        IReadOnlyList<NetworkHostFact> hosts = await NetworkScanner.ScanAsync(probe, Options());
+
+        NetworkHostFact host = Assert.Single(hosts);
+        Assert.Equal("10.0.0.1", host.Ip);
+        Assert.True(host.AnsweredPing);
+        // Liveness is already proven; knocking on ports would just be noise on the wire.
+        Assert.DoesNotContain(probe.PortProbes, p => p.Ip == "10.0.0.1");
+    }
+
+    [Fact]
+    public async Task A_host_that_drops_ping_but_serves_tcp_is_still_alive() {
+        var probe = new ScriptedProbe { OpenPorts = [("10.0.0.2", 80)] };
+
+        IReadOnlyList<NetworkHostFact> hosts = await NetworkScanner.ScanAsync(probe, Options());
+
+        NetworkHostFact host = Assert.Single(hosts);
+        Assert.Equal("10.0.0.2", host.Ip);
+        Assert.False(host.AnsweredPing);
+        Assert.Equal([80], host.OpenPorts);
+    }
+
+    [Fact]
+    public async Task Port_probing_stops_at_the_first_answer() {
+        var probe = new ScriptedProbe { OpenPorts = [("10.0.0.2", 22), ("10.0.0.2", 80)] };
+
+        await NetworkScanner.ScanAsync(probe, Options());
+
+        // 22 answered, so 80 was never asked: the sweep proves liveness, not a port map.
+        Assert.Equal([("10.0.0.2", 22)], probe.PortProbes.Where(p => p.Ip == "10.0.0.2"));
+    }
+
+    [Fact]
+    public async Task The_arp_table_is_read_after_the_sweep_and_names_resolve_only_for_the_living() {
+        var probe = new ScriptedProbe {
+            PingReplies = ["10.0.0.1"],
+            Arp = "? (10.0.0.1) at a4:91:b1:4e:3c:20 on en0 ifscope [ethernet]",
+            Names = { ["10.0.0.1"] = "router.lan" }
+        };
+
+        IReadOnlyList<NetworkHostFact> hosts = await NetworkScanner.ScanAsync(probe, Options());
+
+        Assert.True(probe.ArpReadAfterSweep,
+            "ARP must be read after the sweep — the sweep's own probes populate it.");
+        Assert.Equal("a4:91:b1:4e:3c:20", hosts[0].Mac);
+        Assert.Equal("router.lan", hosts[0].Hostname);
+        Assert.Equal(["10.0.0.1"], probe.DnsLookups); // dead hosts get no PTR queries
+    }
+
+    [Fact]
+    public async Task Results_come_back_in_address_order_whatever_order_probes_finished() {
+        var probe = new ScriptedProbe { PingReplies = ["10.0.0.2", "10.0.0.1"] };
+
+        IReadOnlyList<NetworkHostFact> hosts = await NetworkScanner.ScanAsync(probe, Options());
+
+        Assert.Equal(["10.0.0.1", "10.0.0.2"], hosts.Select(h => h.Ip));
+    }
+
+    [Fact]
+    public async Task No_more_hosts_are_probed_at_once_than_the_options_allow() {
+        var probe = new ScriptedProbe { PingDelay = TimeSpan.FromMilliseconds(20) };
+        NetworkScanOptions options = Options("10.0.0.0/24") with { Concurrency = 4 };
+
+        await NetworkScanner.ScanAsync(probe, options);
+
+        Assert.True(probe.MaxInFlight <= 4,
+            $"{probe.MaxInFlight} hosts were probed at once; the cap was 4.");
+    }
+
+    [Fact]
+    public async Task A_block_wider_than_the_cap_is_refused_wherever_it_came_from() {
+        // The floor lives in the scanner, not a front end: an auto-detected VPN /10
+        // must hit the same wall a typed --cidr does.
+        await Assert.ThrowsAsync<ArgumentOutOfRangeException>(() =>
+            NetworkScanner.ScanAsync(new ScriptedProbe(), Options("10.0.0.0/8")));
+    }
+
+    /// <summary>Scripted IO: answers what it is told to, records what was asked of it.</summary>
+    private sealed class ScriptedProbe : INetworkProbe {
+        private readonly Lock _lock = new();
+        private int _inFlight;
+        private bool _sweepDone;
+
+        public List<string> PingReplies { get; init; } = [];
+        public List<(string Ip, int Port)> OpenPorts { get; init; } = [];
+        public string? Arp { get; init; }
+        public Dictionary<string, string> Names { get; } = [];
+        public TimeSpan PingDelay { get; init; } = TimeSpan.Zero;
+
+        public List<(string Ip, int Port)> PortProbes { get; } = [];
+        public List<string> DnsLookups { get; } = [];
+        public bool IsSupported => true;
+        public int MaxInFlight { get; private set; }
+        public bool ArpReadAfterSweep { get; private set; }
+
+        public async Task<bool> PingAsync(string ip, TimeSpan timeout, CancellationToken cancellationToken = default) {
+            lock (_lock) {
+                _inFlight++;
+                MaxInFlight = Math.Max(MaxInFlight, _inFlight);
+            }
+
+            try {
+                if (PingDelay > TimeSpan.Zero)
+                    await Task.Delay(PingDelay, cancellationToken);
+
+                return PingReplies.Contains(ip);
+            }
+            finally {
+                lock (_lock) {
+                    _inFlight--;
+                }
+            }
+        }
+
+        public Task<bool> TryConnectAsync(
+            string ip,
+            int port,
+            TimeSpan timeout,
+            CancellationToken cancellationToken = default) {
+            lock (_lock) {
+                PortProbes.Add((ip, port));
+            }
+
+            return Task.FromResult(OpenPorts.Contains((ip, port)));
+        }
+
+        public Task<string?> ReadArpAsync(CancellationToken cancellationToken = default) {
+            lock (_lock) {
+                _sweepDone = true;
+                ArpReadAfterSweep = _inFlight == 0;
+            }
+
+            return Task.FromResult(Arp);
+        }
+
+        public Task<string?> ReverseDnsAsync(
+            string ip,
+            TimeSpan timeout,
+            CancellationToken cancellationToken = default) {
+            lock (_lock) {
+                if (!_sweepDone)
+                    throw new InvalidOperationException("Reverse DNS ran before the sweep finished.");
+
+                DnsLookups.Add(ip);
+            }
+
+            return Task.FromResult(Names.GetValueOrDefault(ip));
+        }
+
+        public Cidr? LocalSubnet() => null;
+    }
+}

+ 121 - 0
Tests.Discovery/ProxmoxMacBridgeTests.cs

@@ -0,0 +1,121 @@
+using RackPeek.Domain.Api;
+using RackPeek.Domain.Discovery;
+using RackPeek.Domain.Resources;
+using RackPeek.Domain.Resources.SystemResources;
+
+namespace Tests.Discovery;
+
+/// <summary>
+///     Proxmox's side of the MAC bridge: guest configs carry the NIC MACs Proxmox
+///     assigned, a network scan sees exactly those MACs on the wire, and the resolver
+///     lands both collectors on one card — the same contract the system collector has.
+/// </summary>
+public class ProxmoxMacBridgeTests {
+    // -- parsing ------------------------------------------------------------------------
+
+    [Theory]
+    [InlineData("pve-qemu-config.json", "bc:24:11:12:34:56")] // virtio=BC:24:11:…
+    [InlineData("pve-lxc-config.json", "bc:24:11:aa:bb:cc")] // hwaddr=BC:24:11:…
+    [InlineData("pve-lxc-config-dhcp.json", "bc:24:11:dd:ee:ff")] // dhcp still has a MAC
+    public void A_guests_config_yields_its_normalised_mac(string fixture, string expected) {
+        ProxmoxGuestConfig config = ProxmoxResponseParser.ParseGuestConfig(Fixture.Read(fixture));
+
+        Assert.Equal([expected], config.Macs);
+    }
+
+    [Theory]
+    [InlineData("""{"data":{}}""")]
+    [InlineData("""{"data":{"ostype":"l26","scsi0":"local-lvm:vm-1-disk-0,size=64G"}}""")]
+    [InlineData("""{"data":{"net0":"bridge=vmbr0,firewall=1"}}""")] // a net line with no MAC
+    [InlineData("""{"data":{"network":"virtio=BC:24:11:12:34:56"}}""")] // not a netN slot
+    public void A_config_without_nic_macs_yields_none(string json) {
+        ProxmoxGuestConfig config = ProxmoxResponseParser.ParseGuestConfig(json);
+
+        Assert.Empty(config.Macs ?? []);
+    }
+
+    [Fact]
+    public void Every_nic_of_a_multi_homed_guest_is_recorded() {
+        ProxmoxGuestConfig config = ProxmoxResponseParser.ParseGuestConfig(
+            """
+            {"data":{
+              "net0":"virtio=BC:24:11:12:34:56,bridge=vmbr0",
+              "net1":"e1000=BC:24:11:99:88:77,bridge=vmbr1,tag=50"
+            }}
+            """);
+
+        Assert.Equal(["bc:24:11:12:34:56", "bc:24:11:99:88:77"], config.Macs);
+    }
+
+    // -- mapping ------------------------------------------------------------------------
+
+    [Fact]
+    public void A_guest_card_carries_its_macs_label() {
+        List<Resource> resources = ProxmoxResourceMapper.ToResources(
+            "homelab",
+            [new ProxmoxNode { Name = "pve01" }],
+            [
+                new ProxmoxGuest {
+                    VmId = 104,
+                    Node = "pve01",
+                    Name = "docker-01",
+                    Type = "vm",
+                    Macs = ["bc:24:11:12:34:56"]
+                }
+            ]);
+
+        SystemResource guest = resources.OfType<SystemResource>().Single(r => r.Name == "docker-01");
+        Assert.Equal("bc:24:11:12:34:56", guest.Labels["macs"]);
+    }
+
+    // -- the bridge, end to end through the real server ----------------------------------
+
+    [Fact]
+    public async Task A_scanned_guest_and_its_proxmox_card_become_one() {
+        // The sweep found the VM on the LAN first — by the very MAC Proxmox assigned it.
+        var scanned = DiscoveryDocument.ToYaml(NetworkScanMapper.ToResources([
+            new NetworkHostFact("192.168.1.178", "bc:24:11:12:34:56", null, true, [])
+        ]));
+
+        using var api = new DiscoveryApiFixture(scanned);
+
+        // Now `rpk discover proxmox` reports the estate, including that guest.
+        List<Resource> estate = ProxmoxResourceMapper.ToResources(
+            "homelab",
+            [new ProxmoxNode { Name = "pve01" }],
+            [
+                new ProxmoxGuest {
+                    VmId = 104,
+                    Node = "pve01",
+                    Name = "docker-01",
+                    Type = "vm",
+                    Cores = 4,
+                    Os = "Linux",
+                    Macs = ["bc:24:11:12:34:56"]
+                }
+            ]);
+
+        ImportYamlResponse response = await api.PublishAsync(DiscoveryDocument.ToYaml(estate));
+
+        var stored = api.StoredYaml;
+
+        // The guest landed on the scan's card: identity upgraded to the vmid-based id,
+        // the scan's address kept, no duplicate for the same machine.
+        Assert.DoesNotContain("rpk1:net:", stored);
+        Assert.Contains("rpk1:pve:", stored);
+        Assert.Contains("ip: 192.168.1.178", stored);
+        Assert.Contains("os: Linux", stored);
+        Assert.DoesNotContain(response.Added, name => name.StartsWith("docker-01"));
+        Fixture.AssertConformsToSchema(stored);
+
+        // And a rescan afterwards still lands on that same card via the MAC.
+        ImportYamlResponse rescan = await api.PublishAsync(DiscoveryDocument.ToYaml(
+            NetworkScanMapper.ToResources([
+                new NetworkHostFact("192.168.1.179", "bc:24:11:12:34:56", null, true, [])
+            ])));
+
+        Assert.Empty(rescan.Added);
+        Assert.Contains("rpk1:pve:", api.StoredYaml);
+        Assert.Contains("ip: 192.168.1.179", api.StoredYaml);
+    }
+}

+ 61 - 0
Tests/EndToEnd/DiscoveryTests/DiscoverNetworkValidationTests.cs

@@ -0,0 +1,61 @@
+using Tests.EndToEnd.Infra;
+using Xunit.Abstractions;
+
+namespace Tests.EndToEnd.DiscoveryTests;
+
+/// <summary>
+///     `rpk discover network` argument validation. Every case here fails before any
+///     probing starts, so these tests never send a packet anywhere.
+/// </summary>
+[Collection("Yaml CLI tests")]
+public class DiscoverNetworkValidationTests(TempYamlCliFixture fs, ITestOutputHelper outputHelper)
+    : IClassFixture<TempYamlCliFixture> {
+    private async Task<string> ExecuteAsync(params string[] args) =>
+        await YamlCliTestHost.RunAsync(args, fs.Root, outputHelper, "config.yaml");
+
+    [Theory]
+    [InlineData("not-a-cidr")]
+    [InlineData("192.168.1.0")] // no prefix
+    [InlineData("192.168.1.0/24/7")]
+    [InlineData("192.168.1.0/notanumber")]
+    public async Task a_malformed_cidr_is_refused_with_an_example_of_the_right_shape(string cidr) {
+        var output = await ExecuteAsync("discover", "network", "--cidr", cidr);
+
+        Assert.Contains("not a usable CIDR block", output);
+        Assert.Contains("192.168.1.0/24", output);
+    }
+
+    [Theory]
+    [InlineData("10.0.0.0/8")]
+    [InlineData("0.0.0.0/0")]
+    public async Task a_sweep_wider_than_a_16_is_refused(string cidr) {
+        var output = await ExecuteAsync("discover", "network", "--cidr", cidr);
+
+        Assert.Contains("65,534 hosts", output);
+        Assert.Contains("/16", output);
+    }
+
+    [Theory]
+    [InlineData("eighty")]
+    [InlineData("0")] // port zero is not a port
+    [InlineData("65536")]
+    [InlineData("22;80")]
+    [InlineData(",")]
+    public async Task a_malformed_port_list_is_refused(string ports) {
+        var output = await ExecuteAsync(
+            "discover", "network", "--cidr", "192.168.1.0/24", "--ports", ports);
+
+        Assert.Contains("not a usable port list", output);
+    }
+
+    [Theory]
+    [InlineData("--timeout", "0", "--timeout must be between")]
+    [InlineData("--timeout", "999999", "--timeout must be between")]
+    [InlineData("--parallel", "0", "--parallel must be between")]
+    [InlineData("--parallel", "4096", "--parallel must be between")]
+    public async Task out_of_range_tuning_flags_are_refused(string flag, string value, string expected) {
+        var output = await ExecuteAsync("discover", "network", "--cidr", "192.168.1.0/24", flag, value);
+
+        Assert.Contains(expected, output);
+    }
+}

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

@@ -85,6 +85,35 @@ public class AnsibleInventoryWorkflowTests(
                      """, 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]
     public async Task ansible_inventory_yaml_output_test() {
         await File.WriteAllTextAsync(Path.Combine(fs.Root, "config.yaml"), """

+ 4 - 0
Tests/Tests.csproj

@@ -42,6 +42,10 @@
         <!-- Validate against the published schemas directly so the test and
              published copies can never drift apart again (#310, #311). -->
         <None Include="..\schemas\**\*.json" Link="schemas\%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest"/>
+        <!-- The wwwroot copies the server and viewer actually serve, so a test can
+             prove they never drift from the published ones again (#310/#311). -->
+        <None Include="..\RackPeek.Web\wwwroot\schemas\**\*.json" Link="wwwroot-schemas\web\%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest"/>
+        <None Include="..\RackPeek.Web.Viewer\wwwroot\schemas\**\*.json" Link="wwwroot-schemas\viewer\%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest"/>
         <None Include="TestConfigs\**\*.yaml" CopyToOutputDirectory="PreserveNewest"/>
         <None Update="TestConfigs\v3\01-server.yaml">
             <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>

+ 27 - 0
Tests/Yaml/SchemaTests.cs

@@ -1,5 +1,6 @@
 using System.Globalization;
 using System.Text.Json;
+using System.Text.Json.Nodes;
 using Json.Schema;
 using YamlDotNet.RepresentationModel;
 
@@ -61,6 +62,32 @@ public class SchemaConformanceTests {
         return "null";
     }
 
+    /// <summary>
+    ///     The schema is published three times: the repo root copy tests validate
+    ///     against, and the copies the web app and the viewer serve at
+    ///     /schemas/v{n}/schema.v{n}.json. They are hand-synced, and #310/#311 were
+    ///     what happens when a sync is missed — this pins them together for good.
+    /// </summary>
+    [Theory]
+    [InlineData(1)]
+    [InlineData(2)]
+    [InlineData(3)]
+    [InlineData(4)]
+    public void The_served_schema_copies_never_drift_from_the_published_one(int version) {
+        var published = JsonNode.Parse(
+            File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "schemas", $"schema.v{version}.json")));
+
+        foreach (var host in new[] { "web", "viewer" }) {
+            var served = JsonNode.Parse(File.ReadAllText(
+                Path.Combine(AppContext.BaseDirectory, "wwwroot-schemas", host, $"schema.v{version}.json")));
+
+            Assert.True(
+                JsonNode.DeepEquals(published, served),
+                $"The {host} wwwroot copy of schema.v{version}.json differs from schemas/ — " +
+                "update both together, or documents RackPeek writes will fail the schema it serves.");
+        }
+    }
+
     [Theory]
     [InlineData(1)]
     [InlineData(2)]

+ 0 - 6
schemas/v4/schema.v4.json

@@ -663,12 +663,6 @@
         },
         {
           "type": "object",
-          "required": [
-            "type",
-            "os",
-            "cores",
-            "ram"
-          ],
           "properties": {
             "kind": {
               "const": "System"