Преглед изворни кода

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 пре 14 часа
родитељ
комит
be0cb36153

+ 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 Prefix = "rpk1";
     public const string SystemScheme = "sys";
     public const string SystemScheme = "sys";
     public const string DockerScheme = "docker";
     public const string DockerScheme = "docker";
+    public const string NetworkScheme = "net";
 
 
     public static string Create(string scheme, string seed) {
     public static string Create(string scheme, string seed) {
         if (string.IsNullOrWhiteSpace(scheme))
         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.
         // so an unsupported host fails with a message rather than a missing registration.
         services.AddSingleton<ISystemProbe, LinuxSystemProbe>();
         services.AddSingleton<ISystemProbe, LinuxSystemProbe>();
         services.AddSingleton<ISystemProbe, MacSystemProbe>();
         services.AddSingleton<ISystemProbe, MacSystemProbe>();
+        services.AddSingleton<INetworkProbe, NetworkProbe>();
 
 
         services.AddScoped(typeof(IAddResourceUseCase<>), typeof(AddResourceUseCase<>));
         services.AddScoped(typeof(IAddResourceUseCase<>), typeof(AddResourceUseCase<>));
         services.AddScoped(typeof(IAddLabelUseCase<>), typeof(AddLabelUseCase<>));
         services.AddScoped(typeof(IAddLabelUseCase<>), typeof(AddLabelUseCase<>));

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

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

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

@@ -663,12 +663,6 @@
         },
         },
         {
         {
           "type": "object",
           "type": "object",
-          "required": [
-            "type",
-            "os",
-            "cores",
-            "ram"
-          ],
           "properties": {
           "properties": {
             "kind": {
             "kind": {
               "const": "System"
               "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.")
                     .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", "https://pve.lan:8006", "--insecure")
                     .WithExample("discover", "proxmox", "--host", "pve.lan", "--push");
                     .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 => {
             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
     - [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
     - [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
     - [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
   - [ansible](docs/Commands.md#rpk-ansible) - Generate and manage Ansible inventory
     - [inventory](docs/Commands.md#rpk-ansible-inventory) - Generate an Ansible inventory
     - [inventory](docs/Commands.md#rpk-ansible-inventory) - Generate an Ansible inventory
   - [ssh](docs/Commands.md#rpk-ssh) - Generate SSH configuration from infrastructure
   - [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       
     docker     Read the Docker API and emit each published container as a       
                Service on this host's System                                    
                Service on this host's System                                    
     proxmox    Read a Proxmox cluster and emit its nodes and guests as Systems  
     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`
 ## `rpk discover system`
@@ -4060,6 +4061,37 @@ OPTIONS:
                                    Proxmox ships with by default                
                                    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`
 ## `rpk ansible`
 ```
 ```
 DESCRIPTION:
 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 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 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 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
 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.
 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
 ## Reviewing before you commit to it
 
 
 `--dry-run` asks the server what would change and writes nothing:
 `--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",
           "type": "object",
-          "required": [
-            "type",
-            "os",
-            "cores",
-            "ram"
-          ],
           "properties": {
           "properties": {
             "kind": {
             "kind": {
               "const": "System"
               "const": "System"