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

Add rpk discover network: sweep a subnet, emit answering hosts as Systems

The fourth collector, for machines nothing else can describe — no agent,
no API, just an address that answers. Pure .NET (no nmap): a bounded-
parallel ICMP ping sweep with TCP connect fallback on a curated port
list (a host is alive if either answers — plenty of gear drops ICMP),
ARP for MAC identity, reverse DNS for names.

Design follows the discovery philosophy: INetworkProbe is the thin IO
seam; ArpTableParser, target enumeration and NetworkScanMapper are pure
and fixture-tested. Identity is MAC-seeded (rpk1:net:<mac>), normalised
across platforms because macOS prints unpadded MAC octets where Linux
pads them; hosts with no ARP entry (routed segments) fall back to an
IP-seeded id and the command says so. Scanned cards are deliberately
sparse — ip, mac label, name, id, and nothing else — so a rescan can
never overwrite the type/os/cores/ram a user or agent collector filled
in on an adopted card. The v4 schema's System definition loses its
required [type, os, cores, ram] to allow that; loosening validation is
backwards-compatible and the emitters (Proxmox included) could already
produce Systems without cores.

--cidr defaults to the machine's own subnet and sweeps are capped at
/16; --ports, --timeout and --parallel tune the sweep.

Tests: 48 new in Tests.Discovery — both ARP formats parse to identical
MACs, target enumeration edges (/16, /24, /30, /31, /32, top-of-space
wrap), mapper identity contracts, scripted-probe scanner semantics
(ping-only, TCP-only, first-answer short-circuit, ARP-after-sweep,
concurrency cap), real-probe loopback and dead-block scans, and
merge-through-the-real-server e2e: idempotent rescans, renames that
survive, DHCP moves updating the same card, never stealing an
agent-discovered host's identity, adopting a hand-written one. Plus 11
CLI validation tests that fail before any packet is sent.

Proven against a real /24: 7 hosts found in ~10s, all identities stable
across consecutive runs, the scanning machine finds itself.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Tim Jones 15 часов назад
Родитель
Сommit
be0cb36153
26 измененных файлов с 1324 добавлено и 18 удалено
  1. 96 0
      RackPeek.Domain/Discovery/ArpTableParser.cs
  2. 1 0
      RackPeek.Domain/Discovery/DiscoveryId.cs
  3. 33 0
      RackPeek.Domain/Discovery/INetworkProbe.cs
  4. 98 0
      RackPeek.Domain/Discovery/NetworkProbe.cs
  5. 30 0
      RackPeek.Domain/Discovery/NetworkScanFacts.cs
  6. 44 0
      RackPeek.Domain/Discovery/NetworkScanMapper.cs
  7. 94 0
      RackPeek.Domain/Discovery/NetworkScanner.cs
  8. 23 0
      RackPeek.Domain/Discovery/WellKnownPorts.cs
  9. 1 0
      RackPeek.Domain/ServiceCollectionExtensions.cs
  10. 0 6
      RackPeek.Web.Viewer/wwwroot/schemas/v4/schema.v4.json
  11. 0 6
      RackPeek.Web/wwwroot/schemas/v4/schema.v4.json
  12. 6 0
      Shared.Rcl/CliBootstrap.cs
  13. 134 0
      Shared.Rcl/Commands/Discovery/DiscoverNetworkCommand.cs
  14. 1 0
      Shared.Rcl/wwwroot/raw_docs/cli-commands-index.md
  15. 32 0
      Shared.Rcl/wwwroot/raw_docs/cli-commands.md
  16. 63 0
      Shared.Rcl/wwwroot/raw_docs/discovery-guide.md
  17. 69 0
      Tests.Discovery/ArpTableParserTests.cs
  18. 5 0
      Tests.Discovery/Fixtures/linux-arp-table
  19. 5 0
      Tests.Discovery/Fixtures/macos-arp-output
  20. 122 0
      Tests.Discovery/NetworkDiscoveryMergeTests.cs
  21. 92 0
      Tests.Discovery/NetworkProbeLoopbackTests.cs
  22. 89 0
      Tests.Discovery/NetworkScanMapperTests.cs
  23. 54 0
      Tests.Discovery/NetworkScanTargetTests.cs
  24. 171 0
      Tests.Discovery/NetworkScannerTests.cs
  25. 61 0
      Tests/EndToEnd/DiscoveryTests/DiscoverNetworkValidationTests.cs
  26. 0 6
      schemas/v4/schema.v4.json

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

