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

Address code-review findings on network discovery

An adversarial review of the branch surfaced ten verified findings; all
are addressed:

- Identity stability (the review's top finding): hosts sharing a MAC now
  collapse into ONE card (lowest address as its ip, all addresses in an
  'ips' label) instead of count-dependent id seeds — a VIP failing over
  or appearing can no longer move or duplicate a machine's identity.
- The /16 sweep cap moved into NetworkScanner itself, so an
  auto-detected VPN/CGNAT /10 hits the same wall a typed --cidr does.
- IpHelper.ToUInt32 range-checks octets: 192.168.256.0/24 is refused
  instead of silently wrapping into 192.169.0.0 and probing a network
  the user never named.
- The ARP source chain trusts parse results, not exit codes: a source
  only wins if it yields usable entries, so arp.exe printing usage text
  with exit 0 can no longer cost Windows scans their MAC identity.
- INetworkProbe.IsSupported guards the browser: the WASM viewer console
  now says scanning is unsupported instead of reporting a false-empty
  network.
- LocalSubnet skips 169.254/16 self-assigned addresses when picking the
  subnet to auto-sweep.
- Reverse DNS resolves in parallel under the same concurrency gate, with
  the per-lookup cap promoted from a buried constant to
  NetworkScanOptions.DnsTimeout — a PTR-dropping resolver now costs one
  timeout, not one per host.
- Cidr.TryParse is the single definition of CIDR validity: the settings
  and ServiceSubnetsUseCase both use it, the command no longer re-parses
  on faith, and ResolvedPorts caches its parse instead of a null-forgive.
- New drift guard: SchemaTests pins the wwwroot schema copies the server
  and viewer actually serve to the published schemas/ copies (the #310/
  #311 failure mode), for every version.

20 new/updated tests; re-verified against the real /24 that every
previously emitted discovery id is unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Tim Jones 5 часов назад
Родитель
Сommit
6ce03a7625

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

@@ -9,6 +9,13 @@ namespace RackPeek.Domain.Discovery;
 ///     lives in <see cref="NetworkScanner" /> and the pure parsers.
 /// </summary>
 public interface INetworkProbe {
+    /// <summary>
+    ///     True when this platform can sweep at all. The browser (WASM viewer) cannot —
+    ///     its sockets are sandboxed — and without this guard a scan there would report
+    ///     an empty network instead of the truth. Mirrors <see cref="ISystemProbe.IsSupported" />.
+    /// </summary>
+    bool IsSupported { get; }
+
     /// <summary>True when the host answers an ICMP echo within the timeout.</summary>
     Task<bool> PingAsync(string ip, TimeSpan timeout, CancellationToken cancellationToken = default);
 
@@ -23,7 +30,7 @@ public interface INetworkProbe {
     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);
+    Task<string?> ReverseDnsAsync(string ip, TimeSpan timeout, CancellationToken cancellationToken = default);
 
     /// <summary>
     ///     The subnet of the first up, non-loopback IPv4 interface with a gateway — what

+ 33 - 7
RackPeek.Domain/Discovery/NetworkProbe.cs

@@ -7,6 +7,8 @@ namespace RackPeek.Domain.Discovery;
 
 /// <summary>The real network IO. Deliberately dumb; see <see cref="INetworkProbe" />.</summary>
 public sealed class NetworkProbe : INetworkProbe {
+    public bool IsSupported => !OperatingSystem.IsBrowser();
+
     public async Task<bool> PingAsync(string ip, TimeSpan timeout, CancellationToken cancellationToken = default) {
         try {
             using var ping = new Ping();
@@ -42,18 +44,32 @@ public sealed class NetworkProbe : INetworkProbe {
 
     public async Task<string?> ReadArpAsync(CancellationToken cancellationToken = default) {
         // Linux reads the kernel's file; BSD/macOS answer `arp -an`; Windows' arp.exe
-        // only knows `-a`. Each failed attempt is null, so the chain just walks on.
-        return await SystemProbeCommon.TryReadFileAsync("/proc/net/arp", cancellationToken)
-               ?? await SystemProbeCommon.TryRunAsync("arp", "-an", cancellationToken)
-               ?? await SystemProbeCommon.TryRunAsync("arp", "-a", cancellationToken);
+        // only knows `-a`. A source only wins if it yields entries the parser can use —
+        // an exit code alone is not proof (arp.exe printing usage text could exit 0),
+        // and trusting one would silently cost every host its MAC identity.
+        foreach (Func<Task<string?>> read in new Func<Task<string?>>[] {
+                     () => SystemProbeCommon.TryReadFileAsync("/proc/net/arp", cancellationToken),
+                     () => SystemProbeCommon.TryRunAsync("arp", "-an", cancellationToken),
+                     () => SystemProbeCommon.TryRunAsync("arp", "-a", cancellationToken)
+                 }) {
+            var text = await read();
+
+            if (text != null && ArpTableParser.Parse(text).Count > 0)
+                return text;
+        }
+
+        return null;
     }
 
-    public async Task<string?> ReverseDnsAsync(string ip, CancellationToken cancellationToken = default) {
+    public async Task<string?> ReverseDnsAsync(
+        string ip,
+        TimeSpan timeout,
+        CancellationToken cancellationToken = default) {
         try {
             // A resolver with a dead PTR zone can sit on the query far longer than the
             // whole sweep took; the cap keeps a pile of dead lookups from stalling it.
             using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
-            cts.CancelAfter(TimeSpan.FromSeconds(2));
+            cts.CancelAfter(timeout);
 
             IPHostEntry entry = await Dns.GetHostEntryAsync(ip, cts.Token);
 
@@ -83,8 +99,12 @@ public sealed class NetworkProbe : INetworkProbe {
                 if (!hasGateway)
                     continue;
 
+                // Skip 169.254/16 self-assigned addresses: a NIC mid-DHCP-renewal can
+                // carry one alongside its real address, and sweeping that block finds
+                // nothing by definition.
                 UnicastIPAddressInformation? address = properties.UnicastAddresses.FirstOrDefault(a =>
-                    a.Address.AddressFamily == AddressFamily.InterNetwork);
+                    a.Address.AddressFamily == AddressFamily.InterNetwork
+                    && !IsLinkLocal(a.Address));
 
                 if (address == null)
                     continue;
@@ -98,4 +118,10 @@ public sealed class NetworkProbe : INetworkProbe {
 
         return null;
     }
+
+    private static bool IsLinkLocal(IPAddress address) {
+        var bytes = address.GetAddressBytes();
+
+        return bytes.Length == 4 && bytes[0] == 169 && bytes[1] == 254;
+    }
 }

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

@@ -25,6 +25,10 @@ public sealed record NetworkScanOptions {
 
     public TimeSpan PortTimeout { get; init; } = TimeSpan.FromMilliseconds(500);
 
+    /// <summary>Cap on each alive host's reverse-DNS lookup — resolvers that silently
+    /// drop PTR queries would otherwise stall the whole result on the OS default.</summary>
+    public TimeSpan DnsTimeout { get; init; } = TimeSpan.FromSeconds(2);
+
     /// <summary>How many hosts are probed at once.</summary>
     public int Concurrency { get; init; } = 128;
 }

+ 55 - 17
RackPeek.Domain/Discovery/NetworkScanMapper.cs

@@ -1,4 +1,5 @@
 using RackPeek.Domain.Resources;
+using RackPeek.Domain.Resources.Services.Networking;
 using RackPeek.Domain.Resources.SystemResources;
 
 namespace RackPeek.Domain.Discovery;
@@ -7,27 +8,15 @@ namespace RackPeek.Domain.Discovery;
 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);
+        var resources = new List<Resource>();
 
-        // One MAC answering on several addresses is one box with aliases or VIPs —
-        // gateways do this all the time. Each address still gets its own card, but the
-        // shared MAC alone cannot identify them: the import rejects duplicate ids.
-        var macCounts = hosts
-            .Where(h => h.Mac != null)
-            .GroupBy(h => h.Mac!)
-            .ToDictionary(g => g.Key, g => g.Count(), StringComparer.OrdinalIgnoreCase);
-
-        foreach (NetworkHostFact host in hosts) {
+        foreach ((NetworkHostFact host, IReadOnlyList<string> allIps) in Collapse(hosts)) {
             // The MAC is the only identity a scan can see that survives a DHCP re-lease;
             // when ARP could not provide one (a routed subnet, say) the IP has to do,
             // and the id changes if the address does — documented in the guide.
-            var seed = host.Mac == null
-                ? $"ip:{host.Ip}"
-                : macCounts[host.Mac] > 1
-                    ? $"{host.Mac}/{host.Ip}"
-                    : host.Mac;
-
-            var discoveryId = DiscoveryId.Create(DiscoveryId.NetworkScheme, seed);
+            var discoveryId = DiscoveryId.Create(
+                DiscoveryId.NetworkScheme,
+                host.Mac ?? $"ip:{host.Ip}");
 
             var system = new SystemResource {
                 Kind = SystemResource.KindLabel,
@@ -48,9 +37,58 @@ public static class NetworkScanMapper {
             if (host.Mac != null)
                 system.Labels["mac"] = host.Mac;
 
+            if (allIps.Count > 1)
+                system.Labels["ips"] = string.Join(",", allIps);
+
             resources.Add(system);
         }
 
         return resources;
     }
+
+    /// <summary>
+    ///     One MAC answering on several addresses — a gateway's VIPs and aliases — is
+    ///     still one machine, so it becomes one card: the lowest address as the card's
+    ///     ip (deterministic), every address in an "ips" label. Anything else would make
+    ///     the machine's identity depend on how many of its addresses happened to answer
+    ///     a particular scan, and identity must never move between scans.
+    /// </summary>
+    private static IEnumerable<(NetworkHostFact Host, IReadOnlyList<string> AllIps)> Collapse(
+        IReadOnlyList<NetworkHostFact> hosts) {
+        var byMac = new Dictionary<string, List<NetworkHostFact>>(StringComparer.OrdinalIgnoreCase);
+
+        foreach (NetworkHostFact host in hosts)
+            if (host.Mac != null) {
+                if (!byMac.TryGetValue(host.Mac, out List<NetworkHostFact>? group))
+                    byMac[host.Mac] = group = [];
+
+                group.Add(host);
+            }
+
+        var emitted = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
+
+        foreach (NetworkHostFact host in hosts) {
+            if (host.Mac == null) {
+                yield return (host, [host.Ip]);
+
+                continue;
+            }
+
+            if (!emitted.Add(host.Mac))
+                continue;
+
+            var group = byMac[host.Mac]
+                .OrderBy(h => IpHelper.ToUInt32(h.Ip))
+                .ToList();
+
+            NetworkHostFact primary = group[0];
+
+            // Any name in the group beats none: a VIP rarely has its own PTR record.
+            var hostname = group.Select(h => h.Hostname).FirstOrDefault(n => n != null);
+
+            yield return (
+                primary with { Hostname = hostname },
+                group.Select(h => h.Ip).ToList());
+        }
+    }
 }

+ 31 - 7
RackPeek.Domain/Discovery/NetworkScanner.cs

@@ -9,6 +9,13 @@ namespace RackPeek.Domain.Discovery;
 ///     neither signal alone is enough. All IO goes through <see cref="INetworkProbe" />.
 /// </summary>
 public static class NetworkScanner {
+    /// <summary>
+    ///     The widest block a sweep accepts, wherever the block came from — typed by the
+    ///     user or auto-detected off a NIC. Wider than this is 65k+ hosts: a typo or a
+    ///     CGNAT/VPN prefix, not a homelab.
+    /// </summary>
+    public const int MinPrefix = 16;
+
     /// <summary>
     ///     Every address worth probing in the block: hosts only, so the network and
     ///     broadcast addresses are skipped — except in /31 (RFC 3021 point-to-point)
@@ -32,6 +39,13 @@ public static class NetworkScanner {
         INetworkProbe probe,
         NetworkScanOptions options,
         CancellationToken cancellationToken = default) {
+        // Enforced here rather than only at a front end, so every caller — CLI flag,
+        // auto-detected subnet, future MCP tool — hits the same wall.
+        if (options.Cidr.Prefix < MinPrefix)
+            throw new ArgumentOutOfRangeException(
+                nameof(options),
+                $"/{options.Cidr.Prefix} is more than 65,534 hosts. Narrow the sweep to /{MinPrefix} or smaller.");
+
         var targets = EnumerateTargets(options.Cidr).ToList();
 
         using var gate = new SemaphoreSlim(options.Concurrency);
@@ -46,15 +60,25 @@ public static class NetworkScanner {
         IReadOnlyDictionary<string, string> macByIp =
             ArpTableParser.Parse(await probe.ReadArpAsync(cancellationToken));
 
+        // Names resolve in parallel too — a resolver that drops PTR queries burns the
+        // full timeout per lookup, and paying that once beats paying it per host.
+        var names = await Task.WhenAll(alive.Select(async h => {
+            await gate.WaitAsync(cancellationToken);
+
+            try {
+                return await probe.ReverseDnsAsync(h.Ip, options.DnsTimeout, cancellationToken);
+            }
+            finally {
+                gate.Release();
+            }
+        }));
+
         var facts = new List<NetworkHostFact>(alive.Count);
 
-        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));
+        for (var i = 0; i < alive.Count; i++) {
+            (var ip, var ping, List<int> open) = alive[i];
+            facts.Add(new NetworkHostFact(ip, macByIp.GetValueOrDefault(ip), names[i], ping, open));
+        }
 
         return facts
             .OrderBy(f => IpHelper.ToUInt32(f.Ip))

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

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

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

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

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

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

+ 33 - 14
Shared.Rcl/Commands/Discovery/DiscoverNetworkCommand.cs

@@ -9,8 +9,7 @@ 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;
+    private IReadOnlyList<int>? _resolvedPorts;
 
     [CommandOption("--cidr <CIDR>")]
     [Description("Subnet to sweep, e.g. 192.168.1.0/24. Defaults to this machine's own subnet.")]
@@ -29,24 +28,24 @@ public sealed class DiscoverNetworkSettings : DiscoverSettings {
     [Description("How many hosts to probe at once.")]
     public int Parallel { get; init; } = 128;
 
+    /// <summary>The parsed --cidr, or null when it was omitted or does not parse.</summary>
+    public NetworkCidr? ParsedCidr =>
+        NetworkCidr.TryParse(Cidr, out NetworkCidr parsed) ? parsed : null;
+
     public IReadOnlyList<int> ResolvedPorts =>
-        string.IsNullOrWhiteSpace(Ports) ? WellKnownPorts.Defaults : ParsePorts(Ports)!;
+        _resolvedPorts ??= string.IsNullOrWhiteSpace(Ports)
+            ? WellKnownPorts.Defaults
+            : ParsePorts(Ports) ?? WellKnownPorts.Defaults;
 
     public override ValidationResult Validate() {
         if (Cidr != null) {
-            NetworkCidr parsed;
-
-            try {
-                parsed = NetworkCidr.Parse(Cidr);
-            }
-            catch {
+            if (ParsedCidr is not { } parsed)
                 return ValidationResult.Error(
                     $"'{Cidr}' is not a usable CIDR block. Use e.g. --cidr 192.168.1.0/24");
-            }
 
-            if (parsed.Prefix < MinPrefix)
+            if (parsed.Prefix < NetworkScanner.MinPrefix)
                 return ValidationResult.Error(
-                    $"/{parsed.Prefix} is more than 65,534 hosts. Narrow the sweep to /{MinPrefix} or smaller.");
+                    $"/{parsed.Prefix} is more than 65,534 hosts. Narrow the sweep to /{NetworkScanner.MinPrefix} or smaller.");
         }
 
         if (Ports != null && ParsePorts(Ports) == null)
@@ -87,10 +86,20 @@ public sealed class DiscoverNetworkCommand(INetworkProbe probe)
         CommandContext context,
         DiscoverNetworkSettings settings,
         CancellationToken cancellationToken) {
+        if (!probe.IsSupported) {
+            // Without this, the browser console's sandboxed sockets would swallow every
+            // probe and the command would report an empty network as if it were true.
+            AnsiConsole.MarkupLine(
+                "[red]Network scanning is not supported on this platform.[/] " +
+                "Run rpk on a machine attached to the network instead.");
+
+            return 1;
+        }
+
         Cidr cidr;
 
-        if (settings.Cidr != null) {
-            cidr = Cidr.Parse(settings.Cidr); // Validate() vouched for it
+        if (settings.ParsedCidr is { } requested) {
+            cidr = requested;
         }
         else {
             Cidr? detected = probe.LocalSubnet();
@@ -102,6 +111,16 @@ public sealed class DiscoverNetworkCommand(INetworkProbe probe)
                 return 1;
             }
 
+            // The same cap --cidr gets: a VPN or CGNAT interface can carry a /10, and
+            // auto-detection must never be the way around the sweep limit.
+            if (detected.Value.Prefix < NetworkScanner.MinPrefix) {
+                AnsiConsole.MarkupLine(
+                    $"[red]This machine's subnet is {Markup.Escape(detected.Value.ToString())} — more than " +
+                    $"65,534 hosts.[/] Pass --cidr with a narrower block, e.g. --cidr 192.168.1.0/24");
+
+                return 1;
+            }
+
             cidr = detected.Value;
         }
 

+ 4 - 1
Shared.Rcl/wwwroot/raw_docs/discovery-guide.md

@@ -357,7 +357,10 @@ rpk discover network --cidr 10.0.50.0/24 --push   # the server VLAN
 
 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:
+than inventing a new machine. One MAC answering on several addresses (a gateway's
+VIPs and aliases) is still one machine and becomes **one card**: the lowest address as
+its `ip`, every address in an `ips` label — so a VIP failing over never moves the
+machine's identity. Two caveats:
 
 - **Hosts beyond the local segment have no ARP entry** (a routed VLAN, a VPN subnet).
   Their identity falls back to the IP address, and the command says so — a DHCP

+ 35 - 0
Tests.Discovery/CidrParsingTests.cs

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

+ 25 - 12
Tests.Discovery/NetworkScanMapperTests.cs

@@ -78,21 +78,34 @@ public class NetworkScanMapperTests {
     }
 
     [Fact]
-    public void One_mac_answering_on_several_addresses_yields_distinct_stable_identities() {
-        // Gateways answer on VIPs and aliases all the time: one MAC, many addresses.
-        // The shared MAC alone cannot identify the cards — the import rejects duplicate
-        // ids — so each address folds into the seed, deterministically.
-        NetworkHostFact[] swept = [
+    public void One_mac_answering_on_several_addresses_is_one_machine_with_one_card() {
+        // Gateways answer on VIPs and aliases all the time: one MAC, many addresses —
+        // still one box. Collapsing keeps the import happy (duplicate ids are rejected)
+        // AND keeps identity independent of how many addresses answered this scan.
+        List<Resource> resources = NetworkScanMapper.ToResources([
+            Host(ip: "192.168.1.2", hostname: null), // the VIP, deliberately first
+            Host(ip: "192.168.1.1", hostname: "gw.lan")
+        ]);
+
+        SystemResource card = Assert.IsType<SystemResource>(Assert.Single(resources));
+        Assert.Equal("192.168.1.1", card.Ip); // the lowest address, deterministically
+        Assert.Equal("gw", card.Name); // the one name anywhere in the group
+        Assert.Equal("192.168.1.1,192.168.1.2", card.Labels["ips"]);
+    }
+
+    [Fact]
+    public void A_vip_appearing_or_disappearing_never_moves_the_machines_identity() {
+        // The regression that motivated the collapse: an id seeded on scan-local
+        // address counts flips when a keepalived VIP fails over. MAC alone, always.
+        List<Resource> alone = NetworkScanMapper.ToResources([
+            Host(ip: "192.168.1.1", hostname: "gw.lan")
+        ]);
+        List<Resource> withVip = NetworkScanMapper.ToResources([
             Host(ip: "192.168.1.1", hostname: "gw.lan"),
             Host(ip: "192.168.1.2", hostname: null)
-        ];
-
-        List<Resource> resources = NetworkScanMapper.ToResources(swept);
+        ]);
 
-        Assert.Equal(2, resources.Select(r => r.DiscoveryId).Distinct().Count());
-        Assert.Equal(
-            resources.Select(r => r.DiscoveryId),
-            NetworkScanMapper.ToResources(swept).Select(r => r.DiscoveryId));
+        Assert.Equal(alone[0].DiscoveryId, Assert.Single(withVip).DiscoveryId);
     }
 
     [Fact]

+ 13 - 1
Tests.Discovery/NetworkScannerTests.cs

@@ -98,6 +98,14 @@ public class NetworkScannerTests {
             $"{probe.MaxInFlight} hosts were probed at once; the cap was 4.");
     }
 
+    [Fact]
+    public async Task A_block_wider_than_the_cap_is_refused_wherever_it_came_from() {
+        // The floor lives in the scanner, not a front end: an auto-detected VPN /10
+        // must hit the same wall a typed --cidr does.
+        await Assert.ThrowsAsync<ArgumentOutOfRangeException>(() =>
+            NetworkScanner.ScanAsync(new ScriptedProbe(), Options("10.0.0.0/8")));
+    }
+
     /// <summary>Scripted IO: answers what it is told to, records what was asked of it.</summary>
     private sealed class ScriptedProbe : INetworkProbe {
         private readonly Lock _lock = new();
@@ -112,6 +120,7 @@ public class NetworkScannerTests {
 
         public List<(string Ip, int Port)> PortProbes { get; } = [];
         public List<string> DnsLookups { get; } = [];
+        public bool IsSupported => true;
         public int MaxInFlight { get; private set; }
         public bool ArpReadAfterSweep { get; private set; }
 
@@ -155,7 +164,10 @@ public class NetworkScannerTests {
             return Task.FromResult(Arp);
         }
 
-        public Task<string?> ReverseDnsAsync(string ip, CancellationToken cancellationToken = default) {
+        public Task<string?> ReverseDnsAsync(
+            string ip,
+            TimeSpan timeout,
+            CancellationToken cancellationToken = default) {
             lock (_lock) {
                 if (!_sweepDone)
                     throw new InvalidOperationException("Reverse DNS ran before the sweep finished.");

+ 4 - 0
Tests/Tests.csproj

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

+ 27 - 0
Tests/Yaml/SchemaTests.cs

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