@@ -0,0 +1,96 @@
+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);
+        }
+
+        // Linux /proc/net/arp: "192.168.1.1  0x1  0x2  a4:91:b1:4e:3c:20  *  eth0"
+        var columns = line.Split(' ', '\t', StringSplitOptions.RemoveEmptyEntries);
+
+        if (columns.Length < 4 || !IsIpv4(columns[0]))
+            return null;
+
+        // 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);
+    }
+
+    /// <summary>Lowercase, zero-padded, or null for anything that is not a usable MAC.</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;
+}

+ 1 - 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))

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

@@ -0,0 +1,33 @@
+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 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, 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();
+}

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

@@ -0,0 +1,98 @@
+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 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) {
+        return await SystemProbeCommon.TryReadFileAsync("/proc/net/arp", cancellationToken)
+               ?? await SystemProbeCommon.TryRunAsync("arp", "-an", cancellationToken);
+    }
+
+    public async Task<string?> ReverseDnsAsync(string ip, 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(TimeSpan.FromSeconds(2));
+
+            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;
+
+                UnicastIPAddressInformation? address = properties.UnicastAddresses.FirstOrDefault(a =>
+                    a.Address.AddressFamily == AddressFamily.InterNetwork);
+
+                if (address == null)
+                    continue;
+
+                return Cidr.Parse($"{address.Address}/{address.PrefixLength}");
+            }
+        }
+        catch {
+            // Fall through: the caller asks the user for --cidr instead.
+        }
+
+        return null;
+    }
+}

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

@@ -0,0 +1,30 @@
+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>How many hosts are probed at once.</summary>
+    public int Concurrency { get; init; } = 128;
+}

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

@@ -0,0 +1,44 @@
+using RackPeek.Domain.Resources;
+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>(hosts.Count);
+
+        foreach (NetworkHostFact host in 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;
+
+            resources.Add(system);
+        }
+
+        return resources;
+    }
+}

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

@@ -0,0 +1,94 @@
+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>
+    ///     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) {
+        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));
+
+        var facts = new List<NetworkHostFact>(alive.Count);
+
+        foreach ((var ip, var ping, List<int> open) in alive)
+            facts.Add(new NetworkHostFact(
+                ip,
+                macByIp.GetValueOrDefault(ip),
+                await probe.ReverseDnsAsync(ip, cancellationToken),
+                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();
+        }
+    }
+}

+ 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
+    ];
+}

+ 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<>));

+ 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 => {

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

@@ -0,0 +1,134 @@
+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 {
+    /// <summary>Sweeping wider than a /16 is 65k+ hosts — a typo, not a homelab.</summary>
+    public const int MinPrefix = 16;
+
+    [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;
+
+    public IReadOnlyList<int> ResolvedPorts =>
+        string.IsNullOrWhiteSpace(Ports) ? WellKnownPorts.Defaults : ParsePorts(Ports)!;
+
+    public override ValidationResult Validate() {
+        if (Cidr != null) {
+            NetworkCidr parsed;
+
+            try {
+                parsed = NetworkCidr.Parse(Cidr);
+            }
+            catch {
+                return ValidationResult.Error(
+                    $"'{Cidr}' is not a usable CIDR block. Use e.g. --cidr 192.168.1.0/24");
+            }
+
+            if (parsed.Prefix < MinPrefix)
+                return ValidationResult.Error(
+                    $"/{parsed.Prefix} is more than 65,534 hosts. Narrow the sweep to /{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) {
+        Cidr cidr;
+
+        if (settings.Cidr != null) {
+            cidr = Cidr.Parse(settings.Cidr); // Validate() vouched for it
+        }
+        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;
+            }
+
+            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);
+    }
+}

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

+ 63 - 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,68 @@ 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. 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.
+
+The known limitation above applies here twice over: a host discovered by `rpk discover
+system` (machine-id identity) and by a network scan (MAC identity) becomes two
+resources, the second visibly suffixed. Keep whichever card you prefer and delete the
+other; the scan will keep updating the one that carries its id. The machine running
+the sweep also finds itself — same rule.
+
+### 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:

+ 69 - 0
Tests.Discovery/ArpTableParserTests.cs

@@ -0,0 +1,69 @@
+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 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")]
+    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));
+}

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

+ 122 - 0
Tests.Discovery/NetworkDiscoveryMergeTests.cs

@@ -0,0 +1,122 @@
+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 already exists with a machine-id identity from `rpk discover system`.
+        // The scan sees the same box from outside and proposes the same name with a
+        // MAC identity — the resolver must keep them apart, not merge one over the other.
+        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 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));
+    }
+}

+ 89 - 0
Tests.Discovery/NetworkScanMapperTests.cs

@@ -0,0 +1,89 @@
+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 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);
+}

+ 171 - 0
Tests.Discovery/NetworkScannerTests.cs

@@ -0,0 +1,171 @@
+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.");
+    }
+
+    /// <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 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, 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;
+    }
+}

+ 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);
+    }
+}

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

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