Explorar o código

Merge pull request #348 from Timmoth/feature/network-host-identity

Discovery: services from ports, self-improving names, and symmetric identity
Tim Jones hai 1 día
pai
achega
b9b35be6a1
Modificáronse 60 ficheiros con 5677 adicións e 190 borrados
  1. 10 2
      RackPeek.Domain/Api/UpsertInventoryUseCase.cs
  2. 333 5
      RackPeek.Domain/Discovery/DiscoveryIdResolver.cs
  3. 19 0
      RackPeek.Domain/Discovery/DockerApiClient.cs
  4. 21 0
      RackPeek.Domain/Discovery/INetworkProbe.cs
  5. 12 0
      RackPeek.Domain/Discovery/IProxmoxClient.cs
  6. 106 0
      RackPeek.Domain/Discovery/MacVendorLookup.cs
  7. 653 0
      RackPeek.Domain/Discovery/MacVendorTable.g.cs
  8. 132 0
      RackPeek.Domain/Discovery/NetworkProbe.cs
  9. 38 1
      RackPeek.Domain/Discovery/NetworkScanFacts.cs
  10. 90 1
      RackPeek.Domain/Discovery/NetworkScanMapper.cs
  11. 171 2
      RackPeek.Domain/Discovery/NetworkScanner.cs
  12. 128 0
      RackPeek.Domain/Discovery/OpnsenseApiClient.cs
  13. 105 0
      RackPeek.Domain/Discovery/OpnsenseDiscovery.cs
  14. 120 0
      RackPeek.Domain/Discovery/OpnsenseModels.cs
  15. 30 0
      RackPeek.Domain/Discovery/ProxmoxApiClient.cs
  16. 107 0
      RackPeek.Domain/Discovery/ProxmoxDiscovery.cs
  17. 135 8
      RackPeek.Domain/Discovery/ProxmoxModels.cs
  18. 211 0
      RackPeek.Domain/Discovery/ServiceIdentityParser.cs
  19. 74 0
      RackPeek.Domain/Discovery/WellKnownPorts.cs
  20. 3 1
      RackPeek.Domain/Persistence/Yaml/YamlResourceCollection.cs
  21. 7 0
      RackPeek.Domain/RackPeek.Domain.csproj
  22. 28 0
      RackPeek.Domain/Resources/Resource.cs
  23. 15 21
      RackPeek.Domain/Resources/Services/Service.cs
  24. 87 0
      RackPeek.Domain/Resources/Services/ServiceEndpoint.cs
  25. 4 0
      RackPeek.Domain/UseCases/RenameResourceUseCase.cs
  26. 11 36
      RackPeek.Mcp/Tools/DiscoveryTools.cs
  27. 4 0
      RackPeek.Web.Viewer/wwwroot/schemas/v4/schema.v4.json
  28. 4 0
      RackPeek.Web/wwwroot/schemas/v4/schema.v4.json
  29. 5 0
      Shared.Rcl/CliBootstrap.cs
  30. 14 2
      Shared.Rcl/Commands/Discovery/DiscoverDockerCommand.cs
  31. 6 1
      Shared.Rcl/Commands/Discovery/DiscoverNetworkCommand.cs
  32. 100 0
      Shared.Rcl/Commands/Discovery/DiscoverOpnsenseCommand.cs
  33. 1 42
      Shared.Rcl/Commands/Discovery/DiscoverProxmoxCommand.cs
  34. 30 11
      Shared.Rcl/Components/HardwareDependencyTreeComponent.razor
  35. 4 21
      Shared.Rcl/Services/ServiceCardComponent.razor
  36. 30 11
      Shared.Rcl/Systems/SystemDependencyTreeComponent.razor
  37. 1 0
      Shared.Rcl/wwwroot/raw_docs/cli-commands-index.md
  38. 42 5
      Shared.Rcl/wwwroot/raw_docs/cli-commands.md
  39. 244 15
      Shared.Rcl/wwwroot/raw_docs/discovery-guide.md
  40. 86 0
      Tests.Discovery/Fixtures/opnsense-arp.json
  41. 36 0
      Tests.Discovery/Fixtures/pve-agent-interfaces.json
  42. 16 0
      Tests.Discovery/Fixtures/pve-lxc-interfaces.json
  43. 171 0
      Tests.Discovery/IpBridgeSymmetryTests.cs
  44. 96 0
      Tests.Discovery/MacVendorLookupTests.cs
  45. 217 0
      Tests.Discovery/NetworkIdentityTests.cs
  46. 204 1
      Tests.Discovery/NetworkScannerTests.cs
  47. 115 0
      Tests.Discovery/OpenPortServiceTests.cs
  48. 177 0
      Tests.Discovery/OpnsenseDiscoveryTests.cs
  49. 321 0
      Tests.Discovery/PlaceholderNameUpgradeTests.cs
  50. 207 0
      Tests.Discovery/ProxmoxGuestAddressTests.cs
  51. 63 0
      Tests.Discovery/ProxmoxQuotedNumberTests.cs
  52. 29 0
      Tests.Discovery/RemoteDockerDiscoveryTests.cs
  53. 274 0
      Tests.Discovery/RunsOnByIpTests.cs
  54. 115 0
      Tests.Discovery/ServiceEndpointTests.cs
  55. 138 0
      Tests.Discovery/ServiceIdentityParserTests.cs
  56. 24 0
      Tests.Mcp/DiscoveryToolTests.cs
  57. 7 4
      Tests.Mcp/FakeHttpServer.cs
  58. 33 0
      Tests/EndToEnd/DiscoveryTests/DiscoverDockerEndpointTests.cs
  59. 209 0
      generate-oui-table.py
  60. 4 0
      schemas/v4/schema.v4.json

+ 10 - 2
RackPeek.Domain/Api/UpsertInventoryUseCase.cs

@@ -63,9 +63,18 @@ public class UpsertInventoryUseCase(
         List<Resource>? incomingResources = incomingRoot.Resources;
         List<Resource>? incomingResources = incomingRoot.Resources;
         IReadOnlyList<Resource> currentResources = await repo.GetAllOfTypeAsync<Resource>();
         IReadOnlyList<Resource> currentResources = await repo.GetAllOfTypeAsync<Resource>();
 
 
+        IReadOnlyList<Connection> currentConnections = await repo.GetConnectionsAsync();
+
         // Line discovered resources up with what they already map to before anything
         // Line discovered resources up with what they already map to before anything
         // else looks at names, so the diff below reports against the right resources.
         // else looks at names, so the diff below reports against the right resources.
-        DiscoveryIdResolver.ResolveNames(currentResources, incomingResources, incomingRoot.Connections);
+        // A dry run gets the same reconciliation but is not allowed to improve stored
+        // names, because that rewrites the inventory and a dry run must not.
+        DiscoveryIdResolver.ResolveNames(
+            currentResources,
+            incomingResources,
+            incomingRoot.Connections,
+            currentConnections,
+            !request.DryRun);
 
 
         IGrouping<string, Resource>? duplicate = incomingResources
         IGrouping<string, Resource>? duplicate = incomingResources
             .GroupBy(r => r.Name, StringComparer.OrdinalIgnoreCase)
             .GroupBy(r => r.Name, StringComparer.OrdinalIgnoreCase)
@@ -123,7 +132,6 @@ public class UpsertInventoryUseCase(
             else if (oldYaml != newYaml) response.Updated.Add(incoming.Name);
             else if (oldYaml != newYaml) response.Updated.Add(incoming.Name);
         }
         }
 
 
-        IReadOnlyList<Connection> currentConnections = await repo.GetConnectionsAsync();
         List<Connection>? mergedConnections = ConnectionMerger.Merge(
         List<Connection>? mergedConnections = ConnectionMerger.Merge(
             currentConnections,
             currentConnections,
             incomingRoot.Connections,
             incomingRoot.Connections,

+ 333 - 5
RackPeek.Domain/Discovery/DiscoveryIdResolver.cs

@@ -1,6 +1,8 @@
 using System.ComponentModel.DataAnnotations;
 using System.ComponentModel.DataAnnotations;
 using RackPeek.Domain.Resources;
 using RackPeek.Domain.Resources;
 using RackPeek.Domain.Resources.Connections;
 using RackPeek.Domain.Resources.Connections;
+using RackPeek.Domain.Resources.Services;
+using RackPeek.Domain.Resources.SystemResources;
 
 
 namespace RackPeek.Domain.Discovery;
 namespace RackPeek.Domain.Discovery;
 
 
@@ -19,11 +21,21 @@ public static class DiscoveryIdResolver {
     ///     the stored resources the ids point at. Also rewrites <c>runsOn</c> references
     ///     the stored resources the ids point at. Also rewrites <c>runsOn</c> references
     ///     between incoming resources — and the payload's <paramref name="connections" />,
     ///     between incoming resources — and the payload's <paramref name="connections" />,
     ///     which name resources the same way — so a rename does not break the tree.
     ///     which name resources the same way — so a rename does not break the tree.
+    ///     <para>
+    ///         The one exception is <paramref name="improveStoredNames" />, which lets a
+    ///         stored placeholder nobody chose be replaced by a real name this payload
+    ///         knows — see <see cref="CanImproveName" />. That rewrites the stored side, so
+    ///         only a caller that is about to persist should ask for it, and it must hand
+    ///         over <paramref name="storedConnections" /> for the same reason the incoming
+    ///         side hands over its own.
+    ///     </para>
     /// </summary>
     /// </summary>
     public static void ResolveNames(
     public static void ResolveNames(
         IReadOnlyList<Resource> existing,
         IReadOnlyList<Resource> existing,
         IReadOnlyList<Resource> incoming,
         IReadOnlyList<Resource> incoming,
-        IReadOnlyList<Connection>? connections = null) {
+        IReadOnlyList<Connection>? connections = null,
+        IReadOnlyList<Connection>? storedConnections = null,
+        bool improveStoredNames = false) {
         var incomingWithId = incoming
         var incomingWithId = incoming
             .Where(r => !string.IsNullOrWhiteSpace(r.DiscoveryId))
             .Where(r => !string.IsNullOrWhiteSpace(r.DiscoveryId))
             .ToList();
             .ToList();
@@ -39,6 +51,7 @@ public static class DiscoveryIdResolver {
             .ToDictionary(r => r.DiscoveryId!, r => r, StringComparer.OrdinalIgnoreCase);
             .ToDictionary(r => r.DiscoveryId!, r => r, StringComparer.OrdinalIgnoreCase);
 
 
         Dictionary<string, Resource> existingByMac = BuildMacMap(existing);
         Dictionary<string, Resource> existingByMac = BuildMacMap(existing);
+        Dictionary<string, Resource> existingByIp = BuildIpMap(existing);
 
 
         // Tolerant of a hand-edited file that managed to get two resources of the
         // Tolerant of a hand-edited file that managed to get two resources of the
         // same name: the first wins, rather than crashing the import.
         // same name: the first wins, rather than crashing the import.
@@ -49,13 +62,37 @@ public static class DiscoveryIdResolver {
 
 
         var renames = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
         var renames = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
 
 
+        var storedRenames = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
+
         foreach (Resource resource in incomingWithId) {
         foreach (Resource resource in incomingWithId) {
-            var resolved = ResolveName(resource, existingById, existingByName, existingByMac);
+            // Captured before resolution, which may null the id as part of unifying.
+            var offeredName = resource.Name;
+            var offeredId = resource.DiscoveryId;
+
+            var resolved = ResolveName(resource, existingById, existingByName, existingByMac, existingByIp);
+
+            if (resolved.Equals(offeredName, StringComparison.OrdinalIgnoreCase))
+                continue;
+
+            // The stored card is about to lend its name to this one. If that name is a
+            // placeholder nobody chose and this collector has a real one, the better name
+            // should win instead — so the card improves as more is learned about it.
+            if (improveStoredNames
+                && existingByName.TryGetValue(resolved, out Resource? stored)
+                && !existingByName.ContainsKey(offeredName)
+                && CanImproveName(stored, offeredName, offeredId)) {
+                storedRenames[stored.Name] = offeredName;
+
+                // The name index has to follow, or a later card in this same payload would
+                // resolve onto a name that no longer exists.
+                existingByName.Remove(stored.Name);
+                stored.Name = offeredName;
+                existingByName[offeredName] = stored;
 
 
-            if (resolved.Equals(resource.Name, StringComparison.OrdinalIgnoreCase))
                 continue;
                 continue;
+            }
 
 
-            renames[resource.Name] = resolved;
+            renames[offeredName] = resolved;
             resource.Name = resolved;
             resource.Name = resolved;
         }
         }
 
 
@@ -64,7 +101,190 @@ public static class DiscoveryIdResolver {
             RewriteConnections(connections, renames);
             RewriteConnections(connections, renames);
         }
         }
 
 
+        // The stored side has its own references to fix up, and its own connections. The
+        // incoming side gets the same treatment because a payload may well be a re-push of
+        // previously exported YAML, which still names the resource the way it was stored.
+        if (storedRenames.Count > 0) {
+            RewriteRunsOn(existing, storedRenames);
+
+            // Now that the hosts answer to their new names, the services named after the
+            // old ones follow. Done here so the renames below travel together.
+            foreach ((var from, var to) in RenameServicesAfterTheirHost(existing, storedRenames, existingByName))
+                storedRenames[from] = to;
+
+            RewriteConnections(storedConnections, storedRenames);
+            RewriteRunsOn(incoming, storedRenames);
+            RewriteConnections(connections, storedRenames);
+        }
+
         PreserveStoredRunsOn(incomingWithId, incoming, existingById, existingByName);
         PreserveStoredRunsOn(incomingWithId, incoming, existingById, existingByName);
+        AnchorRunsOnByIp(existing, incoming, existingByName);
+    }
+
+    /// <summary>
+    ///     Carries a service's name along when the host it runs on stops being a
+    ///     placeholder.
+    ///     <para>
+    ///         A sweep names what it finds on a port after the host it found it on, so a
+    ///         machine it could only call <c>host-1a2b3c4d</c> gets a <c>host-1a2b3c4d-ssh</c>
+    ///         beside it. When the firewall or the hypervisor later supplies the real name
+    ///         the host becomes <c>forgejo</c> and the service is left announcing a machine
+    ///         that no longer exists — the link still resolves, but the name reads as a
+    ///         leftover, which is exactly what it is.
+    ///     </para>
+    ///     <para>
+    ///         Only names this collector's own convention produced are touched: the
+    ///         service must be named for the old host and must actually run on it, must
+    ///         not be a name a person chose, and the name it would take must be free.
+    ///     </para>
+    /// </summary>
+    private static Dictionary<string, string> RenameServicesAfterTheirHost(
+        IReadOnlyList<Resource> existing,
+        Dictionary<string, string> hostRenames,
+        Dictionary<string, Resource> existingByName) {
+        var renamed = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
+
+        foreach (Service service in existing.OfType<Service>()) {
+            if (service.IsUserNamed())
+                continue;
+
+            foreach ((var oldHost, var newHost) in hostRenames) {
+                if (!service.Name.StartsWith($"{oldHost}-", StringComparison.OrdinalIgnoreCase))
+                    continue;
+
+                // runsOn has already been rewritten, so this is the new name by now. A
+                // service merely named like the host without running on it is a
+                // coincidence, and coincidences are not renamed.
+                if (!service.RunsOn.Contains(newHost, StringComparer.OrdinalIgnoreCase))
+                    continue;
+
+                var candidate = $"{newHost}{service.Name[oldHost.Length..]}";
+
+                if (existingByName.ContainsKey(candidate))
+                    break;
+
+                renamed[service.Name] = candidate;
+                existingByName.Remove(service.Name);
+                service.Name = candidate;
+                existingByName[candidate] = service;
+
+                break;
+            }
+        }
+
+        return renamed;
+    }
+
+    /// <summary>
+    ///     Whether a stored card's name is a placeholder that this collector can improve
+    ///     on.
+    ///     <para>
+    ///         A discovered card is named from whatever the collector could see, and when
+    ///         that was nothing it falls back to a slug of its own id — <c>host-1a2b3c4d</c>
+    ///         says only that something is there. A later run, or a collector that can see
+    ///         more, often does know the machine's name: a firewall knows what it handed
+    ///         out over DHCP, a hypervisor knows what its guest is called. Keeping the
+    ///         placeholder in that case would mean the inventory never improved.
+    ///     </para>
+    ///     <para>
+    ///         Only ever placeholder to real name, and never over a name a person chose.
+    ///         Real to real is left alone on purpose: two collectors that each know a
+    ///         different name for a machine would otherwise rename it back and forth on
+    ///         every run.
+    ///     </para>
+    /// </summary>
+    private static bool CanImproveName(Resource stored, string offeredName, string? offeredId) =>
+        !stored.IsUserNamed()
+        && IsGeneratedName(stored.Name, stored.DiscoveryId)
+        && !IsGeneratedName(offeredName, offeredId);
+
+    /// <summary>
+    ///     Whether a name is the slug-of-its-own-id form <see cref="DiscoveryNaming.Suggest" />
+    ///     falls back to when the collector had nothing better to offer.
+    /// </summary>
+    private static bool IsGeneratedName(string name, string? discoveryId) =>
+        !string.IsNullOrWhiteSpace(discoveryId)
+        && name.EndsWith($"-{DiscoveryId.ShortSuffix(discoveryId)}", StringComparison.OrdinalIgnoreCase);
+
+    /// <summary>
+    ///     Gives a service whose <c>runsOn</c> names nothing the host it is plainly
+    ///     running on: the system at its own address.
+    ///     <para>
+    ///         A collector that cannot see the machine it is talking to has to guess the
+    ///         host's name — docker over TCP sends whatever the engine calls itself, which
+    ///         need not match any resource — and the link then dangles. The address is
+    ///         evidence the guess is not: a service answering on 192.0.2.57 is running on
+    ///         whatever owns 192.0.2.57.
+    ///     </para>
+    ///     <para>
+    ///         Deliberately conservative. It only fills a link that resolves to nothing,
+    ///         only when exactly one system claims that address, and never across a
+    ///         resource that already has a working parent — an ambiguous address is no
+    ///         evidence at all, and a wrong parent is worse than a missing one.
+    ///     </para>
+    /// </summary>
+    private static void AnchorRunsOnByIp(
+        IReadOnlyList<Resource> existing,
+        IReadOnlyList<Resource> incoming,
+        Dictionary<string, Resource> existingByName) {
+        var services = incoming.OfType<Service>().ToList();
+
+        if (services.Count == 0)
+            return;
+
+        var incomingNames = new HashSet<string>(
+            incoming.Select(r => r.Name),
+            StringComparer.OrdinalIgnoreCase);
+
+        // Both sides count: the host may have arrived in this very payload (discover
+        // docker emits it alongside its services) or be sitting in the inventory already.
+        // Stored systems are gathered separately because they win — see below.
+        Dictionary<string, List<string>> storedByIp = IndexByIp(existing);
+        Dictionary<string, List<string>> arrivingByIp = IndexByIp(incoming);
+
+        foreach (Service service in services) {
+            var ip = service.Network?.Ip;
+
+            if (string.IsNullOrWhiteSpace(ip))
+                continue;
+
+            var anchored = service.RunsOn.Any(name =>
+                existingByName.ContainsKey(name) || incomingNames.Contains(name));
+
+            if (anchored)
+                continue;
+
+            // A stored system beats one arriving in this payload when both claim the
+            // address. They are usually the same machine seen twice — a hypervisor knows
+            // its guest by name and specification, a sweep only found something
+            // answering — and the stored card is the one a person recognises.
+            List<string>? candidates =
+                storedByIp.TryGetValue(ip, out List<string>? stored) ? stored
+                : arrivingByIp.TryGetValue(ip, out List<string>? arriving) ? arriving
+                : null;
+
+            if (candidates is not [var host])
+                continue;
+
+            service.RunsOn = [host];
+        }
+    }
+
+    private static Dictionary<string, List<string>> IndexByIp(IReadOnlyList<Resource> resources) {
+        var byIp = new Dictionary<string, List<string>>(StringComparer.OrdinalIgnoreCase);
+
+        foreach (SystemResource system in resources.OfType<SystemResource>()) {
+            if (string.IsNullOrWhiteSpace(system.Ip))
+                continue;
+
+            if (!byIp.TryGetValue(system.Ip, out List<string>? names))
+                byIp[system.Ip] = names = [];
+
+            if (!names.Contains(system.Name, StringComparer.OrdinalIgnoreCase))
+                names.Add(system.Name);
+        }
+
+        return byIp;
     }
     }
 
 
     /// <summary>
     /// <summary>
@@ -103,7 +323,8 @@ public static class DiscoveryIdResolver {
         Resource resource,
         Resource resource,
         Dictionary<string, Resource> existingById,
         Dictionary<string, Resource> existingById,
         Dictionary<string, Resource> existingByName,
         Dictionary<string, Resource> existingByName,
-        Dictionary<string, Resource> existingByMac) {
+        Dictionary<string, Resource> existingByMac,
+        Dictionary<string, Resource> existingByIp) {
         // Known id: the stored resource wins on name, whatever the user has renamed it to.
         // Known id: the stored resource wins on name, whatever the user has renamed it to.
         if (existingById.TryGetValue(resource.DiscoveryId!, out Resource? matched))
         if (existingById.TryGetValue(resource.DiscoveryId!, out Resource? matched))
             return matched.Name;
             return matched.Name;
@@ -113,6 +334,9 @@ public static class DiscoveryIdResolver {
         if (TryUnifyByMac(resource, existingByMac, out var unifiedName))
         if (TryUnifyByMac(resource, existingByMac, out var unifiedName))
             return unifiedName;
             return unifiedName;
 
 
+        if (TryUnifyByIp(resource, existingByIp, out unifiedName))
+            return unifiedName;
+
         // Unknown id and the name is free: nothing to reconcile.
         // Unknown id and the name is free: nothing to reconcile.
         if (!existingByName.TryGetValue(resource.Name, out Resource? sameName))
         if (!existingByName.TryGetValue(resource.Name, out Resource? sameName))
             return resource.Name;
             return resource.Name;
@@ -191,6 +415,110 @@ public static class DiscoveryIdResolver {
         return false;
         return false;
     }
     }
 
 
+    /// <summary>
+    ///     The bridge for machines a sweep cannot identify by MAC at all: ARP is
+    ///     link-local, so a host on any subnet but the scanner's own yields no MAC and
+    ///     its identity falls back to its address. Once a hypervisor reports its guests'
+    ///     addresses, that same address is the only thing tying the sweep's find to the
+    ///     guest the inventory describes in full.
+    ///     <para>
+    ///         Which of the two arrived first must not matter, so this reads the same in
+    ///         both directions: one scan-grade card that produced no MAC, one agent-grade
+    ///         card, one address, same kind. The agent-grade identity always wins — it is
+    ///         dropped from the incoming card when the incoming card is the scan (so the
+    ///         merge cannot downgrade the stored one) and kept when the incoming card is
+    ///         the agent (so the merge upgrades the stored one).
+    ///     </para>
+    ///     <para>
+    ///         Two scan cards can bridge as well, but only when exactly one of them saw a
+    ///         MAC. A firewall's neighbour table gives an address <em>and</em> the NIC
+    ///         answering at it; a sweep of a subnet it does not sit on gives an address
+    ///         and nothing else. Those are not two stand-ins — one is a direct observation
+    ///         of a specific interface and the other is "something replied" — so the
+    ///         MAC-bearing card wins the identity and the address-only card folds into it.
+    ///     </para>
+    ///     <para>
+    ///         Narrow on purpose. Against an agent-grade card the scan side must have no
+    ///         MAC at all: one that has a MAC either unified through the MAC bridge
+    ///         already or genuinely disagrees, and disagreement is not evidence. Two cards
+    ///         of equal standing never bridge — both agent-grade, both scan-grade with a
+    ///         MAC, or both scan-grade without one — because an address adds nothing when
+    ///         neither side can better it. And the address must be claimed by exactly one
+    ///         stored card, which <see cref="BuildIpMap" /> guarantees: two cards on one
+    ///         address is a conflict or an overlapping subnet, neither of which is
+    ///         evidence of anything.
+    ///     </para>
+    /// </summary>
+    private static bool TryUnifyByIp(
+        Resource resource,
+        Dictionary<string, Resource> existingByIp,
+        out string unifiedName) {
+        unifiedName = string.Empty;
+
+        if (resource is not SystemResource { Ip: { } ip } || string.IsNullOrWhiteSpace(ip))
+            return false;
+
+        if (!existingByIp.TryGetValue(ip, out Resource? stored)
+            || stored.GetType() != resource.GetType())
+            return false;
+
+        var incomingIsNet = DiscoveryId.Scheme(resource.DiscoveryId) == DiscoveryId.NetworkScheme;
+        var storedIsNet = DiscoveryId.Scheme(stored.DiscoveryId) == DiscoveryId.NetworkScheme;
+
+        var incomingHasMac = MacsOf(resource).Any();
+        var storedHasMac = MacsOf(stored).Any();
+
+        // Which side holds the weaker identity, and so folds into the other. Null means
+        // the two are of equal standing and the address settles nothing.
+        bool? incomingIsWeaker =
+            incomingIsNet != storedIsNet
+                // Agent grade against scan grade. The scan is the weaker one, but only
+                // when it saw no MAC of its own — one that did either unified through the
+                // MAC bridge already or disagrees with the card it would be folded into.
+                ? (incomingIsNet ? incomingHasMac : storedHasMac) ? null : incomingIsNet
+            : !incomingIsNet
+                // Two agent-grade identities. A guest and the machine-id of the OS inside
+                // it are two cards on purpose; sharing an address does not change that.
+                ? null
+                // Two scan-grade cards: a MAC beats an address, and nothing beats nothing.
+                : incomingHasMac == storedHasMac ? null : !incomingHasMac;
+
+        if (incomingIsWeaker is not { } weaker)
+            return false;
+
+        // Same as the MAC bridge: the weaker identity is dropped so the merge cannot
+        // downgrade the stronger one. Where the stronger card is the one arriving, its id
+        // survives and the merge stamps it onto the stored card instead.
+        if (weaker)
+            resource.DiscoveryId = null;
+
+        unifiedName = stored.Name;
+
+        return true;
+    }
+
+    /// <summary>
+    ///     Systems by address, excluding any address more than one of them claims — an
+    ///     ambiguous address is not evidence.
+    /// </summary>
+    private static Dictionary<string, Resource> BuildIpMap(IReadOnlyList<Resource> existing) {
+        var byIp = new Dictionary<string, Resource>(StringComparer.OrdinalIgnoreCase);
+        var ambiguous = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
+
+        foreach (SystemResource system in existing.OfType<SystemResource>()) {
+            if (string.IsNullOrWhiteSpace(system.Ip))
+                continue;
+
+            if (!byIp.TryAdd(system.Ip, system))
+                ambiguous.Add(system.Ip);
+        }
+
+        foreach (var ip in ambiguous)
+            byIp.Remove(ip);
+
+        return byIp;
+    }
+
     /// <summary>The MACs a resource claims, from its "mac" and "macs" labels, normalised.</summary>
     /// <summary>The MACs a resource claims, from its "mac" and "macs" labels, normalised.</summary>
     private static IEnumerable<string> MacsOf(Resource resource) {
     private static IEnumerable<string> MacsOf(Resource resource) {
         IEnumerable<string?> raw = [
         IEnumerable<string?> raw = [

+ 19 - 0
RackPeek.Domain/Discovery/DockerApiClient.cs

@@ -98,12 +98,31 @@ public sealed class DockerApiClient : IDockerClient, IDisposable {
             return (UnixSocketClient(path), dockerHost);
             return (UnixSocketClient(path), dockerHost);
         }
         }
 
 
+        // Anything else is rejected here rather than at send time. HttpClient accepts an
+        // ssh:// or npipe:// URI happily and only throws NotSupportedException on the
+        // first request — which is not in the caller's catch list, so a DOCKER_HOST that
+        // `docker context` set up quite normally crashed with a stack trace instead of
+        // saying what was wrong.
+        if (!StartsWithScheme(dockerHost, "tcp://")
+            && !StartsWithScheme(dockerHost, "http://")
+            && !StartsWithScheme(dockerHost, "https://"))
+            // A UriFormatException on purpose: both front ends already turn that into
+            // "not a usable Docker endpoint", so there is one phrasing for a bad endpoint
+            // rather than two.
+            throw new UriFormatException(
+                "Use a unix socket (unix:///var/run/docker.sock) or a TCP endpoint "
+                + "(tcp://host:2375). For an ssh:// context, forward the socket first — "
+                + "ssh -L 2375:/var/run/docker.sock user@host — and point --docker-host at that.");
+
         // tcp:// is the scheme people have in DOCKER_HOST, but it is plain HTTP on the wire.
         // tcp:// is the scheme people have in DOCKER_HOST, but it is plain HTTP on the wire.
         var uri = new Uri(dockerHost.Replace("tcp://", "http://", StringComparison.OrdinalIgnoreCase));
         var uri = new Uri(dockerHost.Replace("tcp://", "http://", StringComparison.OrdinalIgnoreCase));
 
 
         return (new HttpClient { BaseAddress = uri }, dockerHost);
         return (new HttpClient { BaseAddress = uri }, dockerHost);
     }
     }
 
 
+    private static bool StartsWithScheme(string value, string scheme) =>
+        value.StartsWith(scheme, StringComparison.OrdinalIgnoreCase);
+
     private static HttpClient UnixSocketClient(string socketPath) {
     private static HttpClient UnixSocketClient(string socketPath) {
         var handler = new SocketsHttpHandler {
         var handler = new SocketsHttpHandler {
             ConnectCallback = async (_, cancellationToken) => {
             ConnectCallback = async (_, cancellationToken) => {

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

@@ -32,6 +32,27 @@ public interface INetworkProbe {
     /// <summary>The host's reverse-DNS name, or null when it has none worth keeping.</summary>
     /// <summary>The host's reverse-DNS name, or null when it has none worth keeping.</summary>
     Task<string?> ReverseDnsAsync(string ip, TimeSpan timeout, CancellationToken cancellationToken = default);
     Task<string?> ReverseDnsAsync(string ip, TimeSpan timeout, CancellationToken cancellationToken = default);
 
 
+    /// <summary>
+    ///     The subject line of the certificate a TLS port presents, raw, or null when the
+    ///     port is closed or speaks no TLS. Self-signed certificates are the norm on a
+    ///     homelab — Proxmox and OPNsense both ship one naming the host — so the
+    ///     certificate is read without being trusted, and nothing is ever sent over the
+    ///     connection.
+    /// </summary>
+    Task<string?> ReadTlsSubjectAsync(string ip, int port, TimeSpan timeout, CancellationToken cancellationToken = default);
+
+    /// <summary>
+    ///     The first line a port volunteers on connect, before anything is sent to it —
+    ///     what SSH greets with. Null when the port is closed or stays silent.
+    /// </summary>
+    Task<string?> ReadTcpBannerAsync(string ip, int port, TimeSpan timeout, CancellationToken cancellationToken = default);
+
+    /// <summary>
+    ///     The head of an HTTP response to <c>GET /</c>: status line, headers, and enough
+    ///     body to reach a &lt;title&gt;. Null when the port serves no HTTP.
+    /// </summary>
+    Task<string?> ReadHttpHeadAsync(string ip, int port, bool tls, TimeSpan timeout, CancellationToken cancellationToken = default);
+
     /// <summary>
     /// <summary>
     ///     The subnet of the first up, non-loopback IPv4 interface with a gateway — what
     ///     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.
     ///     `--cidr` defaults to. Null when the machine has no such interface.

+ 12 - 0
RackPeek.Domain/Discovery/IProxmoxClient.cs

@@ -41,4 +41,16 @@ public interface IProxmoxClient {
         string endpoint,
         string endpoint,
         int vmId,
         int vmId,
         CancellationToken cancellationToken = default);
         CancellationToken cancellationToken = default);
+
+    /// <summary>
+    ///     Addresses the guest reports for its own interfaces — the only way to learn a
+    ///     DHCP guest's address, since the config only carries one when it was set
+    ///     statically. Needs the guest agent for a VM and a running container for LXC,
+    ///     so an empty list is the normal answer for anything that has neither.
+    /// </summary>
+    Task<IReadOnlyList<ProxmoxGuestAddress>> GetGuestAddressesAsync(
+        string node,
+        string endpoint,
+        int vmId,
+        CancellationToken cancellationToken = default);
 }
 }

+ 106 - 0
RackPeek.Domain/Discovery/MacVendorLookup.cs

@@ -0,0 +1,106 @@
+namespace RackPeek.Domain.Discovery;
+
+/// <summary>
+///     Turns a MAC address into the organisation IEEE assigned its OUI to — the only
+///     identity a silent device on the wire ever volunteers. A camera that answers no
+///     port and has no PTR record is still recognisably an Espressif or a Ubiquiti.
+///     Pure: the table is generated (see <see cref="MacVendorTable" />), never fetched.
+/// </summary>
+public static class MacVendorLookup {
+    /// <summary>
+    ///     True when the address was made up by the device rather than assigned by a
+    ///     manufacturer — the locally-administered bit is set. Modern phones and laptops
+    ///     randomise per network for privacy, so these carry no vendor at all, and the
+    ///     OUI half is meaningless rather than merely unknown. Saying so is more useful
+    ///     than reporting whichever company happens to own the matching block.
+    /// </summary>
+    public static bool IsLocallyAdministered(string? mac) {
+        var octet = FirstOctet(mac);
+
+        return octet >= 0 && (octet & 0x02) != 0;
+    }
+
+    /// <summary>
+    ///     The assigned vendor, "Randomised (locally administered)" for a self-assigned
+    ///     address, or null when the OUI is not in the curated table. Null means "we do
+    ///     not know", never "no vendor".
+    /// </summary>
+    public static string? Lookup(string? mac) {
+        var prefix = NormalisePrefix(mac);
+
+        if (prefix == null)
+            return null;
+
+        var index = IndexOf(prefix);
+
+        // The table is consulted before the locally-administered check on purpose:
+        // hypervisors mint guest addresses out of that range (KVM's 52:54:00), so the
+        // bit alone would report a VM as anonymous when its prefix names the emulator.
+        if (index < 0)
+            return IsLocallyAdministered(mac)
+                ? "Randomised (locally administered)"
+                : null;
+
+        var vendorIndex = MacVendorTable.Records[index + 6] - MacVendorTable.FirstIndexChar;
+
+        return vendorIndex >= 0 && vendorIndex < MacVendorTable.Vendors.Length
+            ? MacVendorTable.Vendors[vendorIndex]
+            : null;
+    }
+
+    /// <summary>The first six hex digits, upper-cased, or null when that cannot be read.</summary>
+    private static string? NormalisePrefix(string? mac) {
+        if (string.IsNullOrWhiteSpace(mac))
+            return null;
+
+        Span<char> digits = stackalloc char[6];
+        var count = 0;
+
+        foreach (var c in mac) {
+            if (!Uri.IsHexDigit(c))
+                continue;
+
+            digits[count++] = char.ToUpperInvariant(c);
+
+            if (count == 6)
+                return new string(digits);
+        }
+
+        return null;
+    }
+
+    private static int FirstOctet(string? mac) {
+        var prefix = NormalisePrefix(mac);
+
+        return prefix == null
+            ? -1
+            : Convert.ToInt32(prefix[..2], 16);
+    }
+
+    /// <summary>
+    ///     Binary search over the packed records. Returns the index of the matching
+    ///     record's first char, or -1.
+    /// </summary>
+    private static int IndexOf(string prefix) {
+        var records = MacVendorTable.Records;
+        var low = 0;
+        var high = records.Length / MacVendorTable.RecordLength - 1;
+
+        while (low <= high) {
+            var mid = (low + high) / 2;
+            var at = mid * MacVendorTable.RecordLength;
+
+            var comparison = string.CompareOrdinal(records, at, prefix, 0, 6);
+
+            if (comparison == 0)
+                return at;
+
+            if (comparison < 0)
+                low = mid + 1;
+            else
+                high = mid - 1;
+        }
+
+        return -1;
+    }
+}

+ 653 - 0
RackPeek.Domain/Discovery/MacVendorTable.g.cs

@@ -0,0 +1,653 @@
+// <auto-generated>
+//     Generated by generate-oui-table.py from the IEEE OUI registry
+//     (https://standards-oui.ieee.org/oui/oui.csv). Do not edit by hand — add a
+//     vendor pattern to the generator and re-run it instead.
+//
+//     10868 assignments across 87 vendors, packed as fixed-width
+//     "PPPPPPv" records (6 hex prefix chars + one vendor index char), sorted so
+//     MacVendorLookup can binary-search them without building a dictionary.
+// </auto-generated>
+
+namespace RackPeek.Domain.Discovery;
+
+internal static class MacVendorTable {
+    internal const int RecordLength = 7;
+
+    internal const char FirstIndexChar = '!';
+
+    internal static readonly string[] Vendors = [
+        "AMD",
+        "ASRock",
+        "ASUS",
+        "AVM (Fritz!Box)",
+        "Amazon",
+        "Apple",
+        "Aqara",
+        "Aquantia",
+        "Arlo",
+        "Arris/CommScope",
+        "Aruba",
+        "Asustor",
+        "Axis",
+        "Beelink",
+        "Broadcom",
+        "Buffalo",
+        "Chelsio",
+        "Cisco",
+        "D-Link",
+        "Dahua",
+        "Dell",
+        "Edimax",
+        "Espressif",
+        "Fortinet",
+        "FriendlyELEC",
+        "Gigabyte",
+        "Google",
+        "HPE/HP",
+        "Hardkernel (ODROID)",
+        "Hikvision",
+        "Huawei",
+        "IKEA",
+        "Intel",
+        "Juniper",
+        "Khadas",
+        "LG",
+        "Lenovo",
+        "MSI",
+        "Mellanox",
+        "Microsoft",
+        "MikroTik",
+        "Minisforum",
+        "NVIDIA",
+        "Netgate",
+        "Netgear",
+        "Nintendo",
+        "Nordic Semiconductor",
+        "Parallels",
+        "Philips",
+        "Pine64",
+        "Proxmox",
+        "QEMU/KVM",
+        "QNAP",
+        "Radxa",
+        "Raspberry Pi",
+        "Realtek",
+        "Reolink",
+        "Ring",
+        "Roku",
+        "Sagemcom",
+        "Samsung",
+        "Seagate",
+        "Sercomm",
+        "Shelly",
+        "Signify (Hue)",
+        "Silicon Labs",
+        "Sonoff",
+        "Sonos",
+        "Sony",
+        "Sophos",
+        "Supermicro",
+        "Synology",
+        "TP-Link",
+        "Technicolor",
+        "Tenda",
+        "TerraMaster",
+        "Texas Instruments",
+        "Tuya",
+        "Ubiquiti",
+        "VMware",
+        "VirtualBox",
+        "Western Digital",
+        "Wyze",
+        "Xen",
+        "Xiaomi",
+        "Zotac",
+        "Zyxel"
+    ];
+
+    internal static readonly string Records =
+        "00000C200009750000B460000F0]00014220001432000144500014Ae00016320001642000196200019720001C720001C920001E6<0001E7<0002162000217200023D200024A2" +
+        "00024B200026CQ00027D200027E20002A5<0002B3A0002B920002BA20002C9G0002FC20002FD200033120003322000347A00036B200036C2000393&00039F20003A020003E32" +
+        "0003E420003FD20003FE20003FFH00040E$00041Fe000423A0004272000428200044BK00044D200044E200046D200046E200049A200049B20004C020004C120004CF^0004DD2" +
+        "0004DE20004EA<00050020005012000502&0005312000532200054EQ00055D300055E200055F2000569p00057320005742000585B00059A200059B20005B5/0005DC20005DD2" +
+        "000628200062A20006522000653200065B500067C20006C120006D620006D720006F6200070D200070E20007400000743100074F2000750200077D2000784200078520007AB]" +
+        "0007B320007B420007E9A0007EB20007EC2000802<0008202000821200082F2000830200083120008322000874500087C200087D2000883<0008A320008A420008C220008C6Q" +
+        "0008C7<0008E220008E3200090F8000911200091220009432000944200095BM00095CQ00097B200097C20009B620009B720009BFN0009E820009E920009FBQ000A27&000A412" +
+        "000A422000A57<000A8A2000A8B2000A95&000AB72000AB82000AD9e000AEBi000AF32000AF42000AF7/000B452000B462000B57b000B5F2000B602000B852000B86<000BBE2" +
+        "000BBF2000BCD<000BDB5000BFC2000BFD2000C29p000C302000C312000C42I000C50^000C6E#000C852000C862000CCAr000CCE2000CCF2000CE68000CF1A000D0B0000D282" +
+        "000D292000D4B[000D565000D652000D662000D883000D93&000D9D<000DB6/000DBC2000DBD2000DEC2000DED2000E07e000E0CA000E2E6000E35A000E382000E392000E58d" +
+        "000E59\\000E7F<000E832000E842000E8F_000EA6#000EB3<000ED62000ED72000F1F5000F20<000F232000F242000F342000F352000F3D3000F4Fq000F61<000F8F2000F902" +
+        "000FB5M000FDEe000FEA:000FF72000FF82001007200100B200100D200101120010142001018/00101F2001029200102F20010542001079200107B2001083<0010A620010DBB" +
+        "0010E3<0010F620010FA&0010FF2001109F00110A<001111A00112020011212001124&00112F#001132h001143500115C200115D2001175A001185<001192200119320011953" +
+        "0011BB20011BC20011C6^0011D8#0012002001201200121EB001237m00123F500124320012442001247]001248500124Bm00125AH001279<00127F200128020012D1m0012D2m" +
+        "0012D920012DA20012EEe0012F0A0012FB]0012FEE001302A001315e001319200131A2001320A001321<0013463001349w00135F200136020013725001377]00137F20013802" +
+        "0013A9e0013C320013C420013CEA0013D4#0013E8A00141B200141C20014225001438<001451&001469200146A200146CM001478i0014A820014A920014C2<0014C3^0014EEr" +
+        "0014F120014F220014F6B001500A00150C$001517A00152B200152C20015305001556\\00155DH001560<0015622001563200156Do001599]0015B9]0015C1e0015C550015C62" +
+        "0015C720015E930015F2#0015F920015FA20016010001620e001632]001635<00163Et00164620016472001656N00166B]00166C]00166FA001676A00169C200169D20016B8e" +
+        "0016C720016C820016CB&0016DB]0016E6:0016EAA0016EBA001708<00170E200170F2001731#00173B2001759200175A2001783m001788a0017942001795200179A30017A4<" +
+        "0017ABN0017B6(0017C9]0017CBB0017D5]0017DF20017E020017E3m0017E4m0017E5m0017E6m0017E7m0017E8m0017E9m0017EAm0017EBm0017ECm0017F2&0017FAH001813e" +
+        "0018182001819200182Fm001830m001831m001832m001833m001834m00184DM001862^001871<00187320018742001882?00188B50018AF]0018B920018BA20018DEA0018F3#" +
+        "0018FE<0019062001907200191DN00192F2001930200194B\\0019552001956200195B3001963e0019A920019AA20019B950019BB<0019C5e0019CBw0019D1A0019D2A0019E0i" +
+        "0019E2B0019E3&0019E720019E820019FDN001A11;001A1E<001A2F2001A302001A4B<001A4D:001A4F$001A6C2001A6D2001A75e001A80e001A8A]001A8Cf001A92#001AA05" +
+        "001AA12001AA22001AB6m001AE22001AE32001AE9N001B0C2001B0D2001B113001B21A001B2A2001B2B2001B2FM001B532001B542001B59e001B63&001B672001B77A001B78<" +
+        "001B7AN001B8F2001B902001B98]001BBF\\001BC0B001BD42001BD52001BE9/001BEAN001BFC#001C0E2001C0F2001C14p001C235001C42P001C43]001C4A$001C572001C582" +
+        "001C62D001CA4e001CB02001CB12001CB3&001CBEN001CBFA001CC0A001CC4<001CF03001CF62001CF92001D095001D0De001D0Fi001D25]001D28e001D38^001D452001D462" +
+        "001D4F&001D60#001D702001D712001D730001D7D:001DA12001DA22001DB5B001DBAe001DBCN001DD8H001DE0A001DE1A001DE52001DE62001DF6]001E0B<001E10?001E132" +
+        "001E142001E2AM001E35N001E45e001E492001E4A2001E4F5001E52&001E583001E64A001E65A001E67A001E74\\001E75D001E792001E7A2001E7D]001E8C#001EA9N001EBD2" +
+        "001EBE2001EC2&001EC95001EDCe001EE1]001EE2]001EF62001EF72001F12B001F1F6001F262001F272001F29<001F32N001F33M001F3BA001F3CA001F3F$001F5B&001F6BD" +
+        "001F6C2001F6D2001F95\\001F9D2001F9E2001FA7e001FC5N001FC6#001FC92001FCA2001FCC]001FCD]001FD0:001FE3D001FE4e001FF3&002037^00207BA0020ED:00211B2" +
+        "00211C2002127i002147N00214C]00215520021562002159B00215A<00215CA00215DA00216AA00216BA0021705002191300219B500219Ee0021A020021A120021BAm0021BDN" +
+        "0021D1]0021D2]0021D720021D820021E9&0021FBD00220C200220D2002215#002219500223FM002241&002248H00224CN00225520022562002264<002283B00229020022912" +
+        "002298e0022A1?0022A5m0022A6e0022A9D0022AAN0022B030022BD20022BE20022D7N0022FAA0022FBA00230420023052002312&002314A002315A002331N002332&0023332" +
+        "0023342002339]00233A]002345e002348\\002354#00235D200235E200236C&00237D<002399]00239CB0023AB20023AC20023AE50023CCN0023CDi0023D4m0023D6]0023D7]" +
+        "0023DF&0023EA20023EB20023F1e0023F8w00240130024132002414200241D:00241EN002436&002444N00245020024512002454]00246C<002481<002483D00248C#00248De" +
+        "002490]002491]002497200249820024A500024B2M0024B6^0024BAm0024BEe0024C320024C420024D6A0024D7A0024DCB0024E850024E9]0024EFe0024F3N0024F720024F92" +
+        "0024FE$002500&00251BQ002522\"002538]0025452002546200254B&0025645002566]002567]002568?002569\\00258320025842002586i00258BG002590g00259E?0025A0N" +
+        "0025AEH0025B3<0025B420025B520025BC&0025E5D0025E7e002608&00260A200260B2002618#00264A&00265120026522002655<002659N00265A300265D]00265F]002688B" +
+        "002691\\002698200269920026B0&0026B950026BB&0026C6A0026C7A0026CA20026CB20026E2D0026F2M002709N00270C200270D200270EA002710A002719i002722o0027902" +
+        "0027E320028F8A0029C22002A102002A6A2002B70]002BF50002CC82002EC7?002F5C2003019200302420030402003048g003065&00306E<0030712003078200307B20030802" +
+        "0030852003094200309620030A320030B620030C1<0030F22003146B003192i003217200337An0034DAD0034FE?00351A20035FFm0037B7\\0038DF2003A7D2003A982003A992" +
+        "003A9A2003A9B2003A9C2003C102003C84b003DE1?003DE8D003EC4?003EE1&00400B2004020*004026000408C-00409620041D22004238A00425A2004268200451D200464B?" +
+        "004B0D?004B127004E015004E35<004F1A?00500B200500F2005013^005014200502A200503E2005050200505320050542005056p0050732005080200508B<0050A220050A72" +
+        "0050BA30050BD20050CC^0050D120050E220050E4&0050F020050FC600562B200566D?0056CD&0057C1D0057D22005907E00596C20059DC2005A13?005B94&005D732005F67i" +
+        "005F862006009200602F200603E20060472006048500604C\\00605C2006070200608320060B0<006151?006171&00620B/0062EC20064402006619?00664B?0066DC&00682B?" +
+        "0068EB<006B6F?006BF12006CBC2006D52&006F64]0070077007147%007204]007230O00727820072EEA0073E0]007686200778D2007888200789E\\007C2D]007D3B]007D60&" +
+        "007E95200805F<0080A0<0080C8300812A&0081C420081F9m008320?008621%008701]00873120087642008865&008A55?008A76&008A962008E732008EF2M00900C20090212" +
+        "009027A00902B200905F2009069B00906D200906F2009086200909220090A620090AB20090B120090BF20090D920090F1^0090F2200919EA009235&009337A0094EC?0097F1&" +
+        "00991D?009ACD?009AD22009C02<009E1E2009EC8u00A040&00A0C5w00A0C9A00A159D00A289200A2EE200A38E200A3D1200A45F?00A554A00A5BF200A62B700A6CA200A7422" +
+        "00A91D?00AA00A00AA01A00AA02A00AA6E200AA70D00AAFDm00AD24300ADD5?00AF1F200B04A200B064200B08E200B0C2200B0D0500B0E1200B1E3200B362&00B463Z00B5D0]" +
+        "00B670200B771200B8B3200BB1C?00BB3A%00BB60A00BC60200BC99>00BE3B?00BE43500BE44b00BE75200BF61]00BF77200C002_00C04F500C0FF^00C164200C1B1200C2C6A" +
+        "00C30Au00C3F4]00C52CB00C585&00C610&00C84E<00C88B200CAE5200CB51\\00CC05?00CC34B00CCFC200CDFE&00D006200D058200D063200D079200D090200D097200D0B7A" +
+        "00D0BA200D0BB200D0BC200D0C0200D0D3200D0E4200D0FF200D49EA00D6FE200D76DA00D78F200D861F00D8A2?00D9D1e00DA55200DB70&00DBDFA00DEFB200DF1D200E0142" +
+        "00E018#00E01E200E034200E04F200E08F200E091D00E0A3200E0B0200E0F7200E0F9200E0FC?00E0FE200E12F?00E16D200E18CA00E3B2]00E406?00E421e00E5F1000EABD2" +
+        "00EB2De00EBD5200EC0Au00EEAB200F28B200F361%00F39F&00F46F]00F4B9&00F5FD?00F620;00F663200F76F&00F7AD?00F81C?00F82C200F8CC\\00F952?00FA21]00FB4A7" +
+        "00FC8B%00FCBA200FD22200FD45<00FEC8204006E;0401A1804021F?040312>0403D6N040973<040CCE&040D84b040E3C<040F66i04106Bu04137A&041471?041552&04180F]" +
+        "041892?0418D6o041B6DD041BBA]041C6CA041E64&042322m042336w0423A3G0425C5?0425E8m042665&042728H042758?04292E]042AE22042EC1&0430FA2043201/04331F?" +
+        "043389?0433C2A0434CF&043F72G0441A5&04421A#044707m04489A&04495D?044A6C?044BB1?044BED&044F4C?0452F3&045453&0455B8?0456E5A045C6CB045D4Be045FB92" +
+        "04627320463D0?0464FA5046761u046865&04698FB0469F8&046C59A046C9D2046F00D047179<047295&0472EF&04749E?047503?0476B02047970?0479B7m047A0Bu047AAE?" +
+        "047C16F04801A?0483087048727b04885F?048C16?048C9A?049226#0495E6k0499B9&0499BB&049D05&049FCA?04A151M04A316m04A6C8A04A741204A81C?04AE47u04B0E7?" +
+        "04B167u04B1A1]04B247704B429]04B4FE$04B5B2&04B9E3]04BA1C?04BA8D]04BAD6304BC6D&04BD70?04BD88<04BD97204BDBF]04BE58?04BF1B504BF6Dw04BFD5&04C06F?" +
+        "04C1D8?04C5A4204C5CDG04C807u04C845i04C8B0;04CAED?04CB01]04CCBC?04CD15b04CF4BA04CF8Cu04D13Au04D3B0A04D3B5?04D3CF&04D4C4#04D590804D9F5#04DAD22" +
+        "04DB56&04E31A\\04E387204E3E5b04E451m04E4B6]04E536&04E598u04E795?04E8B9A04EA56A04EB40204ECD8A04ED33A04EE03m04EECD>04EF61504F03E?04F0EEA04F13E&" +
+        "04F169?04F352?04F41CI04F778e04F7E4&04F938?04F9F8i04FE31]04FE7F204FE8D?04FF08?080007&080009<08001B5080027q080028m080046e080205?08023C]08028EM" +
+        "0804B4m080581[0805E2B0808C2]080FE52081093]081196A0812A5%08152F]0816E3?0817352081814<0819A6?081AFD?081C6Eu081F71i081FF320820E7G0821EF]0823C6?" +
+        "08240B&082525u082573&082697w08276B?082CB6&082E36?082E5F<082FE9?08318B?0831A4?08357DH0836C9M08373D]083A8D7083AF27083BC1>083D88]083E5D\\0840F3k" +
+        "0845D12084F0A?084FA92084FF92085104?0851F2\\085411>085531I085700i0857FB%085A113085B0E8085BD6A085C1B?085D53&08606E#086202&086266#086361?08638A2" +
+        "086518&086698&086AC5A086AE5%086BD7b086D41&086E9C?087045&087073?087190A087402&087671B087808]08798C?087A4C?087B0F%087B12\\087B872087C39%087C43?" +
+        "0881F4B08849D%0887C7&088BC8;088C2C]088E90A088EDC&089115%0891A3%08920450892727089356?0894EC?089542&0896AD20896D7$0897072089734<089DF4A089E08;" +
+        "089E84?08A189>08A5DF]08A6BC%08A6F7708A842?08AD0A708AED6]08B258B08B339u08B3D6?08B4B1;08B4D2A08B61F708B657$08B95Fb08BD43M08BEAC608BFA0]08BFB8#" +
+        "08C021?08C06C?08C0EBG08C224%08C729&08C7B5&08CC68208CC81>08CCA7208D01EB08D09F208D1F9708D23EA08D40CA08D42B]08D46AD08D593m08D59D\\08D945?08DD82?" +
+        "08DDEBb08E64B&08E689&08E7E5?08E84F?08EB21A08EBF6?08ECA9]08ECF5208EDED408EE8B]08F1EA<08F3FB208F458?08F4AB&08F4F0208F69C&08F8BC&08F9E0708FA28?" +
+        "08FC88]08FD0E]08FD52b08FD58?08FF44&0C02BD]0C0535B0C07DFu0C07F3?0C0ADFm0C0E7630C0ECB?0C116720C12AD<0C1420]0C1539&0C1563&0C1773?0C184E?0C19F8&" +
+        "0C1B7BH0C1C57m0C1DAFu0C238D?0C264320C272420C29EF50C2A6Fb0C2C54?0C2D71&0C2E57?0C2FB0]0C3021&0C31DC?0C323A]0C3526H0C37DC?0C3B50&0C3E9F&0C413EH" +
+        "0C41E9?0C42A1G0C4314b0C43F9%0C45BA?0C47C9%0C4885D0C4B54i0C4BEEm0C4DE9&0C4EA070C4F9B?0C5101&0C517E&0C53B7&0C5415A0C599CB0C61CFm0C6743?0C68032" +
+        "0C6AC4&0C6F8B&0C7043e0C704A?0C715D]0C722Ci0C7274$0C7329_0C74C2&0C75BD20C75D2>0C771A&0C7A15A0C8063i0C8126B0C8268i0C8306?0C839A?0C8408?0C85252" +
+        "0C85E1&0C8610B0C8910]0C8B9570C8BA2?0C8BFDA0C8DCA]0C8FFF?0C9192A0C96BF?0C975F<0C9838u0C9A3CA0C9D92#0CA3B2&0CA8A7]0CAC8A\\0CAE39A0CAE5Fb0CAE7Dm" +
+        "0CAF3120CB2B7m0CB319]0CB527?0CB5B3?0CB6D230CB787?0CB78E?0CB7EC?0CB81570CBC9F&0CBEF1?0CC413;0CC47Ag0CC56C&0CC6CC?0CC6FDu0CC98AA0CCC5D&0CD0F82" +
+        "0CD292A0CD5D320CD6BD?0CD746&0CD99620CDBEA&0CDC7E70CDC91%0CDD24A0CDFA4]0CE0DC]0CE441&0CE4A0?0CE5A1&0CE5B5?0CE623?0CE725H0CEA14o0CEABFG0CEC80m" +
+        "0CEDC8u0CEE99%0CEF15i0CEFF6b0CF346u0CF5A420CFC18?0CFE45e0CFEE5m100020&10003B7100177?1002B5A1005CA210061C7100645\\1006ED21007B6]10082Cm1009F9%" +
+        "100BA9A100C6BM100D7FM100D8C?100E7EB1012FB>101B54?101C0C&101D6E<101DC0]101F74<1020BA7102407?1027F5i102959&1029AB]102AB3u102B41]102BAA\\102E00A" +
+        "102EAFm102F6BH102FCA&103025&103047]10321D?10327E?103917]1039E9B103B54?103B59]103D1CA103F44u1040F3&10417F&104210&104400?104780?10490E?104A7DA" +
+        "104F58<104FA8e105072_105107A105172?1051DB710521C71052BD?1057252105932[105A17n105A95i105DDC?105FADA10604B<1062E5<1062EB310653051067A3?10683FD" +
+        "106F3F01070FDG107100?1071B3w1077B1]107A2AH107B44#107BEFw107C61#107D1A5107DC8&1086F4?1088D3?1089FB]108CCF2108EE0]108FFE?1091A871091D1A109266]" +
+        "109327:1093E9&1094BB&1094EF?109693%1096C621097BD710981951098365109ABAA109ADD&109D7A?109E6B&109F41&10A1DA&10A2D3&10A30F?10A4C9&10A4DA?10A51DA" +
+        "10A829210A85B710A879A10ABC9]10AE60%10B1F8?10B3C6210B3D5210B3D6210B41D710B588&10B676<10B9C4&10BC36?10BD18210BD3A&10BDA3710BEF5310BF48#10BF67%" +
+        "10C172?10C197u10C37B#10C3AB?10C595E10C5FAw10C61F?10C735H10CABFm10CD54?10CE02%10CEA9m10CEE9&10CF0F&10D38A]10D542]10D561n10D7B0\\10D9A2;10DA43M" +
+        "10DA49?10DA63&10DBA2m10DDB1&10E2C9&10E376210E4C2]10E676210E7C6<10E953?10EC81]10F005A10F1F2D10F311210F60AA10F920210F96FD10FC33?10FEEDi10FFE0:" +
+        "140152]1402EC<140338?140498&14080871409DC?140AC5%140B9E]14109F&1413FB?14147D&14169D214187751418C3A1419232141A97&141BA0&141F78]14205E&14223B;" +
+        "14230A?1423F2/1423F3/142876&142B2F7142D41b142D4D&142E5E_143004?1432D1]14335C7143375w1435B7&14360Ew1436C6E143B51?143CC3?143EC2A143FA6e1442FCm" +
+        "144658?144920?1449C5?1449D4u144F8AA145120?145594?14563A?14568E]14579F?1458D0<1459C0M145A05&145EBC?145F94?1460CB&146393714656A?14755BA147590i" +
+        "147649<147740?147830K147AE4&147DDA&147E19<147F0Fm147FCE&1484732148509&14857FA148692i14876A&1488E6&1489CB?1489FD]148C4A?148F79&148FC6&14907Au" +
+        "149138%14946C&1495CE&1496E5]149877&14993Eu1499E2&149A10H149AA3?149CEFm149D09?149D99&149ECF5149F3C]149FE8E14A0F8?14A2A0214A32F?14A364]14A3B4?" +
+        "14A51A?14A6B9]14A78B414AB02?14ABC5A14ABEC<14B31F514B3A1B14B457b14B484]14B653&14B903?14B968?14BB6E]14BBCCm14BC68214BD61&14C14E;14C19F714C213&" +
+        "14C7C4w14C88B&14C913D14CB19<14CB65H14CC20i14CF92i14D00D&14D11F?14D169?14D19E&14D1D4?14D55C&14D64D314D864i14D881u14DAB9?14DAE9#14DDA9#14DE39?" +
+        "14E01D]14E1C9b14E22A214E6E4i14EB08?14EBB6i14F287&14F42A]14F65Au14F6D8A14FB70?14FEB5518002De1801F1u18022D?18037351804EDm180B1B%180BD0?180C7A\\" +
+        "180DF9b180F7631814F4m1816C9]1819D6]181DEAA181E78\\181EB0]182032&182195]18227E]182649A182654]182666]182A57?182A7BN182AD3B182C65m1831BF#1832DE&" +
+        "183386?18339D2183451&183A2D]183CB7?183D5E?183DA2A183EEF&183F47]183F70&184516m184617]1848BE%184A53&184E16]184ECB]1854CF]1855E3&185644?185680A" +
+        "1856C3&185936u1859F52185A585185BB3]185E0FA186024<18622C\\1862E4m186472<186590&1866DA51867B0]1868CB>18690Ab186945i1869D4]1869D8n186A81\\18703B?" +
+        "18742E%187A3B<187A3Eb187EB9&187F88Z188025>188090218810E&188331]188740u18895B]188B0E7188B452188B9D21890D8\\189341A1893D7m189C5D2189E2C?189EFC&" +
+        "18A084&18A6F7i18A905<18A99B518AA0F?18AB1D]18AF61&18AF8F&18B657?18B83D]18B842&18BB1C?18BB41?18BFB3]18C007?18C04D:18C086/18C23C'18C2BF018C58A?" +
+        "18CC18A18CE94]18CF24?18D0E1G18D276?18D3CF<18D6C7i18D6DD?18D98F?18DBF2518DC12b18DE50n18DED7?18E2C2]18E671&18E728218E7B0&18E7F4&18E829o18E91D?" +
+        "18ECE7018EE69&18EF63218EFC0_18F0E4u18F1D8&18F22Ci18F643&18F935218FAB7&18FB7B518FD74I18FE34718FF0FA1C0B8Bo1C0D7D&1C0EAF?1C0EC2&1C12B0%1C1386?" +
+        "1C13FA?1C151F?1C17D321C1AC0&1C1ADFH1C1B0D:1C1BB5A1C1D67?1C1D8621C1DD3&1C1FF1?1C20DB?1C222621C232C]1C2575u1C28AF<1C290471C2AB0u1C3003<1C32AC?" +
+        "1C34DAG1C34F1b1C3576]1C36BB&1C3ADE]1C3BF3i1C3C78&1C3CD4?1C3D2F?1C402451C42C2?1C4363?1C4419i1C4586N1C4593m1C472F?1C4D66%1C4D70A1C53F9;1C57DC&" +
+        "1C599B?1C5A3E]1C5A6BQ1C5CF2&1C5F2B31C61B4i1C61BF&1C627E?1C62B8]1C6349m1C66AA]1C6758?1C692071C6A1Bo1C6A76&1C6A7A21C6F65:1C7055?1C7125&1C721D5" +
+        "1C73E2?1C740Dw1C76F2]1C7754&1C7B21e1C7EE531C7F2C?1C84A621C8682&1C869A]1C872C#1C8B8471C8BEFu1C8E2A&1C8E5C?1C8F5771C90FFn1C9148&1C9180&1C93C4%" +
+        "1C98EC<1C9957A1C99DB?1C9C8CB1C9DC271C9E46&1CA681?1CAA0721CABA7&1CAECB?1CAF05]1CAF4A]1CAFF731CB3C9&1CB46C?1CB72C#1CB796?1CBA8Cm1CBDB931CC089b" +
+        "1CC10CA1CC1DE<1CC21281CC3AB71CCCD6u1CD11A81CD1E021CD21EB1CDBD471CDEA721CDF0F21CDF52m1CE209&1CE2CCm1CE4CB71CE4DDj1CE504?1CE57F]1CE61D]1CE62B&" +
+        "1CE639?1CE6AD?1CE6C721CE85D21CEAACu1CED6F$1CF29A;1CF42B?1CF64C&1CF8D0]1CF9D5&1CFA68i1CFC1721CFC2A?1CFE2B%1CFFAD?20040F5200484&2008ED?200B16m" +
+        "200BC52200BC7?200BCFN200CC8M200E2B&2010B1%2013E0]2014C4?201582&2015DE]201642H2016B9A201742D201A94&201BC9B201C3AN201E1D?201E88A201F3B;2021A5D" +
+        "202351i20256572025CCu202680]20283E?202BC1?202D07]202DF6&20326C]2032C6&203389;203462u2034FBu203543\\203626i20370622037A5&203A072203A43A203B34u" +
+        "203B67]203CAE&203DB2?2043A8720463A&20474752047B5\\2047DAu204C03<204C9E2204D52G204E71B204E7FM20500D720521D&205383?205476e2054FA?205531]2059D1G" +
+        "205E64?205EF7]206274H20658E?20677C<206980&206BE7i206BF4?206E9C]206EF172072A9u20768F&207693E2078CD&2078F0&207918A207D74&2082C0u20845F&2087EC?" +
+        "2088105208C0A&208C86?209148m2091DF&209339B209952u209A7D\\209BA97209BCD&209BDD?209CB4<20A171%20A200?20A2E4&20A5CB&20A60Cu20A680?20A6CD<20A716b" +
+        "20A766?20A8BF?20A99BH20AB37&20AB48?20AEB6?20B82B\\20BBC0220BD1DA20BEB8%20C19BA20C2B0?20C38Fm20C9D0&20CC27220CC73]20CD39m20CF30#20CFAE220D390]" +
+        "20D476%20D5BF]20D5C2720D778m20D80BB20DA22?20DBAB]20DBEA220DCE6i20DCFD?20DF73?20DFB9;20E15Di20E2A8&20E525?20E52AM20E7C8720E874&20ED47B20EE28&" +
+        "20EFBD[20F094;20F120220F17C?20F1B2n20F3A3?20F478u20F4D4&20FA85&20FE00%20FF0C?2400BA?24016F?2401C72240588;240935]240995?240A3F]240AC47240F9B>" +
+        "241145u241153]241551?24161B224166D?24169D2241AE6?241B7A&241EEB&241F3Ab241FA0?2420C7\\2421ABe24240E&2424B7]2426D6?2427E5?2428FD>242934;2429B0?" +
+        "242A042242AEA&242BD6Z242E02?242FD0i2430F8?243154?2432AE>2436DA2243FAA?24418CA2442E37244427?24456B?2446E4?244750\\244845>244885?244AF8b244B03]" +
+        "244B81]244BF1?244BFE#244C07?244CAB7244CE3%244ECD\\24526A42453ED524559A&24587C7245A4Co245A5Fi245AB5]245BA7&245CC5?245D92B245E48&245EBEU245F9F?" +
+        "246078?2460B3]2462AB72462C6?2462CE<246477u24649F?246511$246800]2468B0]246968i2469A5?246A0E<246C60?246C842246D10&246E965246F287246F8C?2471212" +
+        "2471525247189m247625m247645?247703A247755?247D4Dm247E122247F20\\247F3C?24813B22481C7?248A07G2491BB?24920E]24952F;249745?249EAB?249F89m24A074&" +
+        "24A160724A2E1&24A43Co24A452]24A487?24A52C?24A799?24AB81&24B105>24B2DE724B339&24B657224B6FD524BA23224BCF8?24BE05<24C42FQ24C613]24C696]24CE33%" +
+        "24CF24u24D0DF&24D337u24D5E4224D660b24D79C224D7EB724DA33?24DB94B24DBAC?24DBED]24DCC3724DEC6<24DEEB?24DF6A?24E29D?24E314&24E50F;24E5AAQ24E9B32" +
+        "24E9CA?24EA9B?24EB16A24EBED?24EC4A724EE9AA24F094&24F0D3]24F27F<24F40A]24F5AA]24F603?24F677&24FB65?24FBE3<24FC4EB24FCE5]2800AF52801CDG28022E&" +
+        "280244&2802D8]2805A57280708]280B5C&280C50A280DFCe28107B32811A8A2811EC?28167Fu2816A8H2816ADA281709?281878H281DFB?28221E?2824C9%2827BF]28285Dw" +
+        "282B96?282CB2i282CC4?282D7F&283152?2831F8?283334?2834A222834FF&28353A?2836F0?28372F7283737&28395E]283B823283C90m283CE4?283DC2]283F69e2840DDe" +
+        "2841C6?2841EC?2845AC?2848E7?2849E9&284B54&284E44?285261228534E?285471?28562F728575D&2857BE>285923u285AEB&285FDB?2864B0?286847b2868D2?286AB8&" +
+        "286ABA&286B35A286B5C2286C07u286ED4?286F7F228704Eo2872C6]2873F6%287681b2877F1&287AB4?287FCFA288023<288088M28808A?288335]2883C9&28848572887BAi" +
+        "288A1CB288EEC&288FF6&289104i289200A28924A<2893FE2289401M28940F2289529A2896B0?28987B]289C6E7289E1Em289E97?289EFC\\289F04]28A02B&28A06BA28A24BB" +
+        "28A44AA28A6DB?28A9AE?28AC9E228AF42]28AFFD228B2BDA28B448?28B591228B5E8m28B829B28BAB5]28BD89;28C039?28C0DAB28C1A0&28C538&28C5C8<28C5D2A28C63FA" +
+        "28C68EM28C709&28C7CE228CC01]28CDC1W28CF51N28CFDA&28CFE9&28D0EAA28D127u28D3EA?28D5B1&28D6EC?28DBA7b28DCC3?28DE1C]28DE65<28DEE5?28DFEBA28E02C&" +
+        "28E14C&28E31Fu28E34E?28E5B0?28E6A9]28E7CF&28EA0BH28EA2D&28EC95&28EC9Am28ED6A&28EE52i28EF01%28F033&28F076&28F10E528FBAE?28FF3C&2C01B522C0786?" +
+        "2C0823_2C08B4?2C0B97u2C0BAB?2C0BE922C0D27?2C0DA7A2C0DCFu2C10C1N2C1165b2C15BF]2C15D9?2C1809&2C195Cu2C1A01?2C1A0522C1CF7&2C1F23&2C200B&2C2080?" +
+        "2C2131B2C2172B2C233A<2C2768?2C27D7<2C2997H2C2B86\\2C301Aj2C3033M2C312422C326A&2C331122C3358A2C3361&2C36F2?2C36F822C3996\\2C3A91?2C3AB1?2C3AE87" +
+        "2C3AFD$2C3ECF22C3F3822C4053]2C4138<2C4401]2C44FD<2C4C15B2C4D54#2C4F5222C52AF?2C542D22C5491H2C54CFD2C55D3?2C56DC#2C574122C57CE&2C58B9<2C58E8?" +
+        "2C598AD2C59E5<2C5A0F22C5EABG2C61F6&2C63A1?2C658D22C693E?2C6B7Dm2C6BF5B2C6DC1A2C6E85A2C71FF%2C73A022C7600&2C768A<2C780E?2C79BEi2C79D7\\2C7BA0A" +
+        "2C7CF2&2C81BF&2C8217&2C86D222C8DB1A2C91AB$2C93FB_2C9452?2C9520&2C97B1?2C97EDe2C9975]2C9D1E?2C9D90G2C9E00e2CA042?2CA2E522CA59C>2CA774m2CA797?" +
+        "2CA79E?2CAA8Es2CAB00?2CAB33m2CABEB22CAE2B]2CB05DM2CB1B7G2CB43A&2CB471n2CB68F?2CB7A1?2CBABA]2CBC87&2CBCBB72CBE08&2CC253&2CC546?2CC81BI2CC8F5?" +
+        "2CCA16&2CCC44e2CCF58?2CCF67W2CD02D22CD066u2CD3ADm2CDA46]2CDB07A2CDF68&2CE2D9?2CE38E22CE412\\2CE5BDo2CEA7F52CEAFCA2CECA6?2CED89?2CEDB0?2CF05DF" +
+        "2CF0A2&2CF0EE&2CF295?2CF2A5\\2CF43272CF81422CF89B22CFB0F\\2CFDA1#2CFE4Fu3001AF2300505A300916&300E43&300EB8D300EE3(3010E4&30138B<301577w3017C8e" +
+        "301966]301984?301C22<302432A302478\\3024A9<30294B?3030D0m3030F973035AD&3035C5?3037A623037B3?303926e303A64A303B7C&303EA7A303FBB<304511m304596?" +
+        "30469AM30487Dn30499E?304D1F%304E1B?3050CEu30560F:305714&305A3A#3061A2?306222]30636B&3063EAB3066D0?3067A1\\306893i306A4F[306A85]307467]307496?" +
+        "307512e30766FD3076F57307A05?307AD2&307C4A?307C5EB308216&30839873085A9#308730?30894AA3089A6?3089ECN308AF7?308BB22308D99<308DD4?308ECF?309048&" +
+        "3090AB&3093BC\\309610?30963B?3096FB]309C23F309E62?30A033&30A1FA?30A2C2?30A30F?30A7F5&30A8DBe30A998?30AAE4?30AEA4730AF7Em30B49Ei30B4B8D30B5C2i" +
+        "30B64FB30BD13w30C0AE&30C50F?30C599#30C6F7730C7AE]30C922730C9CC]30CBF8]30CDA7]30D042530D17E?30D4E2?30D53E&30D587]30D6C9]30D7A1&30D875&30D9D9&" +
+        "30DE4Bi30DE52]30E044;30E04F&30E171<30E226&30E283m30E37AA30E396?30E3A4A30E4D8?30E4DB230E98E?30EB15?30EDA0730F335?30F600\\30F65D<30F6EFA30F70D2" +
+        "30F7C5&30F9EDe30FB10b30FBB8?30FC68i30FCEBD30FD38;30FD65?30FE6C&30FEFA230FFFD?3400A3?340286A34029C334033D<3403DEm34080433408BC&3408E1m340962>" +
+        "340A333340A98?340E22&34105Dm3410BE&3410D0?3410F4b341298&3412F9?3413E8A34145F]3414B5m341513m34159E&3417DD_3417EB5341B2D2341CF0u341E6B?3425B4b" +
+        "3425BE%342601?342840&342865B342912?342AF1m342B6E&342D0D]342EB6?342EB7A342FBDN343111]34318F&3431C4$343638?34363B&343916;343A20<343DA9?343DC40" +
+        "343EA4Z34415DA344262&3446EC?3448ED534495B\\344DF7D344EE2?345184?3451C9&3453D2\\3454EF2345840?34588A2345A60F345D9E\\345DA82345E08[345F4573460F9i" +
+        "34628823464A9<346679?346691&3468B5m346AC2?346B46\\346BD3?346E68?346F9023470692347146?34732D234735A5347916?347C25&347DF6A347E00?347E5Cd3480B3u" +
+        "3481C4$348296&3482C5]3483D5?3484E4m348518734865D73488182348A12<348A3B?348A7B]348AAE\\348C5E&348D13b34936FB3494547349671?349672i3497F6#34987A7" +
+        "3498B5M34A137?34A2A2?34A395&34A84E234A8A0?34A8EB&34AA8B]34AB37&34AB95734AF2CN34AFB3%34B1EB&34B1F7m34B20A?34B354?34B472734B7DA734B883234B98Du" +
+        "34BDC8234BE00]34C059&34C232]34C386&34C3AC]34C3FD234C459m34C515<34C7E9;34C93DA34CDB0734CDBE?34CE00u34CFF6A34D270%34D693?34D868/34DAA1&34DB9C\\" +
+        "34DBFD234DDCC;34DE1AA34E12DA34E1A9$34E2FD&34E3FB]34E6ADA34E6D7534E894i34EAE7734ED1B234EE16&34EFD7?34F015u34F043]34F084]34F39AA34F5D7?34F64BA" +
+        "34F68D&34F716i34F8DD&34F8E7234FA1Cu34FCB9<34FCEFD34FD6A&34FD70A34FE77&34FFF3?380025A380195]3802DE_380484&3809FB&380A94]380B3Cm380B40]380E4D2" +
+        "380F4A&380FAD?3810D5$3810F0<38142853816B3<3816D1]3817B1\\3817C3<38182B738184Ce381868A381C1A2381F8Dn382028?38205623821C7<3822E2<3822F4?3825F3G" +
+        "382C4A#382CE5n382DD1]382DE8]3830F9D38327AI3833C5H3835FB\\38378B?38396C?38398Fb383E517383FE8?38420Bd3844BE73847BC?38484C&384A80]384C4F?384DD2?" +
+        "384E56m384F49B385247?38539C&38563DH385B44b385CFBb386233&3863BB<3865B2&3866F0&386893A3868A4]386A77]386DEDB386EB2?386FF4%387035N3870F2?3871DE&" +
+        "38736E?387862e387A0EA387F8B&3881D7m388345i3886F7;3887D5A38881E?3888A4&38892C&388A06]388B59;388C50D388CEF]388F30]389052?3890A523891B72389496]" +
+        "3894EDM3898E9?389AF6]389CB2&389E4C<38A44B?38A4EDu38A5C9n38A659\\38AA09238AB41m38AF29438B3F7?38B54D&38BAB0/38BAF8A38BC01?38BD7A<38C0EA838C1EDu" +
+        "38C22DG38C43A&38C6BDu38C6CEN38C986&38CA84<38CADA&38D09C?38D269m38D40B]38D547#38DEADA38E13D&38E1F4\\38E2C4m38E60Au38EAA7<38EADDB38EB47?38EC0D&" +
+        "38ECE4]38ED18238F18Fj38F195?38F20DB38F73D%38F7F1?38F889?38F9D3&38FB14?38FC34?38FC98A38FDF8238FF5953C01EFe3C0518]3C058E?3C0630&3C06A7i3C0754&" +
+        "3C0771e3C07D7&3C08CDB3C08F623C0A7A]3C0B59n3C0D0D73C0E2323C0F0273C135Au3C13BB?3C13CC23C15C2&3C15FB?3C1710\\3C195E]3C1BF8>3C1E0433C1EB5&3C20F6]" +
+        "3C219CA3C22FB&3C240A?3C25F853C26E423C286D;3C2983]3C2C3053C2CA6u3C2CCD&3C2DB7m3C2EF5b3C2EF9&3C2EFF&3C306F?3C3174;3C318A]3C333233C3464&3C366A?" +
+        "3C3712$3C3786M3C381F?3C3824u3C38F4e3C39C8&3C3B77&3C410E23C46D8i3C4711?3C4A92<3C4AC9?3C4DBE&3C5002&3C510E23C5282<3C52A1i3C5447?3C573123C576C]" +
+        "3C5836\\3C585D\\3C58C2A3C59C0?3C5A37]3C5AB4;3C5CC4%3C5EC323C6104B3C610573C6200]3C62F0_3C64CFi3C65D1?3C678C?3C6A48i3C6AA7A3C6AD2i3C6D66K3C6D89&" +
+        "3C71BF73C7787?3C7843?3C7895i3C7C3F#3C7D0A&3C7DB1m3C7F6Eu3C81D8\\3C8375H3C842773C846Ai3C869A?3C8A1F73C8AB0B3C8B6EG3C8B7F23C8BFE]3C8C93B3C8D20;" +
+        "3C90E0?3C93F4?3C94D5B3C9872_3C9BC6?3C9C0FA3C9D56?3CA10D]3CA161?3CA308m3CA37E?3CA62F$3CA6F6&3CA82A<3CA916?3CA9ABN3CA9F4A3CAB8E&3CAFB7u3CB233?" +
+        "3CB922?3CBBFD]3CBD3Eu3CBF60&3CBFD7&3CC03E?3CC5C7?3CCD36&3CCD40&3CCD57u3CCD5D?3CCE7323CD0F8&3CD92B<3CDC7573CDCBC]3CDD57&3CDF1E23CDFBD?3CE002m" +
+        "3CE064m3CE072&3CE36B43CE441%3CE4B0m3CE824?3CE86E<3CE90E73CE9F7A3CECEFg3CEF8C43CF011A3CF692?3CF75Dw3CF7A4]3CF808?3CF862A3CFA06H3CFA43?3CFA80?" +
+        "3CFB02&3CFDFEA3CFEAC23CFFD8?40017A2400634?4006A0m4006D52400877u400EB9?4011C3]401277H40148224014AD?40163B]40167E#40169Fi401C83A401CD4?4022D87" +
+        "4024D2?4025C2A402619&402641?402A8F7402BA1e402BD67402E71m403004&403059b40313Cu40331A&4035E6]4036B7B403802b403B7B?403CFC&403F8Ci4040A7e40410D?" +
+        "40424424044CE?4044F7N4045C4?404A03w404CCA7404D7F&404D8E?404F42?40538CD4055392405B7F/405CFD5405D82M405EF6]405FC2m4065A3\\406768?4067922406C8F&" +
+        "406F27?4070F5&407183B4074E0A4076A9?407911&407912m407D0F?407F5FB40831D&4086CB34089C2&4089C6%408D5C:408E2CH408EDF?408F9DB409151740921A&409595i" +
+        "40984Em4098AD&409BCD3409C28&409EA4B40A2DB%40A3CCA40A44A;40A654?40A677B40A6B7A40A6D9&40A6E8240A746/40A8F0<40A9CF%40ACBF>40AE30i40B034<40B076#" +
+        "40B0FAD40B15C?40B395&40B3FA&40B4CD%40B4F0B40B570>40B5C1240B6E7?40B70E?40B837e40B93C<40BA09540BC60&40BD32m40C3BC?40C711&40C729\\40C73CA40CBA8?" +
+        "40CBC0&40CE24240D133A40D160&40D28AN40D32D&40D3AE]40DA5C&40DCA5?40DE24]40DEADB40E3D6<40E64B&40EB21?40EC99A40ECBDA40ED00i40EDCF&40EE6D?40EEDD?" +
+        "40F078240F201\\40F3B0m40F407N40F49F240F4EC240F520740F6BC%40F946&440010&440049%44004D?44032CA4403A7244053F\\4405B8?44070B;4409C6?4409DA&440C4B?" +
+        "441030;441244<441524\\441622H4416FA]44179374418FD&4419B6>441A5C2441B88&441BF67441D647441EA1<44227C?44237Cu4425F4&44272E?442A60&442B03244303F?" +
+        "443192<44321D?443583&4438E8A443D54%443E8Am444201%4447CC>4448C1<444988A4449C0K444A37u444ADB&444C0C&444E1A]444E6D$44552B]4455B1?4455C4?44579Fu" +
+        "4459E3?445BED<445CE9]445E82w4463B6&4463C2w44643C244650D%446690i446747?446A2E?446B1Fm446D6C]446D7F%446EE5?447147u44746Ce447654?447831?44783E]" +
+        "447B307447B45%4482E5?448346m448500A44881624488BEm448A5BF448DD52448F17]4490BB&4494FCM449BC1?449E8B&449F46?449FDAb44A038?44A10E&44A191?44A3BBA" +
+        "44A56EM44A642>44A7F4&44A842544A8FC&44AA50B44ADB1\\44ADD9244AE25244AE44?44AF28A44B176744B32Di44B3C5?44B4A0G44B4B2%44B6BE244BB3B;44BD8D744BDC8u" +
+        "44BE0B?44C15Cm44C20C244C346?44C3B6?44C532?44C63C]44C65D&44C7FC?44CBADu44D3CA244D453\\44D454\\44D4E0e44D5CC%44D791?44D884&44D9E7o44DA30&44DBBE?" +
+        "44DF65u44E213u44E2F8b44E4D9244E517A44E59B?44E66E&44E853&44E968?44E9DD\\44EA30]44EAD8m44ECCEB44EE14m44F09E&44F21B&44F459]44F477B44F770u44FB42&" +
+        "480020<480031?4800B32480234?4805E2?480A28&480EECi480FCF<481258?48128F?48137E]481389G481BA42482254i4825F3?48262C&4827C5?4827E274827EA]482952\\" +
+        "482CA0u482CD0?482E722482F6B<482FD7?483106K483177N4831B774831DB?48352B&483584?4835AB&483871?483A028483B38&483C0C?483F72]483FDA7483FE9?4840D5A" +
+        "48435A?48437C&4843DD%4844F7]484520A4846FB?48474B?484982?484996?4849C7]484AE9<484BAA&484C29?484C86?484D7E5485073H485169]4851B7A4851C5A4855197" +
+        "485702?4857D2/485929D485A0DB485B39#485D35$485F08i485F2D%48605FD4860BC&4861EE]486264)486276?486345?48684AA48701Em48706F?487310B487410248746E&" +
+        "48785B>48785E%48794D]487B2FH487B6B?487D2Ei48800224882DF?4883C7\\48849Dm4886E8H488759u4889E7A488B0A2488C63?488EEF?488F5AI48902FD4890F054891D52" +
+        "489A58]489D317489DD1]489EBD<489ECB<48A170248A195&48A3BDm48A472A48A516?48A5E7N48A6B8d48A91C&48A98AI48AABB\\48AD08?48AD9AA48AFF3748B02DK48B25D?" +
+        "48B423%48B4C3<48B8A3&48BA4E<48BCE1]48BD4A?48BF6B&48C381i48C796]48CA43748CA68&48CDD3?48CFA9?48D24F\\48D539?48D6D5;48D705&48DB50?48DC2D?48DF37<" +
+        "48E150A48E15C&48E1CA&48E27E\\48E729748E9F1&48EA62<48EDE6w48EE0C348EF1C]48EF61?48F17FA48F1EBN48F6EE748F7BC?48F8DB?48FC07?48FD8E?48FDA3u4C00822" +
+        "4C01F724C0220u4C034FA4C0F3EA4C10D5i4C11AE74C11BF44C16FCB4C1744%4C17EB\\4C195D\\4C1D96A4C1F86>4C1FCC?4C20B8&4C21D0e4C2498m4C2B3B?4C2E5E]4C2EB4&" +
+        "4C2FD7?4C306AN4C3275&4C3488A4C3946]4C3BDFH4C3C16]4C3CE2?4C3FD3m4C421E24C445BA4C4553b4C496CA4C49E3u4C4AB4B4C4E3524C5077?4C53FD%4C5499?4C55B2u" +
+        "4C569D&4C5739]4C57CA&4C5BB3b4C5D3C24C5D6A&4C5E0CI4C5F70A4C60AD%4C60DEM4C617E?4C62DF>4C631B?4C6371u4C63AD?4C66A6]4C6BE8&4C6D58B4C710C24C710D2" +
+        "4C734FB4C74BF&4C752574C762554C776D24C77CBA4C796EA4C7975&4C79BAA4C7A8824C7C5F&4C7CD9&4C8093A4C80FB;4C820C&4C842174C858A04C889E?4C8BEF?4C8D53?" +
+        "4C8D79&4C8E19u4C9614B4C97A1b4C97CC&4C9EFFw4C9FF1&4CA56D]4CA64D24CA919n4CA954A4CAB4F&4CAD35&4CAE13?4CAEA3<4CB04AA4CB087?4CB16C?4CB199&4CB910&" +
+        "4CBB47K4CBC4824CBCA5]4CBD8F>4CC38274CC53Ew4CC5D954CC64Cu4CC95E]4CCA95?4CCBEA?4CCC6AF4CCDB6&4CCF7C<4CD012&4CD0CB?4CD0DD?4CD0F924CD1A1?4CD546<" +
+        "4CD587<4CD629?4CD71754CD98F54CDA38m4CDD31]4CDE48?4CE0DBu4CE17524CE17624CE20Fu4CE650&4CE65E?4CE67604CE6C0&4CEB42A4CEBB0]4CEBD674CEC0F24CECEE]" +
+        "4CEDFB#4CEFC0%4CF202u4CF475?4CF55B?4CF5DC>4CF95D?4CFB45?4CFBFE_5000E025000E6G50016B?5001BB]5001D9?50029175004B8?50060425006AB25006F5[5007C3%" +
+        "500B23?500B26?500F802500FF5k50125Ce5014C1?5017FF2501CB02501CBF2501D93?501FC6&5021EC?50236DN5023A2&50284AA502873?502B73k502DA2A502F9BA502FA82" +
+        "503237&50325Fb503275]50338Bm503B70&503CC4E503DA1]503DC6u503DD1i503DE52503EAAi503F50?504172?50464A?50465D#50492125049B0]504A6EM504B9E?504F3Bu" +
+        "5050A4]5051A9m505527D505663m5056BF]50578A&5057A8250586F?505C882505DAC?5061BF2506382?506391?50642Bu506583m5065F3<5066E5?5067AE25067F0w50680A?" +
+        "5068AC?506A03M506B4BG506F0C\\506F77?5071642507224m5076AFA507705]50787D75078B0?507A55&507AC5&507C6FA508114&508140<5082D5&508492A508569]5087892" +
+        "508811u5089D1?508A06n508A7F?508BB9n508CB1m508D62?508D9E?508E49u508F4Cu5091E3i50926Au5092B9]5093CE?509546?509839u509893m50995A%509A4C5509A88?" +
+        "509EA7]509F27?50A009u50A1F3?50A4C8]50A67F&50A6D8&50A72B?50ACB9?50B03Be50B127&50B7C3]50BA84250BC96&50BD5Fi50C4DD050C58DB50C709B50C7BFi50C8E5]" +
+        "50D2F5u50D45C%50D4F7i50DAD6u50DCE7%50DDAB]50DE06&50E039w50E085A50E467Z50E4E0<50E538>50E549:50E636$50EAD6&50EB71A50EBF6#50EC50u50ED3C&50EEB5m" +
+        "50F0D3]50F14Am50F265&50F351&50F4EB&50F520]50F5DA%50F722250F7ED?50F958?50FA84i50FC9F]50FE39u525400T540295?5404A6#540764?54077DM540910&540DF9?" +
+        "540F57b54102E?54104F]5412CB?541310?5414F3A5416A57541E56B54211D?54219D]542259?542369?5425EA?542618?54263De542696&542906&542A1Bd542A43&542B1C%" +
+        "542B8D&542F2B?54320475432C7&5433CB&5434EF?543631A5439DF?543AD6]543CED\\5440AD]544249e5443B2754443B?5444A3]544538m5447CC\\54481055448E6u544A002" +
+        "544A16m544B8CB544C8AH544E90&544EF0[54511B?5451DE2545284?5453EDe5455D5?545618?545925?545AA67546009;54606D?5462E2&5464D9\\546749;546990?546C0Em" +
+        "546CEBA5471DD?54724F&54735A?547595i5475D0254778A<54781A2547C692547DCDm547FEE2548028<54833Aw5486BC25488DE2548998?548ABA2548C81>548D5AA549209?" +
+        "5492BE]549963&549B12]549B24G549DEA7549F13&549F355549FC6254A050#54A274254A51B?54A637?54A6DB?54A703i54AE27&54AF97i54B121?54B27E\\54B802]54B80A3" +
+        "54B8DB&54BAD6?54BD79]54BF55B54BF64554C415>54C480?54C80Fi54CF8D?54D17D]54D299i54D7E3<54D9C6?54DCE9b54DD21?54DD4F]54E019Z54E032B54E15B?54E43A&" +
+        "54E4EDA54E61B&54E6FCi54E6FDe54EAA8&54EBE9&54ECB0_54EF43?54EF44'54F0B1<54F201]54F283b54F294?54F607?54F6E2?54FA3E]54FB66\"54FCF0]54FEEBm5800BBB" +
+        "5803FB>58044Fi580987%580A202580AD4&580E85\\580EE6G580FA5&581122#58170Ce581862e581CF8A581DD8\\581F28?581FAA&582059u582071]5820B1<582429;582575?" +
+        "58263Ab58278C0582A93&582ABD7582AF7?582B0Am582BD37582F40N582FF7\\58355D?5835D92583653&583BC2b583F54D58404E&584120i584498u584822e5850ED>5851A3&" +
+        "585595&5855CA&58569F25856AA&5856C2?58605F?5864C4&58666D&58687A\\586B14&586C25A586D0C<586D67A5873D1?5873D8&587961H5879E0]587A62m587F57&587F66?" +
+        "588336?588670B58879F?588A5A5588B1C2588BF3w588C817588CCFb588D092588E81b589043\\5891CFA589351?5893D8m5893E8&58946BA5894AE?58957E?58960AD58961DA" +
+        "58971E25897BD2589A3E%58A023A58A15Fm58A2B5D58A2E1G58A639]58A839A58A8E8%58ABFB?58AC78258AD12&58AE2B?58AEA8?58B035&58B03EN58B10F]58B18F?58B623u" +
+        "58B965&58BAD4?58BC27258BDA3N58BE72?58BF25758BFEA258C38B]58C5CB]58CB52;58CE2AA58CF79758D061?58D15Am58D349&58D56E358D61Fo58D759?58D812i58D9D5k" +
+        "58DF59258E28F&58E434B58E488%58E6BA&58E6C5758EA1Fu58F2FC?58F39C258F8D7?58F987?58FB3E?58FB84A58FDB1D5C013B75C0214u5C0272b5C0339?5C07A6?5C0947&" +
+        "5C0979?5C0B3B?5C0CE6N5C10C5]5C13AC&5C13CC&5C167D?5C1720?5C1BF4&5C1DD9&5C23C2&5C2573G5C260A55C2E59]5C313Em5C319225C337B;5C345B>5C3977B5C3AA2b" +
+        "5C3C27]5C3E0625C3E1B&5C4071u5C4527B5C475EZ5C4879?5C4979$5C497D]5C4CA9?5C501525C50D9&5C5136]5C514FA5C5181]5C521EN5C5230&5C5284&5C546D?5C5948&" +
+        "5C5AC725C5E0A]5C5EABB5C5EBB?5C5F67A5C60BA<5C6117?5C628Bi5C63B085C63BFi5C647A?5C648Ew5C64F125C6783A5C6A80w5C6B32m5C6F69/5C7017&5C7075?5C70A3D" +
+        "5C710D25C78F8?5C7D5E?5C80B6A5C838F25C843Ce5C8505?5C865C]5C8730&5C879CA5C899Ai5C89BC&5C8A38<5C8B6B%5C8D4E&5C9157?5C9175&5C95AE&5C9666e5C969D&" +
+        "5C97F3&5C9960]5C9977&5C9AA1?5C9BA6&5CA2A2m5CA47D<5CA48A25CA62D25CA64Fi5CA6E6i5CA86A?5CAAFDd5CAC3D]5CADBA&5CADCF&5CAF06D5CB00A?5CB12E25CB13E\\" +
+        "5CB26DA5CB395?5CB43E?5CB47EA5CB524e5CB8B7&5CB901<5CBA2C<5CBA37H5CBD9A?5CC0A0?5CC1D7]5CC1F2?5CC307?5CC5D4A5CC787?5CC7C1b5CCB99]5CCD5BA5CCF7F7" +
+        "5CD06Eu5CD2E4A5CD33D]5CD89E?5CD99835CDC49]5CE0C5A5CE17625CE28Cw5CE42AA5CE50Cu5CE747?5CE883?5CE8EB]5CE91E&5CE931i5CED8C<5CEDF4]5CF4ABw5CF51A4" +
+        "5CF5DA&5CF6DC]5CF7E6&5CF821m5CF938&5CF96A?5CF9DD55CFA25\\5CFC66260019476001B1?600308&6006E3&600810?600F6B&60109E?60123C?60156Fi60183A?6018955" +
+        "601AC7N601F56?602232o6025ED<602602m6026AA26026EF<60292Bi602E20?602ED5&6030B3?6030D4&603197w6032B1i60334B&6036DDA603A7Ci603AAF]603CEED603D29?" +
+        "603E5F&60452EA6045CB#6045CD\\6046D4m604DE1?604F5B?605355?605375?6055F976056B1?605718A6057C8&605B305605E4F?605E65G605FAA?60629AB60634C3606405m" +
+        "606525&606720A60684E]606944&606BBD]606BFFN606C66A606EE8u60706C;6070C0&60735C260756CD607771m6077E2]6079C9?607EC9&607ECD?607FCB]608110&608246&" +
+        "608306?608334?608373&6083DAb6083E7i6084BD0608B0E&608C4A&608E08]608F5C]609217&6092C8[609316&609578]6095BD&6096A4?609866m609AC1&609BB4?60A10A]" +
+        "60A2C6?60A37D&60A3E3i60A423b60A44C#60A4B7i60A4D0]60A5E2A60A6C5?60A751?60A954260AAEF?60AB67u60AF6D]60B0E8?60B4A2]60B58D$60B647b60B6E1m60B763b" +
+        "60B76E;60B9C0260BBEB?60BD83?60BEC4&60C547&60C5AD]60C78DB60CA3A&60CE41?60CE86_60CF84#60D039&60D0A9]60D178260D1D8b60D755?60D9A0E60D9C7&60DD70&" +
+        "60DD8EA60DE18&60DE44?60DE94?60DEF3?60E327i60E32BA60E3ACD60E701?60E85Bm60EFABb60F18A?60F262A60F445&60F549&60F620d60F677A60F723u60F81D&60FA9D?" +
+        "60FACD&60FB42&60FDA6&60FEC5&60FF12]64006A56400F1264028Fb64037F]64078C?6407F6]6408642640980u640BD7&640C91&640D22D64122526413AB?64168D26416F0?" +
+        "6417CD]6418DF\\641B2F]641C10m641CAE]641CB0]64200C&642315?642753?64294336429FF?642CAC?642E41?642F1C?643135&643136G643150<6432A8A6433AAG6433DBm" +
+        "643AEA2643E0A?643E8C?6441E6&6442C2G644842&64497DA644A7DA644C36A644ED7<645106<6451F4?6453E0?645601i6456B5]6457BAA645A36&645AED&645D86A645DF4]" +
+        "645E10?646140?646306u64644Au64649BB646624\\6466B3i6466D8]6467CD?64694Em646CB2]646D2F&646D4E?646D6C?646E97i646EE0A647002i647033&647060m6476BA&" +
+        "647791]647924?647999\\6479F0A647B1E\\647BCE]647BD4m648099A64876C?648788B648914764899AD6489F1]648CBBm648F3E26490C1u64956CD649A63Z649ABE&649B8Fm" +
+        "649C8Em649D38;649E31u649EF1m649EF3264A0E7264A198?64A200u64A28A?64A3CB&64A5C3&64A651?64A963?64AC2BB64ACE0]64AE0C264B0A6&64B0E8?64B310]64B473u" +
+        "64B5C6N64B5F2]64B708764B853]64B9E8&64BC0CD64BC43?64BC58A64BD6D&64BF6B?64C045&64C2DED64C394?64C3D6B64C753&64C905&64C9F1%64CC2Eu64CDC2%64CFD9m" +
+        "64D0D6]64D154I64D2C4&64D4DAA64D562?64D69AA64D7C0?64D814264D989264DB8B>64DD68w64DDE9u64DE6DA64E4A5D64E682&64E7D8]64E833764E881<64E950264F69D2" +
+        "64F705?64F81C?64FA2B\\64FD29464FD96\\680125%6804892680571]6805CAA680715A680927&6809477680AE2b681324?6813F3%681590\\681729A681A47&681BEF?681C522" +
+        "68228EB6822E5?6823B0m6825DD7682737]68286Ce6828CF<682C7B2682E3Co682F67&683036&683045?683421A6837E9%683B09&683B782683E26A683EC0&684406m684465&" +
+        "684571?6845CC&684749m6847C5A684898]684983?684A5F&684AAE?684AE9]684C25?684DB6u684F645685134<68545AA6854FD%68572Dn685ACF]685B35&685D43A685E1Cm" +
+        "685EDD&686372?68644B&68672576867C7\\686CE6H686DBC>68709Eb6871612687251o6872C3]68764Fe687724i6879092687A64A687BDCB687D6B]687DAC]687DB42687FF0i" +
+        "6881E0?6883CB&6886A726887C626889C1?688F84?68951B?68962E?68967B&6899CD2689A87%689B43?689C70&689CE22689DD27689E0B2689E19m689E6A?689FD4%68A03E?" +
+        "68A0F6?68A46A?68A593&68A729&68A828?68A86D&68AB1E&68ABA9\\68ABBCu68AE20&68B599<68B5E3?68B691%68B6B3768B8BBu68BC0C268BDAB268BFC4]68C44Cu68C63A7" +
+        "68C6ACA68C90Bm68CAC4&68CAE4268CC6E?68CCAE868D79Ao68D927?68D93C&68D972268DBCA&68DBF5%68DDB7i68DFDDu68DFE4]68E1DC068E209?68E580&68E59E268E74Am" +
+        "68E7C2]68EBAE]68EC8A@68ECC5A68ED57B68EE8F768EF43&68EFBD268EFDC&68F38EB68F543?68F63B%68F7D8H68F90FA68FB7E&68FCCA]68FE71768FEF7&68FF7Bi6C006B]" +
+        "6C028C?6C02E0<6C030926C03B526C047A?6C0699u6C06D6?6C0B5E<6C0C9A%6C0DC4u6C0E0De6C1270&6C13D526C146E?6C1544H6C1632?6C198F36C19C0&6C1A75?6C1AEAm" +
+        "6C1C7146C1D2C?6C1F8A&6C205626C23B9e6C2636?6C2995A6C29D226C2B5956C2E85\\6C2F2C]6C2F80A6C2F8A]6C302Am6C310E26C3491?6C3AFF&6C3B6BI6C3BE5<6C3C8C5" +
+        "6C3DD876C3E6D&6C4008&6C410E26C416A26C41DE?6C442A?6C483Fu6C4A85&6C4CBCi6C4CE2A6C4D73&6C4EF626C4F89w6C4FA126C504D26C51BF?6C51E4?6C5563]6C558D?" +
+        "6C55B1%6C5697%6C5AB0i6C5CB1b6C5D3AH6C5E3B26C5F1CE6C60D0?6C626DF6C62FEB6C63F8o6C67EF?6C688A%6C6A77A6C6C0F?6C6CD326C709F&6C70CB]6C710D26C71D2?" +
+        "6C722036C72E7&6C7637?6C77F0?6C78C1B6C79B8m6C7E67&6C7F49?6C8243?6C8336]6C8375/6C8814A6C8BD326C8D7726C8DC1&6C92CF/6C9313G6C9466A6C94F8&6C96CF&" +
+        "6C9961\\6C998926C999D%6C9CED26CA042b6CA100A6CAB0526CAB31&6CACC2]6CB0CEM6CB133&6CB158i6CB227e6CB2AE26CB2FDm6CB45676CB4FD?6CB749?6CB7E2?6CB7F4]" +
+        "6CBAB8\\6CC217<6CC26B&6CC374m6CC49F<6CC84076CCDD6M6CD032D6CD1E5?6CD63F?6CD68AD6CD6E326CD704?6CDD3026CDDBC]6CE21076CE4A4b6CE5C9&6CE85C&6CE873i" +
+        "6CE874?6CEBB6?6CECEBm6CF049:6CF373]6CF37F<6CF6DAA6CF784u6CFA8926CFD22b6CFE54A6CFFCE\\7001B5270039F770041D7700514D700810A700971]700B01\\700B4F2" +
+        "700F6A270105C270106F<701124&701384&7014A6&7015FBA7018A7270192F?701AB8A701CE7A701F3C]701F53270217Fu7022FE&70287D;70288B]702AD5]702C09N702F35?" +
+        "70317F&703217A7035092703A0E<703A51u703ACB;703C69&703EAC&7040FF?704698?70480F&7048F7N7049A2w704BCA7704CA2&704CA58704D7B#704E6B?704EE0]704F57i" +
+        "705464b7054F5?705681&705A0F<705AAC]705FA3u70617B27062B837062CB&70662Ae7066B9?70695A2706BB92706D152706E10?706E6D270700D&707013?70708B27070AA%" +
+        "7070D5?70720DE70723C?7072FE&707362?7073CB&70792DG707990?7079B32707BE8?707CE3?707DA1\\707DB9270810527081EB&7085C2\"7086C1m708976n708A09?708BCD#" +
+        "708CB6?708CF2&7090B7?709684&709751u709AC4?709C45?709CD1A709E29e70A04BA70A2B3&70A6CCA70A741o70A8A5H70A8D3A70A8E3?70A983270AC08b70AE2A&70AED5&" +
+        "70AF09770B13D]70B306&70B317270B51A?70B5E8570B7E4/70B8F6770B950m70BB5B&70BBE9u70BC10H70BC48270BD96270C288A70C59Cb70C7F2?70C9C6270CA9B270CD0DA" +
+        "70CD60&70CE8C]70CF49A70D07Eb70D313?70D379270D823A70D8C2A70DA48270DB98270DDEF?70DEE2&70DF2F270E422270E56Em70E72C&70E997?70EA1A270EA5A&70EBA5?" +
+        "70ECE4&70EF00&70F087&70F088N70F096270F35A270F8AEH70F927]70F94A&70FD45?70FD46]70FF76m7400E8?7402E1m7403BD074042BE7404F1A7405A5i740AE1?740B12b" +
+        "740C2E2740CEE?740EA4&7410E0/7411B227413EAA7414D0&741575u7415F5&74190A]741BB2&741EB1]7422BB?742344u742435?742554K7426AC274272C!742869?7428AEG" +
+        "742917&742959&742972B742981m743174&74342B?743675B743822u743989i743AF4A743C24?743CDE<743F8E&743FC2>744218&74427F$74428B&744401M74452D?74458A]" +
+        "7446A0<7446B3m744D28I744D6D?744DBD77450CD?7451BAu74563C:7458F3%745909?745AAA?7460FA?7463C2?74650C&746A84m746DFA]747069?7470FDA74718B&7473B4&" +
+        "747446;747548%74761FH747786&74782757478A68748114&7483C2o748469N74860B274867A57486E2574872E?74882A?7488BB27489277748B23]748D08&748DAA?748F3C&" +
+        "748FC227498F40749B89?749D79_749D8F?749E75<749EAF&749EF5]74A02F274A063?74A2E6274A528?74A58Cm74A6CD&74A722D74A7EA%74A981?74ACB9o74AD98274B587&" +
+        "74B725?74B839m74B8A8?74BC6BA74C14F?74C17ED74C246%74C412H74C929474CA60d74CC40&74D02B#74D21D?74D285m74D423%74D435:74D5E8&74D637%74D6E5?74D6EAm" +
+        "74D809H74D83EA74DA38674DA78<74DA88i74DADA374DAEAm74E182m74E1B6&74E20C%74E28CH74E2E7274E2F5&74E50BA74E5F9A74E6B8D74E6E2574E798B74E987&74E9BF?" +
+        "74E9D8774EA3Ai74EB80]74ECB2%74F2FAu74F441]74F67A]74F90F?74F92Co74F9CAN74FA29o74FC77?74FECEi78009E]78028B&7802B127802F8u780473m7804E3?7806C9?" +
+        "78078F?78084D?780CB8A780CF02780E3E?78119D27811DCu781699?7817BE?7818A8?7818EC87819F7B781A32?781C3C7781C9Db781DBA?781FDB]782051i7820A5N7821847" +
+        "782327]7824AF#7824BE2782599?7825AD]7828CAd782B46A782B60?782BCB5782DAD?7831C1&78321B37833C6]783409?7834B4?783716]783A84&783F4D&7840E4]78421C7" +
+        "7844FDi784558o7845B3?7845C457845DC?7846D4]78471D]784859<7849D7]784F43&784F9BB78507CB78521A]785333u78542E3785536.785773?785860?78595E]785B64?" +
+        "785C5E?785DC8D785ECC&78605Bi786089]786256?7864A027864C0&786559\\7867D7&786A89?786C1C&786C84%78725D2787462A78753E&787826?787871m787984&787B8A&" +
+        "787E61&788102_78818CN788371?78843Ce78851727885F4?78862EH78886D&788A20o788CB5i788DAF\\78929CA7894B4_78960D&78966E?7898E83789987u789A18I789ED0]" +
+        "789F70&789FAA?78A03F%78A106i78A2A0N78A3E4&78A504m78A7C7&78A873]78ABBB]78AC44578ACC0<78AF08A78B46A?78B554?78B5F2?78B6FE]78BAF9278BC1A278BDBC]" +
+        "78C05Ai78C11D]78C213\\78C3E9]78C57Dw78C5E5m78C5F8?78C881e78C884?78CA39&78CD55m78CF2F?78CFF9?78D162&78D294M78D752?78D75F&78D840u78DA6E278DAAF?" +
+        "78DB2Fm78DC87?78DD33?78DEE4m78E0C5]78E103%78E22C?78E36D778E3B5<78E3DE&78E7D1<78EB46?78EE4C778F09B?78F1C6278F238]78F557?78F5FD?78F7BE]78F882D" +
+        "78FBD8&78FD94&78FE3DB78FF57A7C004D?7C010Am7C0191&7C034C\\7C035Eu7C03ABu7C03D8\\7C04D0&7C0A3F]7C0BC6]7C0C5F77C0CF1u7C0CFA?7C0ECE27C10C9#7C11BE&" +
+        "7C11CB?7C162A?7C1689\\7C1960?7C19E3;7C1AC0?7C1B93?7C1C68]7C1CF1?7C1DD9u7C210D27C210E27C214AA7C2302]7C2499&7C2586B7C25A387C2664\\7C296F&7C2A31A" +
+        "7C2ACA&7C2ADBu7C2C6777C2EBD;7C2EDD]7C310E27C31FAb7C33F9?7C3626?7C3866m7C38AD]7C3985?7C3B2D&7C3D2B?7C3E74?7C49EBu7C4B26&7C4D8F<7C4FAD77C4FCD&" +
+        "7C5049&7C5079A7C573C<7C5758<7C59B1&7C5A1Cf7C5CF8A7C6097?7C6130&7C6166%7C62E727C6305%7C6456]7C646CD7C669A?7C669Dm7C67A2A7C67AB[7C68B9?7C692B?" +
+        "7C69F627C6D12H7C6D62&7C6DF8&7C70DBA7C72E7m7C739877C73EB?7C752D]7C7635A7C7668?7C7716w7C787E]7C78B2s7C7A91A7C7BBF]7C7D3D?7C852F?7C876727C87CE7" +
+        "7C8931?7C8956]7C8BB5]7C8BCAi7C8C09G7C8EE4m7C9122]7C942A?7C94B2Q7C95F327C97E1?7C9824\\7C9A1D&7C9EBD77CA177?7CA1AE&7CA23E?7CA449u7CA62A<7CA8EC<" +
+        "7CAB60&7CAD4F27CAD7427CB0C2A7CB15D?7CB27DA7CB35327CB566A7CB59Bi7CB59F?7CBB8AN7CC06F&7CC0AAH7CC180&7CC225]7CC255g7CC294u7CC2C6i7CC385?7CC3A1&" +
+        "7CC537&7CC6B6b7CC882?7CC8DF&7CC95A57CCCB8A7CD1AD&7CD1C3&7CD2DA&7CD3E5?7CD4A8\\7CD54477CD566%7CD62C&7CD661u7CD95C;7CD9A0?7CDC73?7CDFA177CE269m" +
+        "7CE2CAB7CE53F?7CE87F\\7CE8B177CEC79m7CECB1&7CEDC6%7CF05F&7CF17Ei7CF31BD7CF34D&7CF666n7CF854]7CF88027CF90E]7CFADF&7CFC16&7CFD6Bu7CFE90G7CFF4D$" +
+        "80000BA80006E&80045F&800518?800794]800C67&800CF9%800D3F]801242&801316A801382?8013BEB80184458018A7]801934A801970]801D39&801F0268020DA\\8020FD]" +
+        "802395$80248F2802689380276C2802AA8o802DBF2802EC3?802EDE?8030DCm8030E0<8031F0]803253A8035C1u803773M8038BC?8038FBA80398C]803C04i803C20?803FD4&" +
+        "804126?80433FB80456B78045DDA804715&804786]80482Cs80489F>804971&804A14&804AF2d804B50b804DCB?804E70]804E81]8053E0780542D]80549C]8054D9?8054E3&" +
+        "805719]805A04D805A708805FC5&806036?806132280646F780647Cn80656D]80657C&8065997806933?806A002806D71%806F1C?806FB0m80711FB80717A?807264?8075BF]" +
+        "807B3E]807C62>807D14?807D3A7807DF9G807FF8B80802C8808223&8083F6&808489A8086D9]8086F2A808917i808943]808ABD]808DB7<808F1Di808F97u80929F&80953A&" +
+        "809621E809698&8099CFm8099E7e809B20A809FF5]80A63C%80A997&80ACACB80AD16u80AE54i80AF19&80B03D&80B54E780B575?80B655A80B686?80B989&80BE05&80BEAF>" +
+        "80C01EA80C16E<80C41Bm80C5E6H80CC12?80CC9CM80CE62<80CEB9]80CF41E80CFA2?80D09B?80D1CE&80D2E5N80D4A5?80D605&80DACE?80DB17B80E01D280E1BF?80E4BAA" +
+        "80E63Cu80E650&80E82C<80E86F280EA07i80EA0Bw80EA96&80ED2C&80F1A4?80F1B2780F3DA780F5AE>80F5B5m80FB06?8400D2e8400EC`840328B840511&84083AA840C6D2" +
+        "840D8E7840F4C&84119E]84144DA8415D3?84160C/8416F9i841888B841985]841B5EM841B77A841EA3\\841FE878421F1?842289]842519]8425DB]842712b842789&842859%" +
+        "842999&842AFD<842B2B5842E14b842E27]842F57&843497<8437D5]843835&84398F8843A4BA843CFC?843DC62843E03\\843E92?844167&844693u8446FE?844765?844880%" +
+        "845075?845181]845234B8454DF?8455A5]845733H845A3E2845B12?845C315845CF3A845F04]8463D6H8464DD?84683EA846878&846993<847127b84716A?847293m847637?" +
+        "847848o84788B&8478AC2847B57A847BEB5847CEE2847D7E2847E40m84802D2848506&8488E1&8489AD&848A8D2848C8D&848E0C&848EDFe848F695849265A8492E5?8493A0?" +
+        "849437&849459>849866]8498A7m849A40>849FB5?84A06E\\84A134&84A1D1\\84A423\\84A466]84A6C8A84A824;84A8E4?84A93E<84A9C4?84AB1A&84AC16&84AD58?84AD8D&" +
+        "84AEDEu84AFEC084B153&84B1E2H84B1E4&84B261284B4DBb84B517284B541]84B59CB84B802284B890i84BA20b84BB26m84BE52?84C065N84C0EF]84C1C1B84C5A6A84C692m" +
+        "84C7BB784C7EAe84C9B2384CC63?84CCA8784D1C1A84D328&84D3D5?84D47E<84D6D0%84D7DE?84D81Bi84DBA4?84DBAC?84DD20m84DD84284E342n84E657e84E8CB084E986?" +
+        "84EAED[84EB0CG84EB18m84EBEF284EE7F?84EEE4]84EF18A84F147284F3EB784F703784FCAC&84FCE6784FCFE&84FD27b84FDD1A84FE40?84FFC228801F9m88074BD880AA3B" +
+        "880CE0m880F62b880FA2\\881032&88108F?881196?8813BF7881566?8815C5?8817A8&881908&881A14b881DFC2881E5A&881FA1&88200D&88225B<882510<882593i8828B3?" +
+        "8828FBB88299C]8829BF%882C31&882F92u883037B883314m88365FD8836CF?883A30<883D24;883F27?883F4Am883FD3?884033?88403B?8843E12884477?884604u884AEAm" +
+        "884D7C&884F5928851F2&8851FB<8852EBu88532EA885395&8853D4?88541F;88546Bm8856A6788572178857EE0885A922885E54]8863C5?8863DF&886440&886639?88665A&" +
+        "8866A5&8867DC?88693D?886B6E&886BDB&886C60u886D2D?886EEB?886FD45887015&88708CE8871E5%887477?8875562887598]8876B93887873A887ABC2887E9B&8881B9?" +
+        "888322]888603?88892F?888E68?888FA4?889009B88908D2889986i889B39]889CAD2889F6F]88A0BE?88A25EB88A29EW88A2D7?88A303]88A479&88A6C6\\88A9B7&88ACC0w" +
+        "88ADD2]88AE07&88B111A88B291&88B4BE?88B7EB&88B945&88B951u88BA74b88BCC1?88BD45]88BFE4?88C08B&88C227?88C22D088C255m88C344;88C397u88C663&88C6E8?" +
+        "88C9D0D88C9E8e88CB87&88CE3F?88CEFA?88CF98?88CFCDm88D546&88D7F6#88D82EA88D98FB88DA04?88DDB8?88DE39>88DEA9[88E056?88E0F3B88E3AB?88E64BB88E87F&" +
+        "88E9A4<88E9FE&88F031288F077288F155788F2CE&88F3D5w88F4DAA88F56E?88F6DC?88F872?88FC5D28C006D&8C04BA58C0572?8C0879m8C08AA&8C0D76?8C0FC9?8C10D4\\" +
+        "8C142A28C15C7?8C1759A8C17B6?8C1952%8C1ABF]8C1D96A8C1E8028C210Ai8C2114?8C22D2>8C2505?8C26AA&8C2937&8C2A85%8C2DAA&8C2E72]8C3066o8C3396&8C3446?" +
+        "8C34FD?8C3AE3D8C3BADM8C426D?8C44A528C459878C47BE58C4962[8C4B1478C4EBB%8C4F0078C53C3u8C554AA8C5646D8C56C5N8C5877&8C5973w8C5AC1?8C5AF8u8C5EBD?" +
+        "8C604F28C6422e8C65A3b8C683A?8C6A3B]8C6BDB?8C6D77?8C6FB9b8C705AA8C71F8]8C73DAb8C7712]8C7909<8C79F5]8C7A3Du8C7AAA&8C7B9D&8C7C92&8C8283&8C83E1]" +
+        "8C83E8?8C844228C8474/8C8590&8C85C1<8C861E&8C862A?8C86DDi8C89A5F8C8ACD?8C8B48b8C8B83m8C8C2978C8D28A8C8EF2&8C8FE9&8C902Di8C913AG8C91A4&8C941F2" +
+        "8C946128C94DF78C986B&8C9885]8C9A8F\\8CA2F5?8CA3EC]8CA5CF?8CA6DFi8CA96D?8CA982A8CAAB578CAACEu8CB0E9]8CB50E28CB64F28CB87EA8CBEBEu8CBFA6]8CBFEA7" +
+        "8CC246&8CC5B4\\8CC5D0]8CC681A8CC8CD]8CC9E9?8CCDE8N8CCE4E78CCF0958CD066m8CD0B2u8CD9D6u8CDCD4<8CDEE6]8CDEF9u8CE5C0]8CE5EF?8CE748>8CE9B448CE9EEA" +
+        "8CE9FF58CEA48]8CEBC6?8CEC4B58CEC7B&8CEDE1o8CF67DM8CF681b8CF8C5A8CFABA&8CFADD?8CFD18?8CFD4978CFDDE\\8CFE57&9000DB]900117?90013B\\9002A94900325?" +
+        "900628]9006DB?9006F2m9009D0h9009DFA900A48]900A84G900CC8;901057A901195%90150679016BA?90173F?9017AC?9017C8?901F09b901F94i9020C2<9020D7H90235B%" +
+        "9025F2?9027E4&902AEEu902B34:902BD2?902C09&902E1CA9035A2&9035EAb90380C790395Eb90395F%903C92&903FC3?903FEA?9041B2o904528N904748e9047C2A904846m" +
+        "90486CZ9049FAA904C81<904CC5&904D4A\\904DE2&904E2B?9051F8<90567129059AFm905A08g905E44?905F7A&9060F1&9061AEA90623F&90633B]90649B79064AD?906584A" +
+        "90671C?906989]906AEBH906CAC8907065m9070697907240&907282\\9074F2?9077EE2907841A9078B2u907BC6m90808F?90812A&908158&908175]90840D&9088552908A802" +
+        "908C43&908D6C&908D6E5908D783909497?9094E43909507?9096F309097D579097F3]909838?909A4Ai909A77m909B6F&909C4A&909F22w90A25B&90A57D?90A5AF?90A822%" +
+        "90AB96b90AC6D&90AE1Bi90B021A90B0ED&90B11C590B144]90B176A90B21F&90B339790B622]90B790&90B931&90BA09?90C115e90C1C6&90C97E?90CAFA;90CC7A?90CCDFA" +
+        "90CDE8&90CEB8m90D733?90D7EBm90DA72790DD5D&90E17B&90E202m90E2BAA90E317G90E5B1790E6BA#90E95E290EB50290ECEA&90EEC7]90EF68w90F1AA]90F644?90F652i" +
+        "90F80C]90F82E%90F970?90F9B7?90FB5Du90FD61&90FD9Fb9400B0?9401C2]94049C?9408C7?940B19?940BCD&940C6Di940C98&940D4B2940E5F&940E6B?940EE7?94105A5" +
+        "9415B2?941625&941700u941865M941882<941DD4;942157&942453?942533?94261D?94270EA942A6Fo942AD6m942B68&942DDC]9433D82943469b94350A]943589?9437F7?" +
+        "94390EA943A91%943B22M943C96\\943CC67943FC2<943FD6&9440C9<9440F3?944560;9446672944788?9449F6?944A0C_945044m945103]9451DC7945244]9453FFA9454C57" +
+        "9457A5<9458CBN945915%945AEA?945AFC%945C9A&946010?9460D5<94626Du9463D1]946424<94659CA946DAEG9476B7]94772B?947AF4?947BAEu947BE7]947D77?9487E0u" +
+        "948854m948978&948B93u948BC1]948E6DN949010?949426&9495A0;94988F\\949AA9H949CBE?949F3Ed94A07D?94A081b94A25D?94A4F9?94A67EM94A990794A9A8m94AD05?" +
+        "94AD23&94AEF0294B01F&94B10A]94B216b94B271?94B40F<94B43A]94B555794B609A94B86DA94B97E794BF2D&94BF94B94CE0F?94CE2Ce94CFB0?94D00D?94D2BC?94D331u" +
+        "94D469294D54D?94D771]94D9B3i94DB56e94DBDA?94DE80:94DEB8b94DF34?94E129]94E1AC>94E23CA94E300?94E36Dm94E4BA?94E686794E6BA]94E6F7A94E70BA94E7EA?" +
+        "94E7F3?94E96A&94E9EE?94EA32&94EB2C;94EC32b94EF50i94F128<94F392894F6A3&94F6D6&94F7ADB94FE22?94FEF4\\94FF06<94FF3C89800C6&9801A7&98038Am98038Ei" +
+        "98039BG9803D8&98045F?98063C]980709?98072Dm98076A&980C33b980D51?980D67w980D6F]980DAF&9810E8&9812E0u98171Au981A35?981CA2&981DFA]981E19\\98226E%" +
+        "98247B?98254Ai982AC3b982AFD?982CBCA982D68]982FF8?983268b9835ED?98398E]983A1F;983B8FA983C8C&983DAE7983F60?983FE8]9840BB598415CN984265\\9842F5&" +
+        "9843FAA9844CE?98460A&984827i984874?984925B984B06?984BE1<984E8A]984FEEA98502E&9851FC?985207?9852B1]98535F?98541BA985945m98597AA985A98?985AEB&" +
+        "985DADm985F41A985FD3H9860CA&986110?98698A&98751A?987552u987A14H987BF3m987EB5?9880EE]98818A?988389]9884E3m98868BB98876C?98886C?9888E07988924m" +
+        "988B0A>988B5D\\988D46A988F00<98909659897CCi9898FB;989BCB$989C57?989DE5>989E63&989F1E?98A2C0298A316798A375?98A5F9&98A965$98AD1D?98AF65A98B08B]" +
+        "98B379&98B3EF?98B6E9N98B8BAD98B8BC]98B8E3&98BA5Fi98BD80A98C08A?98C377798CA33&98CAB5&98CCF3%98CDAC798CF7D&98D293;98D3D7?98D6BB&98D6F7D98D742]" +
+        "98D7E1298D863798DAC4i98DD60&98DED0i98DF82>98E081u98E0D9&98E255N98E743598E7F4<98E7F5?98E859&98E8FAN98EE94u98F04C298F07Bm98F083?98F0AB&98F112>" +
+        "98F2B3<98F3F6?98F487m98F4AB798F621u98F9CC498FA2Ee98FAE3u98FB27]98FE3EA98FE54W98FE94&98FEE1&98FFD0E9C0298]9C0351?9C04EB&9C0591G9C05D6o9C098B2" +
+        "9C09CA?9C139E79C146349C1A25&9C1C12<9C1D36?9C1D58m9C207B&9C216Ai9C2183/9C2472\\9C2595]9C28B3&9C28EF?9C28F7u9C293F&9C2976A9C2A83]9C2E7A]9C2EA1u" +
+        "9C35EB&9C3708<9C37CBe9C37F4?9C381829C3928]9C3AAF]9C3DCFM9C3E53&9C4782i9C4929?9C4DC229C4E2029C4E36A9C4F5F;9C4FDA&9C52F8?9C5322i9C541629C5636?" +
+        "9C5766?9C57AD29C583C&9C5884&9C5A80B9C5A81u9C5C8E#9C5CF9e9C5FB0]9C6076&9C61D7?9C63C0G9C648B&9C64A1b9C65B0]9C65EBA9C669729C67D6A9C69D1?9C6B00\"" +
+        "9C6C15H9C713A?9C7370?9C73B1]9C741A?9C746F?9C760E&9C7613Z9C79E3&9C7BEF<9C7DA3?9C823F?9C8306]9C84BF&9C8ACBB9C8BA0&9C8C6E]9C8CD8<9C8E99<9C8E9C?" +
+        "9C924F&9C9567?9C9613E9C96D579C971BA9C9774?9C9793?9C99A0u9C9C1F79C9D7Eu9C9E6E79C9E71?9C9ED5u9CA118?9CA2F4i9CA513]9CA615i9CA9B829CA9C5&9CAA1BH" +
+        "9CAFCA29CB150A9CB2B2?9CB2E8?9CB654<9CBCA6<9CBCF0u9CBFCD?9CC172?9CC394&9CC7A6$9CC893B9CC8E9%9CC9EBM9CCC0179CCC83B9CD35B]9CD36DM9CD57D29CD6433" +
+        "9CDA3EA9CDAA8&9CDAB7<9CDBAF?9CDC71<9CDF8A?9CE063]9CE17629CE33F&9CE374?9CE635N9CE65E&9CE6E7]9CEC61?9CF1D4[9CF27E]9CF387&9CF3AC&9CF48E&9CF9A1B" +
+        "9CFA76&9CFC01&9CFC28&9CFCE8AA002A5AA002DC%A00460MA00798]A0086F?A00A9A?A00E98?A00F372A01081]A01828&A01B29\\A01B9E]A01C8D?A01D48<A01E0BJA020A67" +
+        "A02195]A021B7MA0239F2A025D7<A027B6]A029195A02942AA02BB8<A02DDB\\A031DB?A03299EA0334F2A03679?A0369FAA036BC#A039EE\\A039F7DA039F9\\A03BE3&A03C20\\" +
+        "A03D6E2A03D6F2A0406F?A040A0MA04147?A042D1?A0445C?A0481C<A04839?A04A5EHA04EA7&A04ECF&A04F52AA04F85DA0510BAA05272&A052AB$A0551F\\A0554F2A0562C]" +
+        "A056F3&A057E3?A05950AA06090]A06391MA06C65mA070B7?A07591]A0764E7A07817&A0782D&A07D9C]A07F8A\\A08069AA0821F]A08527AA085E37A085FCHA086C6uA087BE&" +
+        "A08869AA0889D?A088B4AA088C2GA08CF8?A08CFD<A08D16?A08E78\\A09169DA09208nA093512A0999B&A09A8E&A09D22&A09F7A3A0A001<A0A0DC?A0A309&A0A33B?A0A3B37" +
+        "A0A3F03A0A47F2A0A4C5AA0A8CDAA0AB1B3A0AC69]A0AD62?A0AD9F#A0AF12?A0AFBDAA0B0BD]A0B15C;A0B339AA0B3CC<A0B40F&A0B4392A0B4A5]A0B7657A0BC6F2A0BD1D4" +
+        "A0C1C5&A0C20D?A0C589AA0C7D22A0C926?A0CBFD]A0CF5B2A0D05B]A0D0DC%A0D1B3&A0D2B1%A0D365AA0D37AAA0D3C1<A0D722]A0D795&A0D7A0?A0D7F3]A0D807?A0D91Am" +
+        "A0DD6C7A0DE0F?A0DF15?A0E0AF2A0E390&A0E453eA0E4CBwA0E6F8mA0E70BAA0ECF92A0EDCD&A0EE1A&A0F2627A0F3C1iA0F479?A0F6FDmA0F8492A0FAC8?A0FBC5&A0FF0C>" +
+        "A4004E2A400E2?A402B7%A402B9AA406E9mA407B6]A40801%A408F5\\A40987&A409B3?A40CC32A40E75<A410B62A41162)A41194EA411BB2A41437>A416C0&A416E7?A4178B?" +
+        "A418752A41A3AiA41F725A42249\\A426CAAA42902>A42A953A42B8CMA42BB0iA4307A]A43135&A434D9AA434F1mA4373E?A43828?A438CCNA439B3uA43B0E?A43FA7<A4423BA" +
+        "A44280&A44343?A44380?A44519uA446B4?A44BD5uA44BD9>A44C112A44CC85A44E31AA45046uA4515EBA4530E2A45590uA456302A459BFbA45C25mA45C27NA45D36<A45E60&" +
+        "A46706&A46BB6AA46C24?A46C2A2A46CF1]A46DA4?A46DD4bA47174?A475B9]A47733;A477F3&A478062A47952?A47B1A?A47B9D7A47CC9?A47F1BBA483E7&A48431]A488732" +
+        "A48CDBEA4933F?A4934C2A493AD?A493FE?A497002A49947?A49A58]A49B4F?A49BCD2A49DDD]A49E69bA49FE7]A4A459>A4A46B?A4A490]A4A5842A4A64EGA4A930uA4AAFE?" +
+        "A4AC0F?A4B0F5mA4B197&A4B1C1AA4B2392A4B4392A4B61E?A4B805&A4BA70uA4BA76?A4BADB5A4BB6D5A4BDC4?A4BE2B?A4BF01AA4C0E1NA4C1E8NA4C337&A4C34EmA4C361&" +
+        "A4C3BEuA4C3F0AA4C494AA4C54E?A4C64F?A4C69A]A4C6F0&A4C74B?A4C788uA4CAA0?A4CB8F7A4CC37bA4CCB3uA4CF127A4CF99&A4D18C&A4D1D2&A4D23E&A4D578mA4D5C2>" +
+        "A4D931&A4D990]A4DA32mA4DCBE?A4DCD52A4DD58?A4E11ABA4E287uA4E57C7A4E975&A4EBD3]A4EF0CGA4F00F7A4F1E8&A4F6E6&A4F6E8&A4F841&A4F8FFoA4F921&A4F933A" +
+        "A4FC14&A4FF9FuA8032A7A80600]A809B1?A80C0D2A80C63?A80DE1?A81087mA8154DiA816B2DA816D0]A81AF1&A81B6AmA82066&A823FEDA82948iA829DCiA82BB9]A82BCD?" +
+        "A82BD5uA82C89&A830BC]A8346A]A83512?A83759?A83B5C?A83CA55A83ED3?A842A1iA842E37A84616?A846747A848FA7A8494D?A84A28&A84B4D]A84FB12A85081?A8515B]" +
+        "A851AB&A852D4<A8574EiA8595FAA85AE0?A85B78&A85BB7&A85BF7<A85C2C&A85E45#A85F61&A860B6&A861ECmA8637D3A863F2mA864F1AA8667F&A86A86uA86ABB\\A86DAAA" +
+        "A86E4E?A86E84iA8724DAA87650]A87971?A8798D]A87C01]A87C45?A87CF8&A87D12?A87EEAAA88055nA8817E&A88195]A886DD&A887B3]A88808&A88940?A88C3EHA88E24&" +
+        "A88FD9&A8913D&A89162fA8922CDA8968A&A899695A89A93\\A89C6CoA89C78&A89CEDuA89D212A89FBA]A8A159\"A8AA7C?A8ABB5&A8B13B<A8B1D42A8B271?A8B4562A8B57C[" +
+        "A8B86EDA8BA25<A8BA69]A8BB56&A8BBCF&A8BD27<A8BE27&A8C092?A8C252?A8C407?A8C83A?A8CA77%A8CA7B?A8D081?A8D0E5BA8D162]A8D4E0?A8DDBCnA8E2C1mA8E3EEe" +
+        "A8E544?A8E621%A8E77DmA8E978?A8EE67]A8F059?A8F07C?A8F266?A8F274]A8F5AC?A8FAD8&A8FB45;A8FD077A8FE9D&A8FFBA?AC007A&AC05C7AAC075F?AC0775&AC0BFB7" +
+        "AC0D1BDAC1203AAC15187AC15A2iAC15F4&AC1615&AC162D<AC16DEAAC198EAAC1A3D5AC1D06&AC1E92]AC1E9EuAC1F0FmAC1F6BgAC1F74&AC220B#AC2241?AC276E7AC293A&" +
+        "AC2AA12AC2B6EAAC3184?AC3328?AC3613]AC3870EAC3A672AC3A7A[AC3AE2KAC3B77\\AC3C0B&AC3DCBAAC3EB1;AC416A%AC4500&AC45EFAAC471B?AC49DB&AC4A562AC4A672" +
+        "AC4BC8BAC4D16mAC4E91?AC51AB?AC5322]AC5A14]AC5AF0DAC5AFCAAC5C2C&AC5E14?AC6089?AC6175?AC61EA&AC63BE%AC6490?AC675DAAC6784;AC67B27AC6C90]AC712E8" +
+        "AC7289AAC74B1AAC751D?AC78D1BAC7A562AC7BA1AAC7E01?AC7E8A2AC7F3E&AC800AeAC80FB]AC8247AAC82F0&AC84C6iAC84C9\\AC853D?AC86A3&AC87A3&AC88FD&AC8AC7?" +
+        "AC8BA9oAC8C46uAC8D34?AC8EBDHAC9073?AC9085&AC91A15AC9232?AC936A?AC9738&AC9929?AC9A3FmAC9B0AeAC9E17#AC9FC3ZACA0162ACA09DBACA26A7ACA31E<ACA7047" +
+        "ACA7F1iACA899mACAE19[ACAFB9]ACB3B5?ACB4805ACB92F>ACBC32&ACBCB5&ACBCD92ACBD70?ACC1EEuACC33A]ACC5B4?ACC906&ACCB51>ACCC8E-ACCCFC%ACCF5C&ACCF85?" +
+        "ACD0747ACD4002ACD75B\\ACDCCA?ACDFA1&ACE011?ACE215?ACE2D3<ACE342?ACE4B5&ACE4D8uACE6BB;ACE6FB&ACE87B?ACEAEA?ACEBE67ACED5CAACEE9E]ACF1DF3ACF2C52" +
+        "ACF466<ACF5E62ACF6F7DACF7F3uACF878nACF970?ACFAE4NACFDCEAACFDEC&ACFF6B?B000B42B00875?B00B22?B00CD1<B010A0mB01656?B01831&B01921iB019C6&B01BFC2" +
+        "B01F8C<B01FF4\\B0216F?B0227A<B02491?B02628/B026802B02A43;B02B642B02EE0?B033A6BB03495&B0359FAB035B5&B03795DB03956MB03ACE?B03CDCAB03F64&B03FD37" +
+        "B042B7?B04502?B047BF]B047E9AB0481A&B0487AiB04A6A]B04E26iB04F135B05476]B05508?B05A7B?B05ADA<B05B67?B05B99\\B05CDA<B06088AB061EBbB064E0]B065BD&" +
+        "B067B5&B06A41;B06EBF#B06FE0]B0702D&B07219&B0735D?B0739C%B0761B?B07ADF?B07B255B07D472B07D64AB07E11mB07FB9MB081847B082E2#B083FE5B08900?B08BA8%" +
+        "B08BCF2B08BD02B08C75&B08D572B0907E2B09122mB09200&B0924A\\B09575iB0958EiB0982B\\B098BC?B0995A?B099D7]B09C63uB09CB2;B09FBA&B0A460AB0A4F0?B0A5B3<" +
+        "B0A6047B0A6512B0A7327B0A737[B0A7B9iB0A86EBB0AA772B0B113mB0B21C7B0B28F\\B0B2DCwB0B448mB0B867<B0B98AMB0BBA9&B0BBE5\\B0BE45GB0BE76iB0BE83&B0C38E?" +
+        "B0C4E7]B0C53C2B0C5543B0C559]B0C61C?B0C69ABB0C7450B0C787?B0C7DEbB0CA68&B0CAE7?B0CBD87B0CCFE?B0CF0EGB0CFCB%B0D09C]B0D278mB0D576&B0D5CCmB0D5FB;" +
+        "B0D616gB0D77E?B0DCEFAB0DE28&B0DE31]B0DF3A]B0DFC1kB0E17E?B0E235uB0E45C]B0E4D5;B0E5ED?B0E5EF&B0E5F9&B0E8E8bB0EB57?B0EB7FBB0EC69&B0EC71]B0ECDD?" +
+        "B0EE7B[B0F1D8&B0F208$B0F2F6]B0F7C4%B0FA8B?B0FAEB2B0FC0D%B0FC88\\B0FEE5?B0FF0D>B402162B405A1uB40931?B40AD8eB40B1D]B40EDEAB40F3BkB4107A%B4107Bm" +
+        "B41324;B414892B414E6?B41513?B41584]B41678BB418D1&B41974&B41A1D]B41BB0&B41DC4?B41F4DeB423A2;B42802AB42BB9?B42E99:B43052?B43522bB437D83B43836?" +
+        "B4394C?B43A28]B43A31bB43A457B43AE2?B43B52\\B440A4&B440DC]B44326?B44389?B445065B44BD2&B44C3B4B44C902B45253^B4527DeB4527EeB452A9mB454F2?B45575&" +
+        "B456E3&B45976&B45BD1iB45CB5GB45D50<B460EDuB46142?B46293]B46921AB46BFCAB46D83AB46E08?B47064]B47443]B476A4?B47AF1<B47B1A?B47C9C%B47E9FuB48351A" +
+        "B485E1&B48655?B48901?B48931bB48A0A7B48A5FBB48B19&B49107?B49691AB496A5&B4994CmB499BA<B49CDF&B49D02]B4A10A?B4A382>B4A4E32B4A5EF_B4A64A7B4A898?" +
+        "B4A8B92B4AC9DmB4AEC1&B4B024iB4B055?B4B291DB4B2E98B4B52F<B4B676AB4B686<B4B742%B4BC7CmB4BFE97B4BFF6]B4C0C3iB4C2F7?B4C3D9?B4C4FCuB4CADD2B4CD27?" +
+        "B4CE40]B4D1F6?B4D5BDAB4D5E5]B4DE312B4E10F5B4E25B<B4E3D0DB4E3F9bB4E454%B4E5C5?B4E62D7B4E9B02B4E9B85B4EED4mB4EF39]B4F0AB&B4F18C?B4F1DADB4F49B?" +
+        "B4F58E?B4F61C&B4F7A1DB4F95DBB4FA48&B4FBE4oB4FBF9?B4FC7D$B4FF98?B8005B?B8011F&B80305AB80533&B8060DnB808CFAB808D7?B8098A&B80B9A?B8114B2B8144D&" +
+        "B8145C?B817C2&B81D1F?B81DAADB81E9E?B81F3F7B8211C&B8220C&B82227mB827C5?B827EBWB82A725B82AA9&B82B68?B831B5HB8374A&B8374B<B837B2<B838612B83865<" +
+        "B83A08kB83A5A<B83BCCuB83C20?B83C28&B83DF6mB83E59[B83FD2GB841A4&B844D9&B845EB&B8496D&B84FA7&B84FD5HB850D8uB852E0uB85384uB853AC&B85600?B857D62" +
+        "B857D8]B8599FGB85A73]B85C5CHB85D0A&B85DC3?B85E7B]B85F98%B85FB0?B861FCBB8621F2B8634D&B86685\\B86870NB869F4IB86AF1\\B86CE0<B86CE8]B8752E?B87826N" +
+        "B8782E&B87B4D7B87BC5&B87BD4;B87CD0?B87E40?B8804FmB88198AB881FA&B88303<B8857B?B885845B88788<B88A60AB88AECNB88C2B\\B88D12&B88E82?B89047&B89436?" +
+        "B894D9mB894E7uB89734bB89A2AAB89FCC?B8A0B8]B8A175[B8A3772B8A3863B8A44F-B8A825]B8AC6F5B8AE6ENB8AF67<B8B2F8&B8B409]B8B81EAB8BA66HB8BB117B8BBAF]" +
+        "B8BC1B?B8BC5B]B8BEBF2B8BF83AB8C111&B8C253BB8C385?B8C68E]B8C75D&B8C9242B8CA3A5B8CB295B8CEED/B8CEF6GB8D4C3?B8D4E7<B8D526wB8D61A7B8D6F6?B8D94D\\" +
+        "B8D9CE]B8DA5EmB8DAE8?B8DB38;B8E3B1?B8E60C&B8E856&B8E924GB8E937dB8EA98uB8ECA3wB8EE0E\\B8F0097B8F015BB8F12A&B8F4A4;B8F6B1&B8F775AB8F8627B8F883i" +
+        "B8F934eB8FBB3iB8FE902B8FE96%B8FF61&B8FFFEmBC026EbBC0358ABC0543$BC071DiBC091BABC0963&BC0DA5mBC0EAB]BC0F64ABC0F9A3BC0FF3<BC0FFEBBC107B]BC1485]" +
+        "BC16652BC16F52BC17B8ABC1827&BC1896?BC19267BC192FmBC1AE4?BC1E85?BC20A4]BC22283BC2411SBC25E0?BC26C72BC277A]BC2978>BC2CE62BC2EF6?BC305B5BC325F4" +
+        "BC32B2]BC3329eBC33ACbBC33DB\\BC351EnBC36F7?BC37D3&BC3898ABC3BAF&BC3D85?BC3F8F?BC4486]BC455B]BC4699iBC4760]BC4A562BC4C78?BC4CA0?BC4CC4&BC4F2D&" +
+        "BC5274]BC52B7&BC542FABC5436&BC5451]BC5A562BC5E33>BC5FF4\"BC60A7eBC6193uBC620E?BC671C2BC6778&BC68C3?BC6A29mBC6AD1uBC6C21&BC6E64eBC6EE2ABC72B1]" +
+        "BC744BNBC74EA&BC7574?BC765E]BC7670?BC76C5?BC7737ABC79AD]BC7ABF]BC7B72?BC7E8B]BC7EC3wBC7F7B?BC7FA4uBC804E&BC8385HBC851F]BC89A6NBC89A7&BC89C1&" +
+        "BC8D1F2BC8D7EbBC926B&BC9307]BC932AbBC9789?BC97E1/BC9911wBC9930?BC9A53?BC9B5E>BC9C31?BC9EBBNBC9F58&BC9FE4<BC9FEF&BCA080]BCA0B9?BCA231?BCA511M" +
+        "BCA58B]BCA5A9&BCA8A6ABCA920&BCABF52BCAD28>BCAEC5#BCB0E7?BCB1F3]BCB2CC]BCB30E2BCB863&BCBAC2>BCBB58&BCBCCA?BCBF2E#BCC427?BCC4932BCCD7F?BCCD99A" +
+        "BCCE25NBCCF4FwBCD074&BCD11F]BCD177iBCD206?BCD22CABCD2952BCD5ED\\BCD7A5<BCD7D4[BCDDC27BCDF58;BCE143&BCE265?BCE63F]BCE7122BCE92F<BCEAFA<BCEBE8]" +
+        "BCEC5D&BCEE7B#BCF105ABCF171ABCF1F22BCF5ACDBCF6853BCF730]BCFABAGBCFAEB2BCFCE7#BCFED9&BCFF4D7C00195%C00380BC0060C?C006C3iC01173]C014FE2C0151Be" +
+        "C01693uC0174D]C01754&C01803<C01944BC01ADA&C01C6A;C0238D]C02506$C0255C2C025A55C025E9iC02C172C02C5C&C02CEDbC02E5FwC03379?C0395A4C03A55iC03C04\\" +
+        "C03C59AC03D03]C03E50?C03E75?C03EBA5C03F0EMC03FDD?C042D0BC04442&C0470E5C048E6]C049EF7C04A00iC04A0EmC04E307C04E8A?C0517E>C05234?C056E3>C0582E2" +
+        "C05B44uC05BBD?C05D897C06118iC06194?C0626B2C06380mC06394&C064E42C06599]C067AF2C06C0C&C06DED>C07009?C07831?C07AD6]C07BBC2C083C9?C0847A&C084E0?" +
+        "C087EB]C08997]C08B05?C08B2A2C08C602C08D51%C091B9%C0956D&C095CF%C09AD0&C09B63?C09B9EbC09F42&C0A0BB3C0A36D?C0A4CFNC0A53E&C0A5E8AC0A600&C0A810A" +
+        "C0A938?C0AB2B?C0AC54\\C0B22F&C0B47D?C0B550/C0B5CD?C0B5DDbC0B658&C0B6F9AC0B883AC0BC9A?C0BDC8]C0BFA7BC0BFAC?C0BFC0?C0C7DB&C0C9E3iC0CCF8&C0CDD67" +
+        "C0CECD&C0D012&C0D026?C0D044\\C0D193?C0D2DD]C0D3C0]C0D46B?C0D5E2]C0D60AmC0D6D5HC0DA5E?C0DCD7?C0DCDA]C0DFEDBC0E018?C0E1BE?C0E3FB?C0E422mC0E42Di" +
+        "C0E579?C0E862&C0F2FB&C0F4E6?C0F6C2?C0F6EC?C0F853nC0F87F2C0F9B0?C0FD6F\\C0FFA8?C0FFD4MC403A8AC40415MC40528?C40683?C4072F?C409B7BC40ACB2C40B31&" +
+        "C40BCBuC40D96?C40F08AC41234&C412EC?C412F53C41411&C4143C2C41688?C4168F&C416C8?C4170E?C418E9]C418FC2C41C07]C42155?C42360AC4278C?C42996aC42AD0&" +
+        "C42B44?C42C03&C42F90>C4345B?C4346B<C435D9&C436C00C43875dC43ABEeC43CEA0C43D1AAC43DC7MC43EAB?C44202]C4438FDC4447D?C444A02C446062C4473F?C4474EA" +
+        "C4491B&C44B312C44D842C44E5DmC44F337C44F5F?C45006]C4524F&C4550D?C45746mC4576E]C457CD?C45A86?C45AB15C45BAC&C45BBE7C45D83]C45E5C?C4618B&C4619Am" +
+        "C461C7HC462EA]C463C4?C464132C464E3mC46516<C467D1?C46940uC469F0?C46AB7uC46DD1?C46E1FiC470BDGC4710FuC47154iC471FE2C472952C4731E]C475ABAC475EA?" +
+        "C47764]C478A2?C47BE3?C47D4F2C47D9F]C47EE02C48025?C482E1nC48466&C484FC&C48508AC486E9?C488E5]C4910C&C493BBuC493D9]C49500%C4969F%C49880&C49A02D" +
+        "C49A31wC49D08?C49DEDHC49E7E7C49F4C?C4A1AE?C4A402?C4A81D3C4AA432C4AA99?C4AAC44C4AB4D2C4ACAA&C4AD34IC4AE12]C4B1D9?C4B2392C4B301&C4B349&C4B36A2" +
+        "C4B8B4?C4B9CD2C4BB03?C4BB897C4BDE5AC4BE84mC4C00AHC4C17D&C4C36B&C4C6032C4CB76HC4CBE15C4D0E3AC4D36AmC4D438?C4D6D35C4D738?C4D8C8bC4D8D4?C4D8D57" +
+        "C4D987AC4DB04?C4DBADZC4DD577C4DE7B?C4DEE27C4E287?C4E90A3C4E984iC4EB39\\C4EB41\\C4EB42\\C4EB43\\C4EB81&C4EDBAmC4EF3D]C4F081?C4F312mC4F445&C4F7C1&" +
+        "C4F7D52C4FBAA?C4FF1F?C4FF22?C4FF99AC800842C808E9DC809A8AC80CC8?C80E14$C8102FMC8120B]C81337BC81451?C81479]C8154EAC819F7]C81EE7&C81F665C81FBE?" +
+        "C81FE8&C82158AC82832uC828E52C82A14&C82ADD;C82B967C82E187C83049?C8334B&C83374wC833E5?C8348EAC834E52C83870]C839AC?C83A35kC83A6B[C83C85&C83DDCu" +
+        "C83E99mC83E9E?C83F26HC8418A]C847092C84805NC84AA0eC84BD65C84C752C84F86fC850CE?C85142]C85195?C8544BwC858B3AC858C0AC85ACF<C85CCCuC85EA9AC86000#" +
+        "C8608F2C863F1eC868DE?C869CD&C86C3D%C86C87wC86E08AC86F1D&C875DDDC8787D3C878F72C87E75]C87F54#C8806D&C882342C884A12C884CF?C885417C88550&C889F3&" +
+        "C88A7B7C88A9AAC88D83?C8908A]C890F7?C89143NC891F9\\C894BB?C895CEAC89665HC89C1D2C89D18?C89E43MC89F1A?C8A030mC8A6EF]C8A702>C8A776?C8A823]C8B1CD&" +
+        "C8B29BAC8B5AD<C8B5B7&C8B6D3?C8B78A?C8BB81?C8BC9C?C8BCC8&C8BD4D]C8BD69]C8BE193C8BF4CuC8BFFE?C8C2FA?C8C465?C8C83FmC8C919/C8C9A37C8C9B2%C8CA63?" +
+        "C8CB9EAC8CBB8<C8CD72\\C8D083&C8D15E?C8D1A9?C8D3A33C8D3FF<C8D7B0]C8D995BC8D9D2<C8DA297C8DDC9EC8DF84mC8E0EB&C8E265AC8E31D?C8E5E0?C8E600?C8E7F0B" +
+        "C8EA717C8ED8B7C8F09E7C8F319DC8F650&C8F733AC8F7505C8F9F92C8FD19mC8FE6ABCC00F1\\CC033DuCC037BmCC051B]CC0577?CC07AB]CC07E4ECC087B?CC088D&CC08E0&" +
+        "CC08FA&CC08FBiCC0DCBHCC115A&CC13F3>CC1531ACC167E2CC1E56?CC1E97?CC208C?CC20AC]CC20E8&CC2119]CC2293%CC22FE&CC25EF&CC2746&CC28AA#CC29F5&CC2D21k" +
+        "CC2DB7&CC2DE0ICC2F71ACC3089GCC3296?CC32E5iCC3331mCC33BB\\CC3429iCC35D9oCC36BBbCC36CF2CC38E1?CC3BFBZCC3D82ACC3DD1?CC3E5F<CC3F36&CC40D0MCC40F3G" +
+        "CC4210uCC4460?CC4463&CC45A5mCC464E]CC46D62CC483A5CC4B04&CC4D75uCC50E37CC53B5?CC5830\\CC5A532CC5B31NCC5C61?CC5D4EwCC6023&CC60C8HCC64A6?CC660A&" +
+        "CC68B6iCC68C77CC68E0&CC69FA&CC6A332CC6DA0[CC6EA4]CC70ED2CC722A&CC7645HCC785F&CC78ABmCC79D72CC7B5C7CC7E1F7CC7F752CC7F762CC808F&CC817D&CC86ECb" +
+        "CC88C7<CC895E?CC8A84?CC8CBFnCC8CE3mCC8DA27CC8E712CC90702CC9096?CC915B?CC96A0?CC96E55CC98912CC9E00NCC9EA2%CCA223?CCA30CbCCA7C1;CCA823bCCAA00H" +
+        "CCAFE3%CCB0A8?CCB0B3HCCB11A]CCB182?CCB2553CCB54CmCCB5D1uCCB6C82CCB775?CCB7C4?CCBA6F?CCBA977CCBABDiCCBBFE?CCBC2B?CCBCE3?CCBE61&CCC5E55CCC760&" +
+        "CCC95D&CCCC81?CCCCCCbCCCE1E$CCD083<CCD281&CCD3422CCD5392CCD73C?CCD843uCCD8C12CCD9ACACCDA20uCCDAB5mCCDB932CCDBA77CCE17FBCCE194BCCE1D50CCE686]" +
+        "CCE9FA]CCEB5EuCCED4D2CCEF482CCF411;CCF735%CCF826]CCF9E4ACCF9E8]CCF9F0]CCFA00DCCFA66?CCFAF1\\CCFB65NCCFE3C]CCFF90?D0034B&D003DF]D003EBmD00477?" +
+        "D004B0]D005E4?D00790mD007CABD009C82D00DF7?D011E5&D012CB$D013FDDD015A6<D016B4?D0176A]D017C2#D01B49]D01BF4\\D021F9oD023DB&D02598&D02B20&D02C392" +
+        "D02DB3?D02EABmD03169]D032C33D032E7nD03311&D03745iD03761mD03972mD039FA]D03C1FAD03E07&D03E5C?D03F27sD03FAA&D0431E5D0460C5D048A1BD04D2C[D04D86&" +
+        "D04DC6<D04E99?D04F7E&D05099\"D05162eD05509ND056F20D056FB]D0574C2D0577BAD0577EAD05794\\D058A5&D059E4]D05FB8mD06158?D06544&D06578AD065CA?D0667B]" +
+        "D06726<D067E55D069C1?D06B78&D06DC8?D06DC9\\D06EDE\\D06F82?D072DC2D07380?D076E7iD07AB5?D07D33?D07E01?D07E28<D07E35AD07FA0]D0817A&D081C5BD082E8n" +
+        "D082EBnD084B0\\D085432D087E2]D0880C&D08CB5mD08E795D09282&D0929EHD094665D094CF?D09AAF7D09C7AuD0A5A62D0A637&D0ABD5AD0AD08<D0AE05uD0B128]D0B324&" +
+        "D0B45D?D0B5C2mD0BF9C<D0C050&D0C1B1]D0C1B55D0C1BFuD0C24E]D0C2822D0C5F3&D0C637AD0C65B?D0C67F?D0C7892D0C7C0iD0CDBFDD0CEC0uD0CF0E\\D0CF137D0D003]" +
+        "D0D04B?D0D0FD2D0D23C&D0D2B0&D0D3E0<D0D49F&D0D783?D0D7BE?D0DAD7&D0DC2C2D0DD49BD0DFC7]D0E0422D0E140&D0E581&D0EA11ID0EC352D0EF767D0EFC1?D0F3F5?" +
+        "D0F4F7?D0F815?D0FCCC]D0FF50mD0FF98?D40129/D4016DiD401C3ID404E6/D404FFBD405927D4060FmD40F9E&D411A3]D4126E&D41761uD41972<D41AD1wD42122_D4223FE" +
+        "D424DD$D4258BAD427877D427FF\\D42B6F2D42C442D42C460D42CA6]D42DCC&D42FCA&D43538uD43639mD4389CeD43A2C;D43B04AD43D7EFD43DF3wD440F0?D4430E4D4438Au" +
+        "D44649?D446E1&D44867bD44A85bD44F67?D45039\\D4532AuD4548BAD45763&D45A3FBD45D64#D45EECuD45F7A?D460E3_D4612E?D4619D&D461DA&D462EA?D463C0&D466242" +
+        "D468AA&D46A352D46AA8?D46BA6?D46D502D46D6DAD46E0EiD46E5C?D47327?D47415?D476A08D477982D4789B2D47954?D47AE2]D47F352D481D75D48564<D487D8]D48866?" +
+        "D48890]D489C1oD48A39]D48AFC7D48C497D48CB52D48F33HD48FA2?D4909C&D4910F%D49400?D494A1mD494A9AD494E8?D4970BuD4996CBD49A20&D49DC0]D49FDD?D4A02A2" +
+        "D4A148?D4A254?D4A2CD5D4A33D&D4A365uD4A651nD4A923?D4AB61AD4AD712D4ADBD2D4AE05]D4AE525D4B110?D4B4C08D4B5CD\\D4BBE6?D4BED75D4BED95D4BEDC[D4C93C2" +
+        "D4C9EF<D4CA6DID4CE40&D4D252AD4D4DA7D4D51B?D4D6DFiD4D7482D4D853AD4D892?D4DA21uD4DCCD&D4E053<D4E13CGD4E22F[D4E6B7]D4E853>D4E8802D4E8B2]D4E95Em" +
+        "D4E98AAD4E9F47D4EB682D4F057ND4F0EAuD4F242?D4F32DAD4F46F&D4F513mD4F547;D4F5EF<D4F7D5eD4F829\\D4F98D7D4F9A1?D4FB8E&D4FE28bD4FF1A&D8004D&D801D0?" +
+        "D807B6iD80831]D80A60?D80B9A]D80D17iD8109F?D8132A7D8150DiD818D3BD81BB5?D81BF4mD81C79&D81D13mD81D72&D81F12nD820A2?D824BD2D82918?D829F8?D83062&" +
+        "D83134[D831CF]D83214kD832E3uD833B7\\D83ADDWD83BBFAD83BDA7D84008?D843AEFD84489iD846CE&D84732iD847BB?D8490B?D84C90&D850E6#D852FAmD8539ABD853BCE" +
+        "D85437?D8543AmD854F2?D85575]D857EF]D85982?D85B2A]D85D4CiD85ED3:D862CA2D86375uD867D3?D867D92D86852?D868A0]D868C3]D86B83ND86BF7ND86C63;D86CE9\\" +
+        "D86D17?D8714DmD87154]D87157ED87475&D874DF?D876AE?D878F0bD87A3BbD87D7F\\D880DC?D885AC7D88863?D88ADC?D88C79;D88F76&D890E8]D8912AwD89333?D893D4u" +
+        "D89403<D89424GD8952FmD89695&D89B3B?D89D13]D89D67<D89E3F&D89E61?D89EF35D8A01D7D8A25E&D8A35C]D8A491?D8A756\\D8A98BmD8B053uD8B122BD8B1902D8B1DE<" +
+        "D8B249?D8B370oD8B673mD8BB2C&D8BBC1FD8BC387D8BE1F&D8BE65%D8BF42AD8BFC07D8C262oD8C4E9]D8C771?D8C7C8<D8C80CnD8C960?D8CB8AFD8CC98?D8CE3AuD8CF61\\" +
+        "D8CF9C&D8D0905D8D1CB&D8D385<D8D43CeD8D668nD8D775\\D8DAF1?D8DC40&D8DDFDmD8DE3A&D8E0E1]D8E2DFHD8E374uD8E593&D8EB46;D8ECE5wD8EDA8uD8EF42?D8F02Fm" +
+        "D8F12EiD8F15B7D8F2CAAD8F883AD8FB58GD8FBD6%D8FC92nD8FC93AD8FEE33DC0077iDC05392DC06757DC07F8>DC080F&DC094C?DC0A697DC0B092DC0B34DDC0C5C&DC1057&" +
+        "DC121D?DC15C8$DC16B2?DC1B48mDC1BA1ADC1ED57DC2148ADC215CADC21E2?DC2727?DC2B2A&DC2B61&DC2C6EIDC2D3C?DC333D?DC3714&DC38E1BDC396F$DC39792DC3A5E[" +
+        "DC415F&DC41A9ADC42C8?DC44B6]DC4546ADC45B8&DC4628ADC4A3E<DC4F227DC5285&DC5360ADC5392&DC54757DC54D7%DC55B17DC56E7&DC5D892DC6180?DC621F?DC6279i" +
+        "DC6672]DC680C<DC68EBNDC69E2]DC6AE7uDC6B1B?DC6DBC&DC7196ADC71D0&DC729B?DC7385?DC73FCGDC74A8]DC774C2DC7794?DC7B942DC7E1D?DC7EF5?DC8084&DC868D?" +
+        "DC86D8&DC87F8]DC8983]DC8B28ADC8C372DC8E6D?DC8E95bDC9009ADC9088?DC9166?DC91BF%DC9272\\DC9396&DC9566&DC97BAADC97E6\\DC9840HDC9914?DC9B9C&DC9C99?" +
+        "DC9DED]DC9E8F&DC9FDBoDCA0D0%DCA4CA&DCA5F42DCA632WDCA782?DCA904&DCA971ADCB4D97DCB54F&DCB72EuDCB7AC<DCB87D<DCBE04mDCC49C]DCC64B?DCCCE6]DCCD18N" +
+        "DCCEC12DCCF96]DCD26A>DCD2FC?DCD2FD?DCD3A2&DCD444?DCD7A0?DCD83B2DCD916?DCDA0C7DCDB27?DCDCE2]DCDEE3mDCE541&DCE55B;DCEAE73DCEB942DCED83uDCEE06?" +
+        "DCEF09MDCEF80?DCF31CmDCF4015DCF4CA&DCF7192DCF756]DCFB020DCFB48ADCFE18iE00084?E0036B]E005C5iE00630?E0071B<E00855$E00C7FNE00CE5?E00DEE?E00EDA2" +
+        "E0150B?E0191D?E01ADF;E01CFC3E01F6A?E01F88uE023FF8E0247F?E02481?E02611&E0280AiE02861?E0286D$E02A662E02B96&E02BE9AE02CB2EE02E0BAE02E3F?E02EFE4" +
+        "E02F6D2E030F9BE0338E&E03676?E03AAAAE03E44/E03F49#E04007?E04027?E0469AME046EEME04BA6?E04E5D?E04F95\\E0508B4E05A1B7E05F45&E05FB92E06066_E06234m" +
+        "E06267uE063DAoE063E5eE06678&E069BA2E06CC5?E06D17&E070EA<E07256AE07291bE072A17E073E7<E07726?E0798DbE07DEAmE0806BuE0897E&E0899D2E08C3C2E08CFE7" +
+        "E08F4CAE091F5ME0925C&E0928FmE09467AE095B3mE09796?E098067E09971]E09D13]E09D31AE09D73GE0A3AC?E0AA96]E0ABC3GE0ACCB&E0ACF12E0AD9B?E0AEA2?E0B52D&" +
+        "E0B55F&E0B655uE0B9BA&E0BA78&E0BAAD>E0BDA0&E0BF0BbE0BFB2&E0C250ME0C264AE0C377]E0C3EA&E0C767&E0C79DmE0C932AE0C97A&E0CA3C>E0CB1D%E0CB4E#E0CBEE]" +
+        "E0CC7A?E0CCF8uE0CDB8?E0D045AE0D083]E0D1732E0D362iE0D462?E0D464AE0D4912E0D4E8AE0D55DAE0D55E:E0D7BAmE0D8485E0DA90?E0DB10]E0DB555E0DCFFuE0DEF2m" +
+        "E0DF13>E0E0FC?E0E258AE0E2E67E0E37C?E0E5CFmE0E751NE0EB40&E0EFBFNE0F330?E0F442?E0F5C6&E0F62DBE0F6B5NE0F728%E0F847&E0FFF1mE4013B&E4029BAE406BFb" +
+        "E406E0?E4072B?E407B4?E40A16?E40A75bE40D36AE40EEE?E41088]E4115B<E4121D]E4135C2E415F6mE4186BwE419C1?E41B43uE41D2DGE41F7B2E41FD5AE4233CBE4246C4" +
+        "E425E7&E4268B?E42AACHE42B34&E42F37&E432CB]E43493?E435C8?E4379F2E4387E2E43883oE43A852E43D1A/E43EC6?E440E2]E442A6AE4434B5E44389&E443CFiE44519u" +
+        "E446DAuE44AE0AE44E2D2E450EB&E4521EmE45341&E454E5?E454E85E456ACbE458B8]E458E7]E45D37BE45D39mE45D75]E45E1B;E45E37AE45ECCBE45F01WE46017AE462C42" +
+        "E465B87E468A3?E46DABGE46F133E47010&E470B8AE472E2?E47319?E47684&E47727?E4793FBE47CF9]E47DBD]E47E66?E48210?E48326?E484D3uE489CA2E48B7F&E48D8CI" +
+        "E48EC5?E48F1D?E4902A?E490FD&E49282]E492FB]E498D6&E4995F?E49A79&E49ADC&E49C67&E49F7D]E4A41C2E4A430]E4A471AE4A7A0AE4A7C5?E4A7D0?E4A8B6?E4AA5D2" +
+        "E4AAE4uE4AEE4nE4B021]E4B0637E4B107?E4B16C&E4B224?E4B2FB&E4B318AE4B3237E4B555?E4B97A5E4BCAAuE4BEFB?E4C0E2\\E4C0FE&E4C2D1?E4C32AiE4C63D&E4C7222" +
+        "E4C767AE4CE8F&E4D332iE4D373?E4D398uE4D3F12E4D58B>E4DB6DuE4DC43?E4DCCC?E4DE40<E4E0A6&E4E0C5]E4E112mE4E4AB&E4E749<E4ECE8]E4F0045E4F042;E4F27CB" +
+        "E4F3C4]E4F4C6ME4F89CAE4F8EF]E4FA5BmE4FAC4iE4FADEHE4FAED]E4FAFDAE4FB1EHE4FB5D?E4FC82BE4FD45AE4FDA1?E4FE43uE4FED4HE8039A]E8040B&E804622E80688&" +
+        "E806907E8088B?E80AB92E81098<E81132]E8136E?E81B69_E81CA5<E81CBA8E81CD8&E81E92?E824A6BE82689<E82725-E8288D?E82AEAAE82BC5?E831CD7E83617&E8377Aw" +
+        "E83935<E83A12]E83DC17E83F67?E840402E843B6UE848B8iE84A54uE84A78&E84C4A%E84D74?E84DD0?E84E84]E84EBA;E84ECENE84FA7?E85497]E85A8BuE85C0A2E85F02&" +
+        "E85FB4uE862BEAE865492E8655F5E865D4kE86819?E868E77E86BEA7E86DCB]E86DE9?E86E3AeE87865&E879A32E87E1C?E87EEFuE87F6B]E87F95&E8802E&E880E7?E88152&" +
+        "E884A5AE884C6?E8854B&E88843uE88D28&E892A4DE89309]E894F6iE89847uE898EE&E89C25#E89E49GE89F6D7E8A0CDNE8A0ED>E8A245BE8A34E?E8A55ABE8A660?E8A6CA?" +
+        "E8A72FHE8A730&E8AACB]E8ABF3?E8AC23?E8ADA6\\E8B0C5AE8B1FCAE8B2655E8B2AC&E8B4C8]E8B5D05E8B6C2BE8B7482E8BA17uE8BA702E8BCE42E8BDD1?E8BE81\\E8BFB8A" +
+        "E8BFE1AE8C1D7QE8C386&E8C3C5?E8C829AE8C913]E8CC183E8CD2D?E8CF835E8D2FF\\E8D3222E8D52B;E8D765?E8D775?E8D87E%E8D8D1<E8DA20NE8DA3E?E8DB847E8DC6C2" +
+        "E8DE27iE8DF70$E8E07EbE8E5D6]E8EA34?E8EA4D?E8EB11mE8EB342E8EBD3GE8EDD68E8EDF32E8F085?E8F1B0\\E8F408AE8F60A7E8F654?E8F673HE8F724<E8F72F?E8F791u" +
+        "E8F9D4?E8FA23?E8FBE9&E8FCAFME8FD35?E8FDF87E8FEBE&E8FF98?E8FFF4&EC01D52EC0273<EC086BiEC09C9mEC0D51&EC0D9AGEC0DE4%EC1055uEC107B]EC1127mEC13DBB" +
+        "EC172FiEC192E2EC1A02?EC1B5F<EC1BBDbEC1D53?EC1D8B2EC22803EC233D?EC24B8mEC2651&EC26CAiEC28D3&EC2A725EC2BEB%EC2C0D&EC2C73&EC2CE2&EC30912EC30B3u" +
+        "EC315F%EC33CC<EC3586&EC3873BEC388F?EC3A52?EC3CBB?EC3EB3wEC3EF7BEC4118uEC42CC&EC43F6wEC44762EC4654&EC4684HEC4C8CAEC4D3EuEC4D47?EC50A6\\EC50AA<" +
+        "EC536F?EC551C?EC5623?EC59E7HEC5A310EC5AA3?EC6073iEC62607EC63D7AEC64C97EC6794<EC71DBYEC7379&EC748CeEC750CiEC753E?EC7C2C?EC7C5CBEC7CB6]EC7CBA<" +
+        "EC8150&EC8152?EC819C?EC8350HEC852F&EC86C4GEC888FiEC8914?EC89F5EEC8AC4%EC8C9A?EC8E77AEC8EB5<EC90C1]EC94CB7EC94D5BEC97A2&EC9A34mEC9A74<EC9B75[" +
+        "EC9B8B<ECA138%ECA1CC2ECA1D1?ECA62F?ECA78D2ECA907&ECA971>ECAA25]ECAA8F?ECADB8&ECADE03ECB157\\ECB1D7<ECB293<ECB550]ECB5FAaECB878?ECB931iECB9A5?" +
+        "ECBB782ECBD1D2ECBEDD\\ECBFD0mECC0182ECC01B?ECC40DNECC5D2?ECC8822ECC89C>ECC9FF7ECCB30?ECCE132ECCED7&ECD09FuECD508&ECD61B7ECDA3B7ECDCAA&ECDD242" +
+        "ECE09B]ECE1A92ECE3347ECE61D?ECE7A7AECE9D2&ECE9F5?ECEBB8<ECED04AECF33CAECF40C2ECF4BB5ECF64CbECF8D0?ECFA5CuECFABC7ECFC2F\\ECFCC6<ECFF3A&F003BC2" +
+        "F004E1&F0051B]F008D17F008F1]F0090DiF00FEC?F010A5mF0161D7F01628jF01898&F01AA0<F01C13DF01C2DBF01D2D2F01DBCHF01FAF5F01FC7&F020FFAF02475&F024F97" +
+        "F025722F0258E?F0272D%F027A0&F029292F02F4B&F02F74#F02F9E%F02FA7?F02FBA&F033E5?F037CF?F03965]F03F95?F040D9&F0421CAF042F5?F04347?F044D3bF045DAm" +
+        "F04A022F04B3ABF04DA25F04DD4\\F04EA4<F04F7C%F05501?F057A6AF05A09]F05B7B]F05C0E?F05C19<F05C77;F05CD5&F05D88?F05ECDmF061C0<F063F9?F065AE]F067B1]" +
+        "F06BCA]F06C5DuF06E0BHF0704F]F0728C]F072EA;F074BFbF0766F&F077C3AF07807&F078162F07959#F07960&F07B65\\F07CC7BF07D683F07F062F08173%F08175\\F08261\\" +
+        "F082C0bF08756wF08A76]F0921C<F09838?F0989D&F099B6&F099BF&F09BB8?F09E4AAF09E632F09E9E7F09FC2oF0A0B1?F0A225%F0A35A&F0A4EA?F0A731iF0A951?F0B014$" +
+        "F0B0E7&F0B13F?F0B163mF0B2B9AF0B2E52F0B3EC&F0B429uF0B479&F0B4D23F0B5D1mF0B61EAF0BC50GF0BDEE?F0BF97eF0C1F1&F0C371&F0C42F?F0C478?F0C725&F0C77Fm" +
+        "F0C850?F0C88BsF0C8B5?F0CBA1&F0CD31]F0D018<F0D1A9&F0D2F1%F0D31F&F0D32BBF0D415AF0D4E25F0D5BFAF0D635&F0D793&F0D7EE?F0D8052F0DBE2&F0DBF8&F0DCE2&" +
+        "F0E426]F0E4A2?F0E77E]F0EE10]F0EE7A&F0EF86;F0F0A4%F0F336iF0F564]F0F5BD7F0F61C&F0F6C1dF0F7552F0F7E7?F0F7FC?F0F832;F0F84A0F0F8F2mF0FABAKF0FAC7?" +
+        "F0FB7FGF0FD45bF0FE6B7F0FEE7?F400A2]F401CCbF402705F40304;F4032A%F40343<F40595\\F40616&F4063CmF40669AF406A3?F40E01&F40E22]F40F1B2F40F24&F412FA7" +
+        "F41A9CuF41BA1&F41D6B?F41E57IF41FC22F4204DGF421CA&F4248B?F425FE?F42679AF42A7DiF42B8C]F42DC97F42E7F<F4308BuF430B9<F431C3&F433922F433B7&F434F0&" +
+        "F437B7&F438C1?F43909<F439A6&F43BD8AF4419E?F4428F]F44588?F44637AF44C7F?F44D5CwF44E052F44EE3AF45214GF45293&F453E4?F4559C?F45B29?F45BB42F45C42?" +
+        "F45C89&F45EABmF46077mF460E2uF462DC?F4631F?F46412eF464B6_F4650B7F465A6&F46802?F46AD7HF46BEF\\F46D04#F46D2FiF46D3FAF47190]F474702F478AC&F47946?" +
+        "F47960?F47B09AF47B5E]F47D8F;F47DEF]F47F352F481C4&F483CDiF4844CmF4848DiF487C5?F48918?F489742F48B32uF48C50AF48CEB3F48E385F48E92?F492BFoF49634A" +
+        "F49AB1<F49F54]F49FF3?F4A157?F4A1A6&F4A310&F4A475AF4A4D6?F4A59D?F4A739BF4ACC12F4AFE7&F4B1C24F4B24E?F4B301AF4B3B1bF4B52FBF4B599&F4B78D?F4B8212" +
+        "F4B85EmF4B898mF4BD9E2F4BEEC&F4BF80?F4BFA8BF4C248]F4C714?F4C88AAF4CB52?F4CBE7&F4CC55BF4CE23AF4CE36OF4CE46<F4CFA27F4CFE22F4D108AF4D488&F4D9FB]" +
+        "F4DBE3&F4DBE62F4DCF9?F4DD06]F4DEAF?F4E11EmF4E1FC<F4E2C6oF4E3FB?F4E451?F4E5F2?F4E8C7&F4EA672F4EB38\\F4EC38iF4EE085F4EE312F4F15A&F4F26DiF4F28A?" +
+        "F4F309]F4F50BiF4F5D8;F4F5DBuF4F5E8;F4F951&F4FBB8?F4FC32mF4FE3E&F4FEFB]F800A1?F80113?F801B4DF80377&F8075D?F8084F\\F80BCB2F80CF3DF80DA9wF80DAC<" +
+        "F80F6F2F80FF9;F81093&F814DD2F81654AF8172DnF81A2B;F81A67iF81E49&F81EDF&F820A9?F823B2?F82793&F828C9?F8293A?F82AE2&F82B7F?F82D7C&F82E0CmF82E3F?" +
+        "F82F65?F83002mF832E4#F83331mF83441AF8369BmF83869DF83880&F839182F83B7E?F83DFF?F83E95?F83F51]F84288&F843EE7F843EFuF84477bF8461CeF84ABF?F84CDA?" +
+        "F84D89&F84DFC>F84E17eF84E58]F84E73&F84F572F85329?F854B8%F85548mF85971AF85B1B7F85B6E]F85C24dF85EA0AF860F0<F86214&F8633FAF8665A&F866F22F868FF2" +
+        "F86BD92F86EEE?F86FB0iF86FC1&F8710CuF871A6&F872EA2F87394MF873DF&F87588?F877B8]F87907?F87A412F87B202F87D3F?F87D76&F88306uF884F2]F887F1&F88A5Em" +
+        "F88C21iF88F07]F88FCA;F8916FmF894970F894C2AF89522?F895C7DF895EA&F89753?F898B9?F898EF?F89A25?F89A78?F89E94AF8A45FuF8A5C52F8A73A2F8A986HF8A9D0D" +
+        "F8AB05\\F8AB82uF8AC65AF8AF05?F8B132?F8B1565F8B1DD&F8B22C[F8B3B77F8B46A<F8B54DAF8B7E22F8BC125F8BF09?F8C001BF8C116BF8C2882F8C362bF8C39E?F8C3CC&" +
+        "F8C6502F8C903iF8CAB85F8CB15&F8CE21iF8CF52AF8D0ACeF8D0BD]F8D111iF8D3F0&F8DB885F8DE73?F8E252&F8E4E3AF8E57E2F8E5CE&F8E61A]F8E811?F8E9033F8E94E&" +
+        "F8E94F2F8EDFC<F8F1E6]F8F21EAF8F58C&F8F7B9?F8FB90mF8FCE1%F8FE5EAF8FFC2&FC012C7FC0296uFC039F]FC0736?FC0F4BmFC0F76%FC0FE6eFC1119bFC1193?FC122C?" +
+        "FC15B4<FC1803?FC183C&FC1910]FC1999uFC1A46]FC1BD1?FC1D3A?FC1D43&FC21202FC22F4wFC253F&FC2640?FC268CaFC2A9C&FC315D&FC3342BFC3497#FC3CD7nFC3F7C?" +
+        "FC3FDB<FC4116;FC4203]FC4345uFC4482AFC45C3mFC47D8&FC48EF?FC492D%FC4CEA5FC4CEF?FC4D6AbFC4DA6?FC4E6D?FC4EA4&FC50D6?FC51B5?FC5557&FC5708/FC589A2" +
+        "FC5B392FC5B8CuFC5F494FC643A]FC64BAuFC65B3?FC65DE%FC6637\\FC66CF&FC671FnFC6947mFC6A1CGFC6D77AFC6E83]FC72882FC73FB?FC75163FC7774AFC79DD?FC7FF1<" +
+        "FC862A?FC8743?FC8827&FC8C11HFC8F90]FC915D;FC931D?FC936B]FC9435?FC9643BFC99472FC9C98)FC9CA7&FC9D05FFC9DD2&FC9E53AFC9F2AwFC9FFD>FCA0F3?FCA13E]" +
+        "FCA183%FCA27E?FCA5C8&FCA621]FCA667%FCA89BmFCA9F5uFCAA14:FCAA81&FCAAB6]FCAB90?FCB214&FCB3AAAFCB3BCAFCB4677FCB69D4FCB6D8&FCBCD1?FCC17DuFCC233#" +
+        "FCC734]FCC766?FCCA40eFCD733iFCD749%FCD848&FCD908uFCDE90]FCDEC5mFCE1A6?FCE26C&FCE33C?FCE5F0]FCE8C07FCE998&FCE9D8%FCEB7B?FCECDAoFCEFD7?FCF136]" +
+        "FCF152eFCF528wFCF5C47FCF738?FCF77B?FCF8AEAFCFBFB2FCFC48&";
+}

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

@@ -1,6 +1,9 @@
 using System.Net;
 using System.Net;
 using System.Net.NetworkInformation;
 using System.Net.NetworkInformation;
+using System.Net.Security;
 using System.Net.Sockets;
 using System.Net.Sockets;
+using System.Security.Cryptography.X509Certificates;
+using System.Text;
 using RackPeek.Domain.Resources.Services.Networking;
 using RackPeek.Domain.Resources.Services.Networking;
 
 
 namespace RackPeek.Domain.Discovery;
 namespace RackPeek.Domain.Discovery;
@@ -83,6 +86,135 @@ public sealed class NetworkProbe : INetworkProbe {
         }
         }
     }
     }
 
 
+    public async Task<string?> ReadTlsSubjectAsync(
+        string ip,
+        int port,
+        TimeSpan timeout,
+        CancellationToken cancellationToken = default) {
+        try {
+            using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+            cts.CancelAfter(timeout);
+
+            using var client = new TcpClient();
+            await client.ConnectAsync(IPAddress.Parse(ip), port, cts.Token);
+
+            // Every certificate is accepted: homelab gear is self-signed by default and
+            // the certificate is being read for its name, never trusted for security.
+            // The callback goes in the options only — setting it in the constructor too
+            // makes AuthenticateAsClientAsync throw.
+            await using var ssl = new SslStream(client.GetStream(), false);
+
+            await ssl.AuthenticateAsClientAsync(
+                new SslClientAuthenticationOptions {
+                    TargetHost = ip,
+                    RemoteCertificateValidationCallback = (_, _, _, _) => true
+                },
+                cts.Token);
+
+            return ssl.RemoteCertificate is { } certificate
+                ? new X509Certificate2(certificate).Subject
+                : null;
+        }
+        catch {
+            // Closed, plaintext, or a handshake this runtime will not do — all "no name".
+            return null;
+        }
+    }
+
+    public async Task<string?> ReadTcpBannerAsync(
+        string ip,
+        int port,
+        TimeSpan timeout,
+        CancellationToken cancellationToken = default) {
+        try {
+            using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+            cts.CancelAfter(timeout);
+
+            using var client = new TcpClient();
+            await client.ConnectAsync(IPAddress.Parse(ip), port, cts.Token);
+
+            await using NetworkStream stream = client.GetStream();
+
+            var buffer = new byte[256];
+            var read = await stream.ReadAsync(buffer, cts.Token);
+
+            return read > 0
+                ? Encoding.ASCII.GetString(buffer, 0, read).Trim()
+                : null;
+        }
+        catch {
+            return null;
+        }
+    }
+
+    public async Task<string?> ReadHttpHeadAsync(
+        string ip,
+        int port,
+        bool tls,
+        TimeSpan timeout,
+        CancellationToken cancellationToken = default) {
+        try {
+            using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+            cts.CancelAfter(timeout);
+
+            using var client = new TcpClient();
+            await client.ConnectAsync(IPAddress.Parse(ip), port, cts.Token);
+
+            Stream stream = client.GetStream();
+            SslStream? ssl = null;
+
+            if (tls) {
+                ssl = new SslStream(stream, false);
+
+                await ssl.AuthenticateAsClientAsync(
+                    new SslClientAuthenticationOptions {
+                        TargetHost = ip,
+                        RemoteCertificateValidationCallback = (_, _, _, _) => true
+                    },
+                    cts.Token);
+
+                stream = ssl;
+            }
+
+            try {
+                // HTTP/1.0 so the server closes the connection itself rather than leaving
+                // the read waiting on a keep-alive timeout.
+                var request = Encoding.ASCII.GetBytes(
+                    $"GET / HTTP/1.0\r\nHost: {ip}\r\nUser-Agent: rackpeek-discover\r\nAccept: */*\r\nConnection: close\r\n\r\n");
+
+                await stream.WriteAsync(request, cts.Token);
+                await stream.FlushAsync(cts.Token);
+
+                // Enough for the headers and a <title> near the top of the body. Capped so
+                // a host streaming megabytes cannot hold the sweep open.
+                var buffer = new byte[8192];
+                var total = 0;
+
+                while (total < buffer.Length) {
+                    var read = await stream.ReadAsync(buffer.AsMemory(total), cts.Token);
+
+                    if (read == 0)
+                        break;
+
+                    total += read;
+                }
+
+                return total > 0
+                    ? Encoding.UTF8.GetString(buffer, 0, total)
+                    : null;
+            }
+            finally {
+                if (ssl != null)
+                    await ssl.DisposeAsync();
+                else
+                    await stream.DisposeAsync();
+            }
+        }
+        catch {
+            return null;
+        }
+    }
+
     public Cidr? LocalSubnet() {
     public Cidr? LocalSubnet() {
         try {
         try {
             foreach (NetworkInterface nic in NetworkInterface.GetAllNetworkInterfaces()) {
             foreach (NetworkInterface nic in NetworkInterface.GetAllNetworkInterfaces()) {

+ 38 - 1
RackPeek.Domain/Discovery/NetworkScanFacts.cs

@@ -12,7 +12,27 @@ public sealed record NetworkHostFact(
     string? Mac,
     string? Mac,
     string? Hostname,
     string? Hostname,
     bool AnsweredPing,
     bool AnsweredPing,
-    IReadOnlyList<int> OpenPorts);
+    IReadOnlyList<int> OpenPorts) {
+    /// <summary>
+    ///     A name a service volunteered — a TLS certificate's CN, an SSH greeting, an
+    ///     HTTP title. Null when nothing answered or nothing said anything useful. Never
+    ///     overrides <see cref="Hostname" />: a PTR record is the network's own answer.
+    /// </summary>
+    public ServiceIdentity? Identity { get; init; }
+
+    /// <summary>
+    ///     Applications that named themselves over HTTP, one per port. These describe
+    ///     what the host runs rather than what it is, so they become Service resources
+    ///     rather than deciding the host's name.
+    /// </summary>
+    public IReadOnlyList<ServiceIdentity> Services { get; init; } = [];
+
+    /// <summary>
+    ///     The organisation IEEE assigned <see cref="Mac" />'s OUI to, or a note that the
+    ///     address is self-assigned. Null when there is no MAC, or none is known for it.
+    /// </summary>
+    public string? Vendor { get; init; }
+}
 
 
 /// <summary>How to sweep. The defaults suit a quiet home /24.</summary>
 /// <summary>How to sweep. The defaults suit a quiet home /24.</summary>
 public sealed record NetworkScanOptions {
 public sealed record NetworkScanOptions {
@@ -31,4 +51,21 @@ public sealed record NetworkScanOptions {
 
 
     /// <summary>How many hosts are probed at once.</summary>
     /// <summary>How many hosts are probed at once.</summary>
     public int Concurrency { get; init; } = 128;
     public int Concurrency { get; init; } = 128;
+
+    /// <summary>
+    ///     Whether living hosts are asked what they are — a TLS certificate CN, an SSH
+    ///     greeting, an HTTP title. Costs a handful of short connections per host and is
+    ///     the difference between "host-1a2b3c4d" and "pve-node-01". Off makes the sweep a
+    ///     pure liveness check again.
+    /// </summary>
+    public bool IdentifyServices { get; init; } = true;
+
+    /// <summary>Cap on each identity probe; these run per living host, not per address.</summary>
+    public TimeSpan IdentifyTimeout { get; init; } = TimeSpan.FromMilliseconds(1200);
+
+    /// <summary>
+    ///     Ports a living host is checked against, over and above <see cref="Ports" />.
+    ///     Wider because it is paid per living host rather than per address.
+    /// </summary>
+    public IReadOnlyList<int> IdentityPorts { get; init; } = WellKnownPorts.Identity;
 }
 }

+ 90 - 1
RackPeek.Domain/Discovery/NetworkScanMapper.cs

@@ -1,4 +1,5 @@
 using RackPeek.Domain.Resources;
 using RackPeek.Domain.Resources;
+using RackPeek.Domain.Resources.Services;
 using RackPeek.Domain.Resources.Services.Networking;
 using RackPeek.Domain.Resources.Services.Networking;
 using RackPeek.Domain.Resources.SystemResources;
 using RackPeek.Domain.Resources.SystemResources;
 
 
@@ -18,11 +19,18 @@ public static class NetworkScanMapper {
                 DiscoveryId.NetworkScheme,
                 DiscoveryId.NetworkScheme,
                 host.Mac ?? $"ip:{host.Ip}");
                 host.Mac ?? $"ip:{host.Ip}");
 
 
+            // A PTR record is the network's own answer and wins. Failing that, whatever a
+            // service volunteered beats a hash of the MAC — "pve-node-01" over "host-1a2b3c4d".
+            var label = DiscoveryNaming.HostLabel(host.Hostname);
+
+            if (string.IsNullOrEmpty(label) && NamesAHost(host.Identity))
+                label = DiscoveryNaming.HostLabel(host.Identity!.Name);
+
             var system = new SystemResource {
             var system = new SystemResource {
                 Kind = SystemResource.KindLabel,
                 Kind = SystemResource.KindLabel,
                 Name = DiscoveryNaming.Unique(
                 Name = DiscoveryNaming.Unique(
                     DiscoveryNaming.Suggest(
                     DiscoveryNaming.Suggest(
-                        DiscoveryNaming.HostLabel(host.Hostname),
+                        label,
                         "host",
                         "host",
                         discoveryId),
                         discoveryId),
                     discoveryId,
                     discoveryId,
@@ -37,15 +45,96 @@ public static class NetworkScanMapper {
             if (host.Mac != null)
             if (host.Mac != null)
                 system.Labels["mac"] = host.Mac;
                 system.Labels["mac"] = host.Mac;
 
 
+            // Named for what it is: the organisation that owns the NIC's OUI, which is
+            // not the same claim as who made the machine — a Proxmox guest's NIC says
+            // Proxmox while the box underneath it is a Dell.
+            if (host.Vendor != null)
+                system.Labels["nic-vendor"] = host.Vendor;
+
+            // Kept even when the name came from somewhere else: it records what the host
+            // actually said, which is how someone judges whether the name is trustworthy.
+            if (host.Identity != null)
+                system.Labels["identified-by"] =
+                    $"{Describe(host.Identity.Source)}:{host.Identity.Port} {host.Identity.Name}";
+
             if (allIps.Count > 1)
             if (allIps.Count > 1)
                 system.Labels["ips"] = string.Join(",", allIps);
                 system.Labels["ips"] = string.Join(",", allIps);
 
 
             resources.Add(system);
             resources.Add(system);
+
+            // Something listening on a port is a service, and a service is a resource in
+            // its own right rather than a note on the host. The host says where it is;
+            // each port says what it serves.
+            IReadOnlyDictionary<int, ServiceIdentity> identified =
+                host.Services.ToDictionary(s => s.Port);
+
+            // A port that answered a banner probe is open by definition, so the two lists
+            // agree in practice — but an identified service must not go missing if they
+            // ever disagree.
+            IEnumerable<int> ports = host.OpenPorts
+                .Concat(identified.Keys)
+                .Distinct();
+
+            foreach (var port in ports) {
+                var serviceId = DiscoveryId.Create(
+                    DiscoveryId.NetworkScheme,
+                    $"{host.Mac ?? $"ip:{host.Ip}"}:{port}");
+
+                // What the service said about itself beats what its port number implies,
+                // because a port is a convention and an answer is evidence. The exception
+                // is an appliance whose management page just says its own name back: a
+                // second card called opnsense tells no one anything, where an opnsense-https
+                // sitting on opnsense says exactly what it is.
+                var announced = identified.TryGetValue(port, out ServiceIdentity? found)
+                    ? DiscoveryNaming.HostLabel(found.Name)
+                    : string.Empty;
+
+                var serviceLabel = announced.Length > 0
+                                   && !announced.Equals(system.Name, StringComparison.OrdinalIgnoreCase)
+                    ? announced
+                    : $"{system.Name}-{WellKnownPorts.NameFor(port)}";
+
+                resources.Add(new Service {
+                    Kind = Service.KindLabel,
+                    Name = DiscoveryNaming.Unique(
+                        DiscoveryNaming.Suggest(serviceLabel, "service", serviceId),
+                        serviceId,
+                        taken),
+                    DiscoveryId = serviceId,
+                    Network = new Network {
+                        Ip = host.Ip,
+                        Port = port,
+                        Protocol = "TCP"
+                    },
+                    RunsOn = [system.Name]
+                });
+            }
         }
         }
 
 
         return resources;
         return resources;
     }
     }
 
 
+    /// <summary>
+    ///     Whether an identity is a claim about the machine rather than about something
+    ///     running on it. Only a certificate is: an X.509 common name is a host name by
+    ///     construction, which is why a Proxmox node's certificate says "pve-node-01".
+    ///     <para>
+    ///         A page title names an application — and a host may run several, so naming
+    ///         the machine after whichever answered first is arbitrary. Those become
+    ///         Services instead. An SSH greeting names only the daemon; naming from it
+    ///         produced five cards called "openssh" on a real sweep.
+    ///     </para>
+    /// </summary>
+    private static bool NamesAHost(ServiceIdentity? identity) =>
+        identity is { Source: IdentitySource.TlsCertificate };
+
+    private static string Describe(IdentitySource source) => source switch {
+        IdentitySource.TlsCertificate => "tls",
+        IdentitySource.SshBanner => "ssh",
+        IdentitySource.Http => "http",
+        _ => "dns"
+    };
+
     /// <summary>
     /// <summary>
     ///     One MAC answering on several addresses — a gateway's VIPs and aliases — is
     ///     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
     ///     still one machine, so it becomes one card: the lowest address as the card's

+ 171 - 2
RackPeek.Domain/Discovery/NetworkScanner.cs

@@ -73,11 +73,29 @@ public static class NetworkScanner {
             }
             }
         }));
         }));
 
 
+        // Interrogate the living: finish the port sweep the liveness check cut short,
+        // then ask what they are. Only living hosts are asked, so the cost is a few
+        // short connections per host rather than per address.
+        (IReadOnlyList<int> Open, ServiceIdentity? Identity, IReadOnlyList<ServiceIdentity> Services)[] interrogated =
+            options.IdentifyServices
+                ? await Task.WhenAll(alive.Select(h =>
+                    InterrogateAsync(probe, options, h.Ip, h.Open, gate, cancellationToken)))
+                : [
+                    .. alive.Select(h =>
+                        ((IReadOnlyList<int>)h.Open, (ServiceIdentity?)null, (IReadOnlyList<ServiceIdentity>)[]))
+                ];
+
         var facts = new List<NetworkHostFact>(alive.Count);
         var facts = new List<NetworkHostFact>(alive.Count);
 
 
         for (var i = 0; i < alive.Count; i++) {
         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));
+            (var ip, var ping, _) = alive[i];
+            var mac = macByIp.GetValueOrDefault(ip);
+
+            facts.Add(new NetworkHostFact(ip, mac, names[i], ping, interrogated[i].Open) {
+                Identity = interrogated[i].Identity,
+                Services = interrogated[i].Services,
+                Vendor = MacVendorLookup.Lookup(mac)
+            });
         }
         }
 
 
         return facts
         return facts
@@ -85,6 +103,157 @@ public static class NetworkScanner {
             .ToList();
             .ToList();
     }
     }
 
 
+    /// <summary>Ports that speak TLS, where a certificate may name the machine.</summary>
+    private static readonly HashSet<int> _tlsPorts = [443, 8443, 8006, 9443];
+
+    /// <summary>Ports that speak plain HTTP, where a page title may name an application.</summary>
+    private static readonly HashSet<int> _httpPorts = [80, 8080, 8000, 3000, 8123, 9000, 9090, 8096, 7860, 11434, 32400];
+
+    /// <summary>
+    ///     What to ask each open port, best evidence first. Only open ports are asked —
+    ///     the sweep has just established which those are, and a handshake with a closed
+    ///     port buys nothing but a timeout.
+    ///     <para>
+    ///         A certificate goes first because an X.509 common name is a host name by
+    ///         construction. A page title is an application's name, which is a different
+    ///         claim. An SSH greeting is last and names only the daemon.
+    ///     </para>
+    /// </summary>
+    private static IEnumerable<(int Port, IdentitySource Source, bool Tls)> Candidates(
+        IReadOnlyList<int> openPorts) {
+        foreach (var port in openPorts.Where(_tlsPorts.Contains))
+            yield return (port, IdentitySource.TlsCertificate, true);
+
+        foreach (var port in openPorts.Where(_httpPorts.Contains))
+            yield return (port, IdentitySource.Http, false);
+
+        // Something is listening but nobody curated the port: it could be either, so
+        // try both rather than assume.
+        foreach (var port in openPorts.Where(p =>
+                     p != 22 && !_tlsPorts.Contains(p) && !_httpPorts.Contains(p))) {
+            yield return (port, IdentitySource.TlsCertificate, true);
+            yield return (port, IdentitySource.Http, false);
+        }
+
+        if (openPorts.Contains(22))
+            yield return (22, IdentitySource.SshBanner, false);
+    }
+
+    /// <summary>
+    ///     Everything worth asking a host that has already proven it is alive: which of
+    ///     the probed ports are open, and what its services say they are.
+    ///     <para>
+    ///         The liveness sweep stops at the first answer on purpose — it only needs to
+    ///         know the host exists. That leaves the port list incomplete for a host that
+    ///         answered, and empty for one that answered ping, so it is finished here. The
+    ///         ports are worth having for their own sake (554 says camera, 445 says file
+    ///         server) and they are the best possible targets for the banner probes below:
+    ///         a service on a port nobody curated is exactly the one worth asking.
+    ///     </para>
+    /// </summary>
+    private static async Task<(IReadOnlyList<int> Open, ServiceIdentity? Identity, IReadOnlyList<ServiceIdentity> Services)> InterrogateAsync(
+        INetworkProbe probe,
+        NetworkScanOptions options,
+        string ip,
+        IReadOnlyList<int> knownOpen,
+        SemaphoreSlim gate,
+        CancellationToken cancellationToken) {
+        await gate.WaitAsync(cancellationToken);
+
+        try {
+            var open = new List<int>(knownOpen);
+
+            // The liveness list plus the wider identity list: the host is alive, so the
+            // extra connections are paid once for it rather than once per address.
+            foreach (var port in options.Ports.Concat(options.IdentityPorts).Distinct()) {
+                if (open.Contains(port))
+                    continue;
+
+                if (await probe.TryConnectAsync(ip, port, options.PortTimeout, cancellationToken))
+                    open.Add(port);
+            }
+
+            open.Sort();
+
+            (ServiceIdentity? identity, IReadOnlyList<ServiceIdentity> services) =
+                await IdentifyAsync(probe, options, ip, open, cancellationToken);
+
+            return (open, identity, services);
+        }
+        catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) {
+            return (knownOpen, null, []);
+        }
+        finally {
+            gate.Release();
+        }
+    }
+
+    /// <summary>
+    ///     Asks every open port what it is and sorts the answers into the two different
+    ///     claims they make.
+    ///     <para>
+    ///         A certificate common name names the machine — that is what an X.509 CN is
+    ///         for — so the first one found becomes the host's identity. A page title
+    ///         names an application, which is a fact about what the host runs rather than
+    ///         about the host, so each one becomes a Service instead. An SSH greeting
+    ///         names only the daemon and is kept as a last-resort annotation.
+    ///     </para>
+    ///     Never throws: an unidentified host is still a found host.
+    /// </summary>
+    private static async Task<(ServiceIdentity? Identity, IReadOnlyList<ServiceIdentity> Services)> IdentifyAsync(
+        INetworkProbe probe,
+        NetworkScanOptions options,
+        string ip,
+        IReadOnlyList<int> openPorts,
+        CancellationToken cancellationToken) {
+        ServiceIdentity? host = null;
+        ServiceIdentity? fallback = null;
+        var services = new List<ServiceIdentity>();
+
+        try {
+            foreach ((var port, IdentitySource source, var tls) in Candidates(openPorts)) {
+                // One application per port: a port that already answered has nothing
+                // further to say, and asking again only costs time.
+                if (services.Exists(s => s.Port == port))
+                    continue;
+
+                var name = source switch {
+                    IdentitySource.TlsCertificate => ServiceIdentityParser.ParseTlsSubject(
+                        await probe.ReadTlsSubjectAsync(ip, port, options.IdentifyTimeout, cancellationToken)),
+                    IdentitySource.SshBanner => ServiceIdentityParser.ParseSshBanner(
+                        await probe.ReadTcpBannerAsync(ip, port, options.IdentifyTimeout, cancellationToken)),
+                    _ => ServiceIdentityParser.ParseHttpIdentity(
+                        await probe.ReadHttpHeadAsync(ip, port, tls, options.IdentifyTimeout, cancellationToken))
+                };
+
+                if (name == null)
+                    continue;
+
+                var identity = new ServiceIdentity(name, source, port);
+
+                switch (source) {
+                    case IdentitySource.TlsCertificate:
+                        host ??= identity;
+                        break;
+
+                    case IdentitySource.Http:
+                        services.Add(identity);
+                        break;
+
+                    default:
+                        fallback ??= identity;
+                        break;
+                }
+            }
+        }
+        catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) {
+            // One host's probe timing out must not abandon the sweep, nor lose whatever
+            // the earlier ports already said.
+        }
+
+        return (host ?? services.FirstOrDefault() ?? fallback, services);
+    }
+
     /// <summary>One host's liveness check; null when nothing answered.</summary>
     /// <summary>One host's liveness check; null when nothing answered.</summary>
     private static async Task<(string Ip, bool Ping, List<int> Open)?> SweepHostAsync(
     private static async Task<(string Ip, bool Ping, List<int> Open)?> SweepHostAsync(
         INetworkProbe probe,
         INetworkProbe probe,

+ 128 - 0
RackPeek.Domain/Discovery/OpnsenseApiClient.cs

@@ -0,0 +1,128 @@
+using System.Net.Http.Headers;
+using System.Net.Security;
+using System.Text;
+
+namespace RackPeek.Domain.Discovery;
+
+/// <summary>Reads an OPNsense firewall's API. The IO half of firewall discovery.</summary>
+public interface IOpnsenseClient {
+    /// <summary>Where this client is pointed, for error messages.</summary>
+    string Endpoint { get; }
+
+    /// <summary>
+    ///     Every neighbour the firewall currently has an ARP entry for, across every
+    ///     subnet it routes.
+    /// </summary>
+    Task<IReadOnlyList<OpnsenseNeighbour>> GetNeighboursAsync(CancellationToken cancellationToken = default);
+}
+
+/// <summary>
+///     Talks to the OPNsense API with a key and secret, which OPNsense issues per user
+///     and sends as HTTP basic credentials. A key can be given a read-only role and
+///     revoked on its own, so it is used rather than a login.
+/// </summary>
+public sealed class OpnsenseApiClient : IOpnsenseClient, IDisposable {
+    public const string KeyEnvironmentVariable = "RPK_OPN_KEY";
+    public const string SecretEnvironmentVariable = "RPK_OPN_SECRET";
+
+    /// <summary>
+    ///     The ARP endpoint, newest spelling first. OPNsense 25.7 renamed its API actions
+    ///     from camelCase to snake_case and kept the old names only for a while — an
+    ///     integration pinned to either one breaks on half the installations out there.
+    ///     Asking for each in turn costs one extra request against older firmware and
+    ///     nothing against new.
+    /// </summary>
+    private static readonly string[] _arpPaths = [
+        "api/diagnostics/interface/get_arp",
+        "api/diagnostics/interface/getArp"
+    ];
+
+    private readonly HttpClient _httpClient;
+
+    /// <param name="allowUntrustedCertificate">
+    ///     OPNsense ships with a self-signed certificate and most installations keep it.
+    ///     Opt-in all the same.
+    /// </param>
+    public OpnsenseApiClient(
+        string host,
+        string key,
+        string secret,
+        bool allowUntrustedCertificate = false,
+        HttpClient? httpClient = null) {
+        Endpoint = Normalise(host);
+
+        _httpClient = httpClient ?? new HttpClient(Handler(allowUntrustedCertificate));
+        _httpClient.BaseAddress = new Uri(Endpoint + "/");
+        _httpClient.Timeout = TimeSpan.FromSeconds(30);
+
+        _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
+            "Basic",
+            Convert.ToBase64String(Encoding.UTF8.GetBytes($"{key}:{secret}")));
+    }
+
+    public string Endpoint { get; }
+
+    public void Dispose() => _httpClient.Dispose();
+
+    public async Task<IReadOnlyList<OpnsenseNeighbour>> GetNeighboursAsync(
+        CancellationToken cancellationToken = default) {
+        HttpRequestException? last = null;
+
+        foreach (var path in _arpPaths) {
+            try {
+                return OpnsenseResponseParser.ParseArp(await GetAsync(path, cancellationToken));
+            }
+            catch (HttpRequestException ex) when (ex.StatusCode is System.Net.HttpStatusCode.NotFound) {
+                // Wrong spelling for this firmware; try the other.
+                last = ex;
+            }
+        }
+
+        throw last ?? new HttpRequestException("The firewall has no ARP endpoint this understands.");
+    }
+
+    /// <summary>
+    ///     A bare name gets https, matching how the web UI is reached. The path is
+    ///     trimmed so callers may paste a URL straight out of the browser.
+    /// </summary>
+    public static string Normalise(string host) {
+        var trimmed = host.Trim().TrimEnd('/');
+
+        if (!trimmed.Contains("://", StringComparison.Ordinal))
+            trimmed = "https://" + trimmed;
+
+        var uri = new Uri(trimmed);
+
+        return uri.GetLeftPart(UriPartial.Authority);
+    }
+
+    private async Task<string> GetAsync(string path, CancellationToken cancellationToken) {
+        using HttpResponseMessage response = await _httpClient.GetAsync(path, cancellationToken);
+
+        // A key without the right privilege is the common setup mistake, and OPNsense
+        // answers it with a redirect to the login page rather than a 401.
+        if (response.StatusCode is System.Net.HttpStatusCode.Found
+            or System.Net.HttpStatusCode.MovedPermanently
+            or System.Net.HttpStatusCode.Unauthorized
+            or System.Net.HttpStatusCode.Forbidden)
+            throw new HttpRequestException(
+                $"The firewall refused the API key ({(int)response.StatusCode}). Check the key and secret, "
+                + "and that its user holds the Diagnostics: ARP Table privilege.",
+                null,
+                response.StatusCode);
+
+        response.EnsureSuccessStatusCode();
+
+        return await response.Content.ReadAsStringAsync(cancellationToken);
+    }
+
+    private static HttpClientHandler Handler(bool allowUntrustedCertificate) {
+        var handler = new HttpClientHandler();
+
+        if (allowUntrustedCertificate)
+            handler.ServerCertificateCustomValidationCallback =
+                (_, _, _, _) => true;
+
+        return handler;
+    }
+}

+ 105 - 0
RackPeek.Domain/Discovery/OpnsenseDiscovery.cs

@@ -0,0 +1,105 @@
+using RackPeek.Domain.Resources;
+using RackPeek.Domain.Resources.SystemResources;
+
+namespace RackPeek.Domain.Discovery;
+
+/// <summary>
+///     Turns a firewall's neighbour table into System resources.
+///     <para>
+///         The cards are seeded exactly as <see cref="NetworkScanMapper" /> seeds its
+///         own — the network scheme, keyed on the MAC — because they describe the same
+///         thing by the same evidence: a machine observed on the network rather than
+///         asked about itself. That makes the two collectors interchangeable. A host the
+///         firewall knows and a host a sweep found are one card, whichever ran first,
+///         with no special case anywhere to say so.
+///     </para>
+/// </summary>
+public static class OpnsenseDiscovery {
+    public static async Task<List<Resource>> ReadAsync(
+        IOpnsenseClient client,
+        bool includePublic = false,
+        CancellationToken cancellationToken = default) =>
+        ToResources(await client.GetNeighboursAsync(cancellationToken), includePublic);
+
+    /// <summary>
+    ///     Whether an address belongs to a network someone runs themselves: the RFC 1918
+    ///     ranges plus the carrier-grade block an ISP may hand out.
+    ///     <para>
+    ///         A firewall's WAN leg has neighbours too, and they are the ISP's equipment
+    ///         rather than anything the user owns. Recording them would also put a public
+    ///         address into a file people commit to git, which is a surprising thing for
+    ///         an inventory of a home lab to do on its own. Anyone documenting a fleet on
+    ///         public addresses can ask for them.
+    ///     </para>
+    /// </summary>
+    public static bool IsPrivate(string ip) {
+        var parts = ip.Split('.');
+
+        if (parts.Length != 4 || !int.TryParse(parts[0], out var a) || !int.TryParse(parts[1], out var b))
+            return false;
+
+        return a switch {
+            10 => true,
+            172 => b is >= 16 and <= 31,
+            192 => b == 168,
+            100 => b is >= 64 and <= 127, // carrier-grade NAT
+            _ => false
+        };
+    }
+
+    public static List<Resource> ToResources(
+        IReadOnlyList<OpnsenseNeighbour> neighbours,
+        bool includePublic = false) {
+        var taken = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
+        var resources = new List<Resource>();
+
+        foreach (OpnsenseNeighbour neighbour in neighbours
+                     .Where(n => includePublic || IsPrivate(n.Ip))
+                     .OrderBy(n => Order(n.Ip))) {
+            var discoveryId = DiscoveryId.Create(DiscoveryId.NetworkScheme, neighbour.Mac);
+
+            var system = new SystemResource {
+                Kind = SystemResource.KindLabel,
+                Name = DiscoveryNaming.Unique(
+                    DiscoveryNaming.Suggest(
+                        DiscoveryNaming.HostLabel(neighbour.Hostname),
+                        "host",
+                        discoveryId),
+                    discoveryId,
+                    taken),
+                DiscoveryId = discoveryId,
+                // Sparse for the same reason a scan's cards are: the firewall knows where
+                // a machine is and what its NIC is, never what runs on it. Writing a guess
+                // here would overwrite the real values on the next run of a collector that
+                // does know.
+                Ip = neighbour.Ip
+            };
+
+            system.Labels["mac"] = neighbour.Mac;
+
+            // Ours first so the vocabulary matches every other card, the firewall's own
+            // lookup second — it carries the whole IEEE registry, so it answers for the
+            // prefixes the curated table leaves out.
+            var vendor = MacVendorLookup.Lookup(neighbour.Mac) ?? neighbour.Manufacturer;
+
+            // The organisation that owns the NIC's OUI — not a claim about who made the
+            // machine, which is a different thing entirely.
+            if (vendor != null)
+                system.Labels["nic-vendor"] = vendor;
+
+            resources.Add(system);
+        }
+
+        return resources;
+    }
+
+    private static uint Order(string ip) {
+        try {
+            return Resources.Services.Networking.IpHelper.ToUInt32(ip);
+        }
+        catch (ArgumentException) {
+            // A malformed address still deserves a card; it just sorts last.
+            return uint.MaxValue;
+        }
+    }
+}

+ 120 - 0
RackPeek.Domain/Discovery/OpnsenseModels.cs

@@ -0,0 +1,120 @@
+using System.Text.Json;
+
+namespace RackPeek.Domain.Discovery;
+
+/// <summary>
+///     One neighbour the firewall has seen, from its ARP table.
+///     <para>
+///         This is the record a sweep cannot produce for anything off its own segment:
+///         ARP is link-local, so a host on another subnet gives a scanner an address and
+///         nothing else. The firewall routes every subnet, so its table carries the MAC
+///         for all of them — and a MAC is the identity that survives a DHCP re-lease.
+///     </para>
+/// </summary>
+public sealed record OpnsenseNeighbour(
+    string Ip,
+    string Mac,
+    string? Hostname,
+    string? Manufacturer,
+    string? Interface);
+
+public static class OpnsenseResponseParser {
+    /// <summary>
+    ///     Reads the ARP table. OPNsense answers either a flat array or, through the
+    ///     search wrapper, an object with a <c>rows</c> array; both shapes are accepted so
+    ///     the caller need not care which endpoint answered.
+    /// </summary>
+    public static List<OpnsenseNeighbour> ParseArp(string json) {
+        var neighbours = new List<OpnsenseNeighbour>();
+
+        using var document = JsonDocument.Parse(json);
+
+        JsonElement root = document.RootElement;
+
+        JsonElement rows = root.ValueKind switch {
+            JsonValueKind.Array => root,
+            JsonValueKind.Object when root.TryGetProperty("rows", out JsonElement r) => r,
+            _ => default
+        };
+
+        if (rows.ValueKind != JsonValueKind.Array)
+            return neighbours;
+
+        var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
+
+        foreach (JsonElement entry in rows.EnumerateArray()) {
+            if (entry.ValueKind != JsonValueKind.Object)
+                continue;
+
+            // An address the firewall holds itself. Every routed subnet contributes one,
+            // and they are all the same box — which is a Firewall, not the handful of
+            // Systems this would otherwise invent.
+            if (IsTrue(entry, "permanent"))
+                continue;
+
+            // The entry is still listed after it ages out; it says where something used
+            // to be, which is not evidence that it is there now.
+            if (IsTrue(entry, "expired"))
+                continue;
+
+            var mac = ArpTableParser.NormaliseMac(Text(entry, "mac"));
+            var ip = Text(entry, "ip");
+
+            if (mac == null || string.IsNullOrWhiteSpace(ip))
+                continue;
+
+            // Broadcast and multicast are not machines.
+            if (mac is "ff:ff:ff:ff:ff:ff" || IsMulticast(mac))
+                continue;
+
+            // One row per machine: a host answering on several of the firewall's
+            // interfaces is still one machine, and the first row carries its address.
+            if (!seen.Add(mac))
+                continue;
+
+            neighbours.Add(new OpnsenseNeighbour(
+                ip,
+                mac,
+                Clean(Text(entry, "hostname")),
+                Clean(Text(entry, "manufacturer")),
+                Clean(Text(entry, "intf_description")) ?? Clean(Text(entry, "intf"))));
+        }
+
+        return neighbours;
+    }
+
+    /// <summary>
+    ///     A locally administered group address — the low bit of the first octet marks
+    ///     multicast, which no host owns.
+    /// </summary>
+    private static bool IsMulticast(string mac) =>
+        Convert.ToInt32(mac[..2], 16) % 2 == 1;
+
+    private static string? Text(JsonElement element, string name) =>
+        element.TryGetProperty(name, out JsonElement value) && value.ValueKind == JsonValueKind.String
+            ? value.GetString()
+            : null;
+
+    /// <summary>
+    ///     OPNsense writes these as real booleans in some versions and as the strings
+    ///     "1"/"true" in others.
+    /// </summary>
+    private static bool IsTrue(JsonElement element, string name) {
+        if (!element.TryGetProperty(name, out JsonElement value))
+            return false;
+
+        return value.ValueKind switch {
+            JsonValueKind.True => true,
+            JsonValueKind.String => value.GetString() is "1" or "true" or "yes",
+            JsonValueKind.Number => value.TryGetInt32(out var number) && number != 0,
+            _ => false
+        };
+    }
+
+    /// <summary>Blank and placeholder values arrive as empty strings or dashes.</summary>
+    private static string? Clean(string? value) {
+        var trimmed = value?.Trim();
+
+        return string.IsNullOrEmpty(trimmed) || trimmed is "-" or "(none)" ? null : trimmed;
+    }
+}

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

@@ -1,3 +1,4 @@
+using System.Text.Json;
 using System.Net.Security;
 using System.Net.Security;
 
 
 namespace RackPeek.Domain.Discovery;
 namespace RackPeek.Domain.Discovery;
@@ -143,6 +144,35 @@ public sealed class ProxmoxApiClient : IProxmoxClient, IDisposable {
         }
         }
     }
     }
 
 
+    public async Task<IReadOnlyList<ProxmoxGuestAddress>> GetGuestAddressesAsync(
+        string node,
+        string endpoint,
+        int vmId,
+        CancellationToken cancellationToken = default) {
+        // Every failure here means the same thing: the guest cannot say where it is.
+        // No agent installed, agent not running, container stopped, guest deleted since
+        // it was listed, or a token without VM.Monitor — all leave the address unknown,
+        // which is what the guest config already told us.
+        try {
+            var path = endpoint == LxcEndpoint
+                ? $"nodes/{Uri.EscapeDataString(node)}/{endpoint}/{vmId}/interfaces"
+                : $"nodes/{Uri.EscapeDataString(node)}/{endpoint}/{vmId}/agent/network-get-interfaces";
+
+            var json = await GetAsync(path, cancellationToken);
+
+            return endpoint == LxcEndpoint
+                ? ProxmoxResponseParser.ParseContainerInterfaces(json)
+                : ProxmoxResponseParser.ParseAgentInterfaces(json);
+        }
+        catch (HttpRequestException) {
+            return [];
+        }
+        catch (JsonException) {
+            // A node that answers the agent call with an error body rather than a status.
+            return [];
+        }
+    }
+
     private async Task<string> FirstNodeAsync(CancellationToken cancellationToken) {
     private async Task<string> FirstNodeAsync(CancellationToken cancellationToken) {
         IReadOnlyList<ProxmoxNode> nodes = await GetNodesAsync(cancellationToken);
         IReadOnlyList<ProxmoxNode> nodes = await GetNodesAsync(cancellationToken);
 
 

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

@@ -0,0 +1,107 @@
+using RackPeek.Domain.Resources;
+
+namespace RackPeek.Domain.Discovery;
+
+/// <summary>
+///     Reads a whole Proxmox endpoint and maps it to resources.
+///     <para>
+///         Lives here rather than in a command so the CLI and the MCP tool run the same
+///         code. They used to hold a copy each, and the copies had already drifted — the
+///         MCP one dropped the guests' MACs, which silently cost every guest its chance
+///         of unifying with a network scan.
+///     </para>
+/// </summary>
+public static class ProxmoxDiscovery {
+    public static async Task<List<Resource>> ReadAsync(
+        IProxmoxClient client,
+        CancellationToken cancellationToken = default) {
+        var scope = await client.GetIdentityScopeAsync(cancellationToken);
+        IReadOnlyList<ProxmoxNode> listed = await client.GetNodesAsync(cancellationToken);
+
+        var nodes = new List<ProxmoxNode>();
+        var guests = new List<ProxmoxGuest>();
+
+        foreach (ProxmoxNode listedNode in listed) {
+            // Node detail needs a broader permission than listing guests does, so it is
+            // enrichment rather than a requirement — a read-only token still gets a tree.
+            ProxmoxNode node = await client.EnrichAsync(listedNode, cancellationToken);
+            nodes.Add(node);
+
+            var nodeName = node.Name;
+
+            foreach (var endpoint in new[] { ProxmoxApiClient.QemuEndpoint, ProxmoxApiClient.LxcEndpoint }) {
+                IReadOnlyList<ProxmoxGuest> listedGuests =
+                    await client.GetGuestsAsync(nodeName, endpoint, cancellationToken);
+
+                // The list call knows nothing about the OS, and for a container it does
+                // not know the address either. Both live in the guest's own config — one
+                // call per guest, so they run concurrently rather than one at a time.
+                ProxmoxGuestConfig[] configs = await Task.WhenAll(listedGuests.Select(g =>
+                    client.GetGuestConfigAsync(nodeName, endpoint, g.VmId, cancellationToken)));
+
+                // Ask the running guests where they are. The config only carries an
+                // address when someone set one statically, so on a DHCP estate this is
+                // the difference between every guest having an address and none of them
+                // having one — and an address is what lets a guest line up with the host
+                // a network sweep found at that address.
+                IReadOnlyList<ProxmoxGuestAddress>[] addresses = await Task.WhenAll(
+                    listedGuests.Select(g => IsRunning(g)
+                        ? client.GetGuestAddressesAsync(nodeName, endpoint, g.VmId, cancellationToken)
+                        : Task.FromResult<IReadOnlyList<ProxmoxGuestAddress>>([])));
+
+                for (var i = 0; i < listedGuests.Count; i++) {
+                    IReadOnlyList<string> macs = configs[i].Macs ?? [];
+
+                    guests.Add(listedGuests[i] with {
+                        Os = configs[i].Os,
+                        Ip = configs[i].Ip ?? SelectGuestIp(addresses[i], macs),
+                        Disks = configs[i].DiskBytes,
+                        PassthroughAddresses = configs[i].PassthroughAddresses,
+                        Macs = macs
+                    });
+                }
+            }
+        }
+
+        return ProxmoxResourceMapper.ToResources(scope, nodes, guests);
+    }
+
+    /// <summary>
+    ///     The address that belongs to the guest itself.
+    ///     <para>
+    ///         A guest agent reports every interface inside the machine, and a guest that
+    ///         runs containers has several: Docker's <c>docker0</c> and its per-network
+    ///         bridges, Home Assistant's <c>hassio</c>, any VPN tunnel. Recording
+    ///         172.17.0.1 as the machine's address would be worse than recording nothing,
+    ///         because every Docker host on the estate reports the same one.
+    ///     </para>
+    ///     <para>
+    ///         The NIC MACs Proxmox assigned are the discriminator: they are already read
+    ///         from the guest's config, and an interface carrying one is a NIC the
+    ///         hypervisor gave the guest rather than something the guest invented. When
+    ///         the MACs are unknown — a container, or a config the token cannot read —
+    ///         nothing is claimed, since a guess here is indistinguishable from a fact.
+    ///     </para>
+    /// </summary>
+    public static string? SelectGuestIp(
+        IReadOnlyList<ProxmoxGuestAddress> addresses,
+        IReadOnlyList<string> configuredMacs) {
+        if (addresses.Count == 0 || configuredMacs.Count == 0)
+            return null;
+
+        var allowed = new HashSet<string>(
+            configuredMacs.Select(ArpTableParser.NormaliseMac).Where(m => m != null)!,
+            StringComparer.OrdinalIgnoreCase);
+
+        if (allowed.Count == 0)
+            return null;
+
+        return addresses
+            .Where(a => ArpTableParser.NormaliseMac(a.Mac) is { } mac && allowed.Contains(mac))
+            .Select(a => a.Ip)
+            .FirstOrDefault();
+    }
+
+    private static bool IsRunning(ProxmoxGuest guest) =>
+        string.Equals(guest.Status, "running", StringComparison.OrdinalIgnoreCase);
+}

+ 135 - 8
RackPeek.Domain/Discovery/ProxmoxModels.cs

@@ -62,6 +62,12 @@ public sealed record ProxmoxGuest {
 
 
     public IReadOnlyList<string> Tags { get; init; } = [];
     public IReadOnlyList<string> Tags { get; init; } = [];
 
 
+    /// <summary>
+    ///     <c>running</c>, <c>stopped</c> and friends, from the guest list. Only a running
+    ///     guest can be asked where it is, and a stopped one has no address to report.
+    /// </summary>
+    public string? Status { get; init; }
+
     /// <summary>Filled in from the guest's config, which is the only place it is known.</summary>
     /// <summary>Filled in from the guest's config, which is the only place it is known.</summary>
     public string? Os { get; init; }
     public string? Os { get; init; }
 
 
@@ -424,6 +430,7 @@ public static class ProxmoxResponseParser {
             Cores = GetInt(element, "cpus") ?? 0,
             Cores = GetInt(element, "cpus") ?? 0,
             MemoryBytes = GetLong(element, "maxmem") ?? 0,
             MemoryBytes = GetLong(element, "maxmem") ?? 0,
             DiskBytes = GetLong(element, "maxdisk") ?? 0,
             DiskBytes = GetLong(element, "maxdisk") ?? 0,
+            Status = GetString(element, "status"),
             Tags = ParseTags(GetString(element, "tags"))
             Tags = ParseTags(GetString(element, "tags"))
         };
         };
     }
     }
@@ -455,18 +462,138 @@ public static class ProxmoxResponseParser {
             ? value.GetString()
             ? value.GetString()
             : null;
             : null;
 
 
-    private static int? GetInt(JsonElement element, string name) =>
-        element.TryGetProperty(name, out JsonElement value) && value.TryGetInt32(out var result)
-            ? result
-            : null;
+    /// <summary>
+    ///     A number Proxmox may have written as a string.
+    ///     <para>
+    ///         Its perl backend quotes numeric fields inconsistently — <c>"vmid":"100"</c>
+    ///         and <c>"maxdisk":"512110190592"</c> both turn up across versions. The
+    ///         <c>TryGetInt32</c> family does not return false for a string, it throws, so
+    ///         reading one unguarded took the whole run down with a stack trace. The kind
+    ///         is checked first and a quoted number parsed, which is what the caller meant
+    ///         either way.
+    ///     </para>
+    /// </summary>
+    private static int? GetInt(JsonElement element, string name) {
+        if (!element.TryGetProperty(name, out JsonElement value))
+            return null;
 
 
-    private static long? GetLong(JsonElement element, string name) =>
-        element.TryGetProperty(name, out JsonElement value) && value.TryGetInt64(out var result)
-            ? result
-            : null;
+        return value.ValueKind switch {
+            JsonValueKind.Number when value.TryGetInt32(out var number) => number,
+            JsonValueKind.String when int.TryParse(value.GetString(), out var parsed) => parsed,
+            _ => null
+        };
+    }
+
+    /// <inheritdoc cref="GetInt" />
+    private static long? GetLong(JsonElement element, string name) {
+        if (!element.TryGetProperty(name, out JsonElement value))
+            return null;
+
+        return value.ValueKind switch {
+            JsonValueKind.Number when value.TryGetInt64(out var number) => number,
+            JsonValueKind.String when long.TryParse(value.GetString(), out var parsed) => parsed,
+            _ => null
+        };
+    }
+
+    /// <summary>
+    ///     Addresses a QEMU guest reports through its guest agent
+    ///     (<c>agent/network-get-interfaces</c>). The agent sees every interface inside
+    ///     the guest, including the bridges Docker and Home Assistant create, so the
+    ///     caller filters by the NIC MACs Proxmox actually assigned — see
+    ///     <see cref="ProxmoxDiscovery.SelectGuestIp" />.
+    /// </summary>
+    public static List<ProxmoxGuestAddress> ParseAgentInterfaces(string json) {
+        var addresses = new List<ProxmoxGuestAddress>();
+
+        using var document = JsonDocument.Parse(json);
+
+        if (!document.RootElement.TryGetProperty("data", out JsonElement data)
+            || data.ValueKind != JsonValueKind.Object
+            || !data.TryGetProperty("result", out JsonElement result)
+            || result.ValueKind != JsonValueKind.Array)
+            return addresses;
+
+        foreach (JsonElement iface in result.EnumerateArray()) {
+            if (iface.ValueKind != JsonValueKind.Object)
+                continue;
+
+            var name = GetString(iface, "name");
+            var mac = GetString(iface, "hardware-address");
+
+            if (!iface.TryGetProperty("ip-addresses", out JsonElement ips)
+                || ips.ValueKind != JsonValueKind.Array)
+                continue;
+
+            foreach (JsonElement entry in ips.EnumerateArray()) {
+                if (entry.ValueKind != JsonValueKind.Object)
+                    continue;
+
+                if (!string.Equals(GetString(entry, "ip-address-type"), "ipv4", StringComparison.OrdinalIgnoreCase))
+                    continue;
+
+                var ip = GetString(entry, "ip-address");
+
+                if (IsUsableAddress(ip))
+                    addresses.Add(new ProxmoxGuestAddress(name, mac, ip!));
+            }
+        }
+
+        return addresses;
+    }
+
+    /// <summary>
+    ///     Addresses a container reports through <c>lxc/{vmid}/interfaces</c>, which is a
+    ///     flat list rather than the agent's nested shape and spells the address with its
+    ///     prefix (<c>10.0.0.5/24</c>).
+    /// </summary>
+    public static List<ProxmoxGuestAddress> ParseContainerInterfaces(string json) {
+        var addresses = new List<ProxmoxGuestAddress>();
+
+        using var document = JsonDocument.Parse(json);
+
+        if (!document.RootElement.TryGetProperty("data", out JsonElement data)
+            || data.ValueKind != JsonValueKind.Array)
+            return addresses;
+
+        foreach (JsonElement iface in data.EnumerateArray()) {
+            if (iface.ValueKind != JsonValueKind.Object)
+                continue;
+
+            var ip = GetString(iface, "inet");
+
+            // "10.0.0.5/24" — the prefix belongs to the interface, not to the address
+            // the inventory records.
+            var slash = ip?.IndexOf('/') ?? -1;
+
+            if (slash > 0)
+                ip = ip![..slash];
+
+            if (IsUsableAddress(ip))
+                addresses.Add(new ProxmoxGuestAddress(
+                    GetString(iface, "name"),
+                    GetString(iface, "hwaddr"),
+                    ip!));
+        }
+
+        return addresses;
+    }
+
+    /// <summary>
+    ///     Whether an address is worth recording: a real IPv4 that is neither loopback
+    ///     nor the 169.254 a guest assigns itself when DHCP fails.
+    /// </summary>
+    private static bool IsUsableAddress(string? ip) =>
+        !string.IsNullOrWhiteSpace(ip)
+        && !ip.StartsWith("127.", StringComparison.Ordinal)
+        && !ip.StartsWith("169.254.", StringComparison.Ordinal)
+        && ip.Count(c => c == '.') == 3;
 }
 }
 
 
 /// <summary>The parts of a guest's config worth recording. Everything is optional.</summary>
 /// <summary>The parts of a guest's config worth recording. Everything is optional.</summary>
+/// <summary>One address a guest reports for one of its own interfaces.</summary>
+public sealed record ProxmoxGuestAddress(string? Interface, string? Mac, string Ip);
+
 public sealed record ProxmoxGuestConfig(
 public sealed record ProxmoxGuestConfig(
     string? Os,
     string? Os,
     string? Ip,
     string? Ip,

+ 211 - 0
RackPeek.Domain/Discovery/ServiceIdentityParser.cs

@@ -0,0 +1,211 @@
+using System.Text.RegularExpressions;
+
+namespace RackPeek.Domain.Discovery;
+
+/// <summary>Where a host's name came from, best evidence first.</summary>
+public enum IdentitySource {
+    /// <summary>A PTR record — the network's own answer, so it wins.</summary>
+    ReverseDns,
+
+    /// <summary>The Common Name on the certificate a TLS port presented.</summary>
+    TlsCertificate,
+
+    /// <summary>The greeting an SSH server sends before anything is asked of it.</summary>
+    SshBanner,
+
+    /// <summary>An HTTP <c>Server</c> header or page title.</summary>
+    Http
+}
+
+/// <summary>A name a service volunteered, and what volunteered it.</summary>
+public sealed record ServiceIdentity(string Name, IdentitySource Source, int Port);
+
+/// <summary>
+///     Reads a host's name out of what its services say when you connect to them. This
+///     is the identification half of <c>nmap -sV</c>, reduced to the three banners that
+///     actually name homelab gear: a TLS certificate's CN, an SSH greeting, and an HTTP
+///     <c>Server</c> header or page title. Pure — <see cref="INetworkProbe" /> does the
+///     talking, everything here just reads what came back.
+/// </summary>
+public static class ServiceIdentityParser {
+    /// <summary>
+    ///     Names that identify software rather than a machine, or are placeholders the
+    ///     installer never changed. Keeping them would label every appliance of a kind
+    ///     with the same name, which is worse than no name at all.
+    /// </summary>
+    private static readonly HashSet<string> _uselessNames = new(StringComparer.OrdinalIgnoreCase) {
+        "localhost",
+        "localhost.localdomain",
+        "example.com",
+        "www.example.com",
+        "default",
+        "changeme",
+        "server",
+        "ubuntu",
+        "debian",
+        "raspberrypi",
+        "openwrt",
+        "*"
+    };
+
+    /// <summary>
+    ///     The Common Name from a certificate's subject. Handles both the OpenSSL-style
+    ///     "CN=host, O=org" and the .NET "CN=host, O=org" orderings, and tolerates the
+    ///     slash-separated form some tools print.
+    /// </summary>
+    public static string? ParseTlsSubject(string? subject) {
+        if (string.IsNullOrWhiteSpace(subject))
+            return null;
+
+        Match match = Regex.Match(
+            subject,
+            @"CN\s*=\s*(?<cn>[^,/]+)",
+            RegexOptions.IgnoreCase,
+            TimeSpan.FromSeconds(1));
+
+        if (!match.Success)
+            return null;
+
+        var cn = match.Groups["cn"].Value.Trim().Trim('"');
+
+        // A wildcard certificate names a domain, not this machine.
+        if (cn.StartsWith("*.", StringComparison.Ordinal))
+            return null;
+
+        return Clean(cn);
+    }
+
+    /// <summary>
+    ///     The software an SSH server announces, e.g. "SSH-2.0-OpenSSH_9.6" -> "OpenSSH".
+    ///     This names what the host runs rather than the host itself, which is still worth
+    ///     having: "dropbear" says embedded appliance, "OpenSSH" says general-purpose box.
+    /// </summary>
+    public static string? ParseSshBanner(string? banner) {
+        if (string.IsNullOrWhiteSpace(banner))
+            return null;
+
+        Match match = Regex.Match(
+            banner,
+            @"^SSH-\d+\.\d+-(?<software>[^\s\r\n]+)",
+            RegexOptions.IgnoreCase,
+            TimeSpan.FromSeconds(1));
+
+        if (!match.Success)
+            return null;
+
+        var software = match.Groups["software"].Value;
+
+        // Trim the version: "OpenSSH_9.6p1" and "OpenSSH_8.4p1" are the same answer to
+        // "what is this", and a version in a resource name goes stale on the next patch.
+        var cut = software.IndexOfAny(['_', '-']);
+
+        if (cut > 0)
+            software = software[..cut];
+
+        return Clean(software);
+    }
+
+    /// <summary>
+    ///     A name from an HTTP response head: the page title if it says something, else
+    ///     the <c>Server</c> header. Titles win because "Home Assistant" identifies a box
+    ///     far better than "nginx" does.
+    /// </summary>
+    public static string? ParseHttpIdentity(string? responseHead) {
+        if (string.IsNullOrWhiteSpace(responseHead))
+            return null;
+
+        // An error page's title describes the error, not the host — "HTTP Status 400 –
+        // Bad Request" is a name no one would recognise. The Server header below is
+        // still trustworthy on an error response, so only the title is gated.
+        if (IsSuccessful(responseHead)) {
+            Match title = Regex.Match(
+                responseHead,
+                @"<title[^>]*>(?<title>[^<]{1,120})</title>",
+                RegexOptions.IgnoreCase,
+                TimeSpan.FromSeconds(1));
+
+            if (title.Success) {
+                var cleaned = Clean(LeadingPhrase(title.Groups["title"].Value));
+
+                if (cleaned != null)
+                    return cleaned;
+            }
+        }
+
+        Match server = Regex.Match(
+            responseHead,
+            @"^Server:\s*(?<server>[^\r\n]{1,80})",
+            RegexOptions.IgnoreCase | RegexOptions.Multiline,
+            TimeSpan.FromSeconds(1));
+
+        if (!server.Success)
+            return null;
+
+        var value = server.Groups["server"].Value;
+
+        // "nginx/1.24.0 (Ubuntu)" -> "nginx": the version is noise in a name.
+        var slash = value.IndexOf('/');
+
+        if (slash > 0)
+            value = value[..slash];
+
+        return Clean(value);
+    }
+
+    /// <summary>
+    ///     The part of a page title before its first separator. Titles are written for
+    ///     people and routinely carry a tagline or a page name after the product —
+    ///     "Forgejo: Beyond coding. We Forge." names a machine far worse than "Forgejo"
+    ///     does. The remainder is dropped only when what precedes it can stand alone.
+    /// </summary>
+    private static string LeadingPhrase(string title) {
+        var cut = title.IndexOfAny([':', '|', '–', '—', '·', '»']);
+
+        if (cut <= 0)
+            return title;
+
+        var lead = title[..cut].Trim();
+
+        // "RackPeek" splits usefully; ": the homelab tool" does not, and a two-character
+        // lead is more likely a stray colon than a product name.
+        return lead.Length >= 3 ? lead : title;
+    }
+
+    /// <summary>
+    ///     Whether the response's status line is a 2xx or 3xx. A head with no recognisable
+    ///     status line is treated as unsuccessful: the title of something that is not
+    ///     plainly a working page is not worth naming a machine after.
+    /// </summary>
+    private static bool IsSuccessful(string responseHead) {
+        Match status = Regex.Match(
+            responseHead,
+            @"^HTTP/\d(?:\.\d)?\s+(?<code>\d{3})",
+            RegexOptions.IgnoreCase,
+            TimeSpan.FromSeconds(1));
+
+        return status.Success
+               && int.TryParse(status.Groups["code"].Value, out var code)
+               && code is >= 200 and < 400;
+    }
+
+    /// <summary>
+    ///     Collapses whitespace, drops anything that is only punctuation or digits, and
+    ///     rejects the placeholder names that would label half a rack identically.
+    /// </summary>
+    private static string? Clean(string? raw) {
+        if (string.IsNullOrWhiteSpace(raw))
+            return null;
+
+        var value = Regex.Replace(raw.Trim(), @"\s+", " ", RegexOptions.None, TimeSpan.FromSeconds(1));
+
+        if (value.Length is 0 or > 120)
+            return null;
+
+        // A CN that is a bare number (some appliances ship a serial as the CN) or pure
+        // punctuation names nothing a person would recognise.
+        if (!value.Any(char.IsLetter))
+            return null;
+
+        return _uselessNames.Contains(value) ? null : value;
+    }
+}

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

@@ -20,4 +20,78 @@ public static class WellKnownPorts {
         8443, // alt https
         8443, // alt https
         9100 // node-exporter / jetdirect
         9100 // node-exporter / jetdirect
     ];
     ];
+
+    /// <summary>
+    ///     The wider list a host is checked against once it has already proven it is
+    ///     alive. Liveness is paid per address, so <see cref="Defaults" /> stays short;
+    ///     this runs only on hosts that answered, where a dozen more connections cost
+    ///     nothing and buy two things — a service on one of these ports is a strong
+    ///     statement about what the machine is, and it is a target for the banner probes
+    ///     that actually name it.
+    /// </summary>
+    public static readonly IReadOnlyList<int> Identity = [
+        .. Defaults,
+        554, // rtsp — cameras
+        1883, // mqtt — home automation brokers
+        3000, // grafana / forgejo / many node apps
+        3306, // mysql
+        5432, // postgres
+        5900, // vnc
+        6379, // redis
+        7860, // gradio / stable-diffusion
+        8000, // alt http
+        8096, // jellyfin
+        8123, // home assistant
+        9000, // portainer / minio
+        9090, // prometheus / cockpit
+        11434, // ollama
+        32400 // plex
+    ];
+
+    /// <summary>
+    ///     What a port conventionally carries, for naming the service found on it. A port
+    ///     number is a convention rather than a guarantee, so this only ever supplies a
+    ///     name — anything a service actually said about itself wins over it.
+    /// </summary>
+    private static readonly Dictionary<int, string> _names = new() {
+        [21] = "ftp",
+        [22] = "ssh",
+        [23] = "telnet",
+        [25] = "smtp",
+        [53] = "dns",
+        [80] = "http",
+        [443] = "https",
+        [445] = "smb",
+        [554] = "rtsp",
+        [631] = "ipp",
+        [1883] = "mqtt",
+        [2375] = "docker",
+        [2376] = "docker",
+        [3000] = "http",
+        [3306] = "mysql",
+        [3389] = "rdp",
+        [5000] = "http",
+        [5432] = "postgres",
+        [5900] = "vnc",
+        [6379] = "redis",
+        [7860] = "http",
+        [8000] = "http",
+        [8006] = "proxmox",
+        [8080] = "http",
+        [8096] = "jellyfin",
+        [8123] = "home-assistant",
+        [8443] = "https",
+        [9000] = "http",
+        [9090] = "http",
+        [9100] = "jetdirect",
+        [11434] = "ollama",
+        [32400] = "plex"
+    };
+
+    /// <summary>
+    ///     The conventional name for a port, or <c>tcp-1234</c> when nobody curated one —
+    ///     which still says more than the bare number, and stays stable across runs.
+    /// </summary>
+    public static string NameFor(int port) =>
+        _names.TryGetValue(port, out var name) ? name : $"tcp-{port}";
 }
 }

+ 3 - 1
RackPeek.Domain/Persistence/Yaml/YamlResourceCollection.cs

@@ -161,7 +161,9 @@ public sealed class YamlResourceCollection(
             DiscoveryIdResolver.ResolveNames(
             DiscoveryIdResolver.ResolveNames(
                 resourceCollection.Resources,
                 resourceCollection.Resources,
                 incomingResources,
                 incomingResources,
-                incomingRoot.Connections);
+                incomingRoot.Connections,
+                resourceCollection.Connections,
+                true);
 
 
             List<Resource> merged = ResourceCollectionMerger.Merge(
             List<Resource> merged = ResourceCollectionMerger.Merge(
                 resourceCollection.Resources,
                 resourceCollection.Resources,

+ 7 - 0
RackPeek.Domain/RackPeek.Domain.csproj

@@ -6,6 +6,13 @@
         <Nullable>enable</Nullable>
         <Nullable>enable</Nullable>
     </PropertyGroup>
     </PropertyGroup>
 
 
+    <ItemGroup>
+        <!-- Tests.Discovery walks the generated OUI table to prove its packed index
+             encoding maps every record to a real vendor; the table itself stays
+             internal so it is not part of the domain's surface. -->
+        <InternalsVisibleTo Include="Tests.Discovery" />
+    </ItemGroup>
+
     <ItemGroup>
     <ItemGroup>
         <PackageReference Include="DocMigrator.Yaml" Version="10.0.3" />
         <PackageReference Include="DocMigrator.Yaml" Version="10.0.3" />
         <PackageReference Include="LibGit2Sharp" Version="0.32.0" />
         <PackageReference Include="LibGit2Sharp" Version="0.32.0" />

+ 28 - 0
RackPeek.Domain/Resources/Resource.cs

@@ -59,6 +59,34 @@ public abstract class Resource {
     /// </summary>
     /// </summary>
     public string? DiscoveryId { get; set; }
     public string? DiscoveryId { get; set; }
 
 
+    /// <summary>
+    ///     Whether a person chose this name. Set the moment anyone renames the resource,
+    ///     and never unset.
+    ///     <para>
+    ///         A discovered resource starts out named by whatever the collector could see,
+    ///         which is often a placeholder derived from its own id. Later runs — or a
+    ///         better collector — may learn the machine's real name, and should be able to
+    ///         improve on a placeholder. They must never touch a name a person typed.
+    ///     </para>
+    ///     <para>
+    ///         Absent means "not stated". A resource with no <see cref="DiscoveryId" /> was
+    ///         entered by hand and is therefore user-named whatever this says; see
+    ///         <see cref="IsUserNamed" />.
+    ///     </para>
+    /// </summary>
+    public bool? UserNamed { get; set; }
+
+    /// <summary>
+    ///     Whether this resource's name is a person's choice and so off limits to
+    ///     discovery. True when the flag says so, and true for anything with no
+    ///     discovery id at all — nothing but a person could have written it.
+    /// </summary>
+    /// <remarks>
+    ///     A method rather than a property because everything public on a resource is
+    ///     serialised, and this is derived from what is stored rather than part of it.
+    /// </remarks>
+    public bool IsUserNamed() => UserNamed ?? string.IsNullOrWhiteSpace(DiscoveryId);
+
     public string[] Tags { get; set; } = [];
     public string[] Tags { get; set; } = [];
     public Dictionary<string, string> Labels { get; set; } = new();
     public Dictionary<string, string> Labels { get; set; } = new();
     public string? Notes { get; set; }
     public string? Notes { get; set; }

+ 15 - 21
RackPeek.Domain/Resources/Services/Service.cs

@@ -1,30 +1,24 @@
-using System.Text;
-
 namespace RackPeek.Domain.Resources.Services;
 namespace RackPeek.Domain.Resources.Services;
 
 
 public class Service : Resource {
 public class Service : Resource {
     public const string KindLabel = "Service";
     public const string KindLabel = "Service";
     public Network? Network { get; set; }
     public Network? Network { get; set; }
 
 
-    public string NetworkString() {
-        if (Network == null) return string.Empty;
-
-        if (!string.IsNullOrEmpty(Network.Url)) return Network.Url;
-
-        var stringBuilder = new StringBuilder();
-        if (!string.IsNullOrEmpty(Network.Ip)) {
-            stringBuilder.Append("Ip: ");
-            stringBuilder.Append(Network.Ip);
-            if (Network.Port.HasValue) {
-                stringBuilder.Append(':');
-                stringBuilder.Append(Network.Port.Value);
-            }
-
-            stringBuilder.Append(' ');
-        }
-
-        return stringBuilder.ToString();
-    }
+    /// <summary>
+    ///     Where this service answers, for showing to a person. Display text, never a
+    ///     link — <see cref="BrowsableUrl" /> is the link.
+    /// </summary>
+    public string NetworkString() =>
+        !string.IsNullOrEmpty(Network?.Url)
+            ? Network.Url
+            : ServiceEndpoint.Describe(Network);
+
+    /// <summary>
+    ///     A link a browser can follow, or null when this port serves something a browser
+    ///     cannot open.
+    /// </summary>
+    public string? BrowsableUrl(string? fallbackIp = null) =>
+        ServiceEndpoint.BrowsableUrl(Network, fallbackIp);
 }
 }
 
 
 public class Network {
 public class Network {

+ 87 - 0
RackPeek.Domain/Resources/Services/ServiceEndpoint.cs

@@ -0,0 +1,87 @@
+namespace RackPeek.Domain.Resources.Services;
+
+/// <summary>
+///     Where a service answers, and whether a browser can do anything with it.
+/// </summary>
+public static class ServiceEndpoint {
+    /// <summary>
+    ///     Ports a browser opens over TLS. Proxmox serves its management UI on 8006 and
+    ///     redirects plain HTTP, so guessing http there costs the user a round trip.
+    /// </summary>
+    private static readonly HashSet<int> _https = [443, 8006, 8443, 9443];
+
+    /// <summary>Ports a browser opens in the clear.</summary>
+    private static readonly HashSet<int> _http = [
+        80, 631, 3000, 5000, 7860, 8000, 8080, 8096, 8123, 9000, 9090, 11434, 32400
+    ];
+
+    /// <summary>
+    ///     The address a service answers on — <c>10.0.50.105:3000</c> — for showing to a
+    ///     person. Empty when there is no address to show. This is display text and never
+    ///     a link: see <see cref="BrowsableUrl" /> for that.
+    /// </summary>
+    public static string Describe(Network? network) {
+        if (string.IsNullOrWhiteSpace(network?.Ip))
+            return string.Empty;
+
+        return network.Port.HasValue
+            ? $"{network.Ip}:{network.Port.Value}"
+            : network.Ip;
+    }
+
+    /// <summary>
+    ///     A link a browser can actually follow, or null when it cannot.
+    ///     <para>
+    ///         A URL somebody typed always wins. Failing that the scheme has to be
+    ///         inferred, and a port number is a convention rather than a promise — so this
+    ///         answers only where the convention is a web one. SSH, SMB, MQTT, DNS and
+    ///         anything uncurated get no link at all, which is more useful than an
+    ///         <c>http://</c> that cannot load: a dead link invites a click and wastes it.
+    ///     </para>
+    ///     <para>
+    ///         <c>protocol</c> is only consulted when it names a scheme. Discovery writes
+    ///         the transport there — <c>TCP</c> — which says nothing about what rides on
+    ///         top of it.
+    ///     </para>
+    /// </summary>
+    public static string? BrowsableUrl(Network? network, string? fallbackIp = null) {
+        if (network == null)
+            return null;
+
+        if (!string.IsNullOrWhiteSpace(network.Url))
+            return network.Url;
+
+        var ip = !string.IsNullOrWhiteSpace(network.Ip) ? network.Ip : fallbackIp;
+
+        if (string.IsNullOrWhiteSpace(ip) || network.Port is not { } port)
+            return null;
+
+        var scheme = SchemeFor(port, network.Protocol);
+
+        if (scheme == null)
+            return null;
+
+        try {
+            return new UriBuilder(scheme, ip) { Port = port }.Uri.ToString();
+        }
+        catch (UriFormatException) {
+            // Whatever is in the address field, a person put it there by hand or a
+            // collector read it off a device, and neither is obliged to produce something
+            // a URL can be built from. A missing link costs a click; letting this escape
+            // would take down every page that renders the resource.
+            return null;
+        }
+    }
+
+    private static string? SchemeFor(int port, string? protocol) {
+        var stated = protocol?.Trim().ToLowerInvariant();
+
+        if (stated is "http" or "https")
+            return stated;
+
+        if (_https.Contains(port))
+            return "https";
+
+        return _http.Contains(port) ? "http" : null;
+    }
+}

+ 4 - 0
RackPeek.Domain/UseCases/RenameResourceUseCase.cs

@@ -27,6 +27,10 @@ public class RenameResourceUseCase<T>(IResourceCollection repo) : IRenameResourc
             throw new NotFoundException($"Resource '{originalName}' not found.");
             throw new NotFoundException($"Resource '{originalName}' not found.");
 
 
         original.Name = newName;
         original.Name = newName;
+
+        // A person has now chosen this name, so discovery must stop improving on it.
+        original.UserNamed = true;
+
         await repo.UpdateAsync(original);
         await repo.UpdateAsync(original);
 
 
         IReadOnlyList<Resource> allResources = await repo.GetAllOfTypeAsync<Resource>();
         IReadOnlyList<Resource> allResources = await repo.GetAllOfTypeAsync<Resource>();

+ 11 - 36
RackPeek.Mcp/Tools/DiscoveryTools.cs

@@ -1,3 +1,4 @@
+using System.ComponentModel.DataAnnotations;
 using System.ComponentModel;
 using System.ComponentModel;
 using Microsoft.Extensions.Configuration;
 using Microsoft.Extensions.Configuration;
 using Microsoft.Extensions.DependencyInjection;
 using Microsoft.Extensions.DependencyInjection;
@@ -79,9 +80,17 @@ public sealed class DiscoveryTools(IServiceProvider services) {
                 ? host.MachineId ?? host.Hostname
                 ? host.MachineId ?? host.Hostname
                 : engine?.Id ?? client.Endpoint;
                 : engine?.Id ?? client.Endpoint;
 
 
+            // No fallback to this machine's address: it is not the remote engine's, and
+            // stamping it on would give every service a confidently wrong one.
             var serviceIp = client.IsLocal
             var serviceIp = client.IsLocal
                 ? host.Ip
                 ? host.Ip
-                : await DockerApiClient.ResolveIpv4Async(client.RemoteHost!, cancellationToken) ?? host.Ip;
+                : await DockerApiClient.ResolveIpv4Async(client.RemoteHost!, cancellationToken);
+
+            if (string.IsNullOrWhiteSpace(serviceIp))
+                throw new ValidationException(
+                    $"Could not determine an IPv4 address for {client.Endpoint}. Services are "
+                    + "recorded at their host's address and the inventory holds IPv4 only; "
+                    + "dial the engine by address instead, e.g. tcp://192.0.2.10:2375.");
 
 
             List<Service> found = DockerServiceMapper.ToResources(containers, seed, effectiveHost, serviceIp);
             List<Service> found = DockerServiceMapper.ToResources(containers, seed, effectiveHost, serviceIp);
 
 
@@ -125,7 +134,7 @@ public sealed class DiscoveryTools(IServiceProvider services) {
 
 
             List<Resource> resources;
             List<Resource> resources;
             try {
             try {
-                resources = await ReadProxmoxAsync(client, cancellationToken);
+                resources = await ProxmoxDiscovery.ReadAsync(client, cancellationToken);
             }
             }
             catch (HttpRequestException ex) {
             catch (HttpRequestException ex) {
                 var hint = !insecure && ex.InnerException is System.Security.Authentication.AuthenticationException
                 var hint = !insecure && ex.InnerException is System.Security.Authentication.AuthenticationException
@@ -156,40 +165,6 @@ public sealed class DiscoveryTools(IServiceProvider services) {
                });
                });
     }
     }
 
 
-    /// <summary>Same read orchestration as `rpk discover proxmox`.</summary>
-    private static async Task<List<Resource>> ReadProxmoxAsync(
-        IProxmoxClient client,
-        CancellationToken cancellationToken) {
-        var scope = await client.GetIdentityScopeAsync(cancellationToken);
-        IReadOnlyList<ProxmoxNode> listed = await client.GetNodesAsync(cancellationToken);
-
-        var nodes = new List<ProxmoxNode>();
-        var guests = new List<ProxmoxGuest>();
-
-        foreach (ProxmoxNode listedNode in listed) {
-            ProxmoxNode node = await client.EnrichAsync(listedNode, cancellationToken);
-            nodes.Add(node);
-
-            foreach (var endpoint in new[] { ProxmoxApiClient.QemuEndpoint, ProxmoxApiClient.LxcEndpoint }) {
-                IReadOnlyList<ProxmoxGuest> listedGuests =
-                    await client.GetGuestsAsync(node.Name, endpoint, cancellationToken);
-
-                ProxmoxGuestConfig[] configs = await Task.WhenAll(listedGuests.Select(g =>
-                    client.GetGuestConfigAsync(node.Name, endpoint, g.VmId, cancellationToken)));
-
-                for (var i = 0; i < listedGuests.Count; i++)
-                    guests.Add(listedGuests[i] with {
-                        Os = configs[i].Os,
-                        Ip = configs[i].Ip,
-                        Disks = configs[i].DiskBytes,
-                        PassthroughAddresses = configs[i].PassthroughAddresses
-                    });
-            }
-        }
-
-        return ProxmoxResourceMapper.ToResources(scope, nodes, guests);
-    }
-
     private async Task<DiscoveryResult> EmitAsync(List<Resource> resources, int skipped, bool apply) {
     private async Task<DiscoveryResult> EmitAsync(List<Resource> resources, int skipped, bool apply) {
         var yaml = DiscoveryDocument.ToYaml(resources);
         var yaml = DiscoveryDocument.ToYaml(resources);
 
 

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

@@ -68,6 +68,10 @@
           "description": "Stable machine-generated identity set by 'rpk discover'. Absent on hand-written resources. The leading rpk<n> is the format version, so the way the id is derived can change without old ids being mistaken for new ones.",
           "description": "Stable machine-generated identity set by 'rpk discover'. Absent on hand-written resources. The leading rpk<n> is the format version, so the way the id is derived can change without old ids being mistaken for new ones.",
           "pattern": "^rpk[0-9]+:[a-z0-9]+:[0-9a-f]{16}$"
           "pattern": "^rpk[0-9]+:[a-z0-9]+:[0-9a-f]{16}$"
         },
         },
+        "userNamed": {
+          "type": "boolean",
+          "description": "True when a person chose this resource's name, which discovery then never changes."
+        },
         "tags": {
         "tags": {
           "type": "array",
           "type": "array",
           "items": {
           "items": {

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

@@ -68,6 +68,10 @@
           "description": "Stable machine-generated identity set by 'rpk discover'. Absent on hand-written resources. The leading rpk<n> is the format version, so the way the id is derived can change without old ids being mistaken for new ones.",
           "description": "Stable machine-generated identity set by 'rpk discover'. Absent on hand-written resources. The leading rpk<n> is the format version, so the way the id is derived can change without old ids being mistaken for new ones.",
           "pattern": "^rpk[0-9]+:[a-z0-9]+:[0-9a-f]{16}$"
           "pattern": "^rpk[0-9]+:[a-z0-9]+:[0-9a-f]{16}$"
         },
         },
+        "userNamed": {
+          "type": "boolean",
+          "description": "True when a person chose this resource's name, which discovery then never changes."
+        },
         "tags": {
         "tags": {
           "type": "array",
           "type": "array",
           "items": {
           "items": {

+ 5 - 0
Shared.Rcl/CliBootstrap.cs

@@ -806,6 +806,11 @@ public static class CliBootstrap {
                     .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<DiscoverOpnsenseCommand>("opnsense")
+                    .WithDescription("Read an OPNsense firewall's neighbour table and emit every machine on it.")
+                    .WithExample("discover", "opnsense", "--host", "https://firewall.lan", "--insecure")
+                    .WithExample("discover", "opnsense", "--host", "firewall.lan", "--push");
+
                 discover.AddCommand<DiscoverNetworkCommand>("network")
                 discover.AddCommand<DiscoverNetworkCommand>("network")
                     .WithDescription("Sweep a subnet and emit every answering host as a System resource.")
                     .WithDescription("Sweep a subnet and emit every answering host as a System resource.")
                     .WithExample("discover", "network")
                     .WithExample("discover", "network")

+ 14 - 2
Shared.Rcl/Commands/Discovery/DiscoverDockerCommand.cs

@@ -86,10 +86,22 @@ public sealed class DiscoverDockerCommand(IEnumerable<ISystemProbe> probes)
             : engine?.Id ?? client.Endpoint;
             : engine?.Id ?? client.Endpoint;
 
 
         // Published ports live on the engine host, so a remote service's address is the
         // Published ports live on the engine host, so a remote service's address is the
-        // endpoint the user dialled — the local probe's address is only the last resort.
+        // endpoint the user dialled. There is deliberately no fallback: this machine's
+        // own address is not the remote engine's, and stamping it on would put a
+        // confidently wrong address on every service — one that then flows into the
+        // ansible, ssh and hosts exports.
         var serviceIp = client.IsLocal
         var serviceIp = client.IsLocal
             ? host.Ip
             ? host.Ip
-            : await DockerApiClient.ResolveIpv4Async(client.RemoteHost!, cancellationToken) ?? host.Ip;
+            : await DockerApiClient.ResolveIpv4Async(client.RemoteHost!, cancellationToken);
+
+        if (string.IsNullOrWhiteSpace(serviceIp)) {
+            AnsiConsole.MarkupLine(
+                $"[red]Could not determine an IPv4 address for {Markup.Escape(client.Endpoint)}.[/] "
+                + "Services are recorded at their host's address, and the inventory holds IPv4 only. "
+                + "Dial the engine by address instead, e.g. --docker-host tcp://192.0.2.10:2375");
+
+            return 1;
+        }
 
 
         List<Service> services = DockerServiceMapper.ToResources(containers, seed, hostName, serviceIp);
         List<Service> services = DockerServiceMapper.ToResources(containers, seed, hostName, serviceIp);
 
 

+ 6 - 1
Shared.Rcl/Commands/Discovery/DiscoverNetworkCommand.cs

@@ -28,6 +28,10 @@ public sealed class DiscoverNetworkSettings : DiscoverSettings {
     [Description("How many hosts to probe at once.")]
     [Description("How many hosts to probe at once.")]
     public int Parallel { get; init; } = 128;
     public int Parallel { get; init; } = 128;
 
 
+    [CommandOption("--no-identify")]
+    [Description("Skip asking living hosts what they are — sweep for liveness only.")]
+    public bool NoIdentify { get; init; }
+
     /// <summary>The parsed --cidr, or null when it was omitted or does not parse.</summary>
     /// <summary>The parsed --cidr, or null when it was omitted or does not parse.</summary>
     public NetworkCidr? ParsedCidr =>
     public NetworkCidr? ParsedCidr =>
         NetworkCidr.TryParse(Cidr, out NetworkCidr parsed) ? parsed : null;
         NetworkCidr.TryParse(Cidr, out NetworkCidr parsed) ? parsed : null;
@@ -128,7 +132,8 @@ public sealed class DiscoverNetworkCommand(INetworkProbe probe)
             Cidr = cidr,
             Cidr = cidr,
             Ports = settings.ResolvedPorts,
             Ports = settings.ResolvedPorts,
             PortTimeout = TimeSpan.FromMilliseconds(settings.Timeout),
             PortTimeout = TimeSpan.FromMilliseconds(settings.Timeout),
-            Concurrency = settings.Parallel
+            Concurrency = settings.Parallel,
+            IdentifyServices = !settings.NoIdentify
         };
         };
 
 
         var targets = NetworkScanner.EnumerateTargets(cidr).Count();
         var targets = NetworkScanner.EnumerateTargets(cidr).Count();

+ 100 - 0
Shared.Rcl/Commands/Discovery/DiscoverOpnsenseCommand.cs

@@ -0,0 +1,100 @@
+using System.ComponentModel;
+using RackPeek.Domain.Discovery;
+using RackPeek.Domain.Resources;
+using Spectre.Console;
+using Spectre.Console.Cli;
+
+namespace Shared.Rcl.Commands.Discovery;
+
+public sealed class DiscoverOpnsenseSettings : DiscoverSettings {
+    [CommandOption("--host <URL>")]
+    [Description("OPNsense host, e.g. https://firewall.lan. A bare host name gets https.")]
+    public string? Host { get; init; }
+
+    [CommandOption("--key <KEY>")]
+    [Description("API key. Defaults to RPK_OPN_KEY.")]
+    public string? Key { get; init; }
+
+    [CommandOption("--secret <SECRET>")]
+    [Description("API secret. Defaults to RPK_OPN_SECRET.")]
+    public string? Secret { get; init; }
+
+    [CommandOption("--insecure")]
+    [Description("Accept a self-signed certificate, which OPNsense ships with by default.")]
+    public bool Insecure { get; init; }
+
+    [CommandOption("--include-public")]
+    [Description("Also record neighbours on public addresses, such as the ISP equipment on the WAN leg.")]
+    public bool IncludePublic { get; init; }
+
+    public string? ResolvedKey =>
+        DiscoveryPublisher.Resolve(Key, OpnsenseApiClient.KeyEnvironmentVariable);
+
+    public string? ResolvedSecret =>
+        DiscoveryPublisher.Resolve(Secret, OpnsenseApiClient.SecretEnvironmentVariable);
+
+    public override ValidationResult Validate() {
+        if (string.IsNullOrWhiteSpace(Host))
+            return ValidationResult.Error("Pass --host, e.g. --host https://firewall.lan");
+
+        if (string.IsNullOrWhiteSpace(ResolvedKey))
+            return ValidationResult.Error(
+                $"No API key. Pass --key or set {OpnsenseApiClient.KeyEnvironmentVariable}.");
+
+        if (string.IsNullOrWhiteSpace(ResolvedSecret))
+            return ValidationResult.Error(
+                $"No API secret. Pass --secret or set {OpnsenseApiClient.SecretEnvironmentVariable}.");
+
+        return base.Validate();
+    }
+}
+
+/// <summary>
+///     Reads an OPNsense firewall's neighbour table and emits every machine on it.
+///     <para>
+///         The collector for everything a sweep can see but not identify. ARP is
+///         link-local, so sweeping from one host yields a MAC only for that host's own
+///         segment and an address for everything else — and an address alone cannot
+///         survive a DHCP re-lease or be matched to anything. The firewall routes every
+///         subnet, so its table has the MAC for all of them.
+///     </para>
+/// </summary>
+public sealed class DiscoverOpnsenseCommand : AsyncCommand<DiscoverOpnsenseSettings> {
+    protected override async Task<int> ExecuteAsync(
+        CommandContext context,
+        DiscoverOpnsenseSettings settings,
+        CancellationToken cancellationToken) {
+        using var client = new OpnsenseApiClient(
+            settings.Host!,
+            settings.ResolvedKey!,
+            settings.ResolvedSecret!,
+            settings.Insecure);
+
+        List<Resource> resources;
+
+        try {
+            resources = await OpnsenseDiscovery.ReadAsync(client, settings.IncludePublic, cancellationToken);
+        }
+        catch (Exception ex) when (
+            ex is HttpRequestException or IOException or TimeoutException
+            || (ex is TaskCanceledException && !cancellationToken.IsCancellationRequested)) {
+            AnsiConsole.MarkupLine(
+                $"[red]Could not read {Markup.Escape(client.Endpoint)}.[/] {Markup.Escape(ex.Message)}");
+
+            // A self-signed certificate is the other common cause, and its message is
+            // opaque enough to be worth naming.
+            if (!settings.Insecure && ex is HttpRequestException { StatusCode: null })
+                AnsiConsole.MarkupLine(
+                    "[grey]If the firewall uses its own certificate, add --insecure.[/]");
+
+            return 1;
+        }
+
+        if (resources.Count == 0)
+            AnsiConsole.MarkupLine(
+                "[grey]The firewall's ARP table is empty. It only holds neighbours it has "
+                + "spoken to recently, so this is normal on a quiet network.[/]");
+
+        return await DiscoveryOutput.EmitAsync(resources, settings, cancellationToken);
+    }
+}

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

@@ -73,7 +73,7 @@ public sealed class DiscoverProxmoxCommand : AsyncCommand<DiscoverProxmoxSetting
         List<Resource> resources;
         List<Resource> resources;
 
 
         try {
         try {
-            resources = await ReadAsync(client, cancellationToken);
+            resources = await ProxmoxDiscovery.ReadAsync(client, cancellationToken);
         }
         }
         catch (HttpRequestException ex) {
         catch (HttpRequestException ex) {
             AnsiConsole.MarkupLine(
             AnsiConsole.MarkupLine(
@@ -98,45 +98,4 @@ public sealed class DiscoverProxmoxCommand : AsyncCommand<DiscoverProxmoxSetting
 
 
         return await DiscoveryOutput.EmitAsync(resources, settings, cancellationToken);
         return await DiscoveryOutput.EmitAsync(resources, settings, cancellationToken);
     }
     }
-
-    private static async Task<List<Resource>> ReadAsync(
-        IProxmoxClient client,
-        CancellationToken cancellationToken) {
-        var scope = await client.GetIdentityScopeAsync(cancellationToken);
-        IReadOnlyList<ProxmoxNode> listed = await client.GetNodesAsync(cancellationToken);
-
-        var nodes = new List<ProxmoxNode>();
-        var guests = new List<ProxmoxGuest>();
-
-        foreach (ProxmoxNode listedNode in listed) {
-            // Node detail needs a broader permission than listing guests does, so it is
-            // enrichment rather than a requirement — a read-only token still gets a tree.
-            ProxmoxNode node = await client.EnrichAsync(listedNode, cancellationToken);
-            nodes.Add(node);
-
-            var nodeName = node.Name;
-
-            foreach (var endpoint in new[] { ProxmoxApiClient.QemuEndpoint, ProxmoxApiClient.LxcEndpoint }) {
-                IReadOnlyList<ProxmoxGuest> listedGuests =
-                    await client.GetGuestsAsync(nodeName, endpoint, cancellationToken);
-
-                // The list call knows nothing about the OS, and for a container it does
-                // not know the address either. Both live in the guest's own config — one
-                // call per guest, so they run concurrently rather than one at a time.
-                ProxmoxGuestConfig[] configs = await Task.WhenAll(listedGuests.Select(g =>
-                    client.GetGuestConfigAsync(nodeName, endpoint, g.VmId, cancellationToken)));
-
-                for (var i = 0; i < listedGuests.Count; i++)
-                    guests.Add(listedGuests[i] with {
-                        Os = configs[i].Os,
-                        Ip = configs[i].Ip,
-                        Disks = configs[i].DiskBytes,
-                        PassthroughAddresses = configs[i].PassthroughAddresses,
-                        Macs = configs[i].Macs ?? []
-                    });
-            }
-        }
-
-        return ProxmoxResourceMapper.ToResources(scope, nodes, guests);
-    }
 }
 }

+ 30 - 11
Shared.Rcl/Components/HardwareDependencyTreeComponent.razor

@@ -55,6 +55,7 @@ else
 
 
                                     case Service service:
                                     case Service service:
                                         var endpoint = service.NetworkString();
                                         var endpoint = service.NetworkString();
+                                        var browsable = service.BrowsableUrl();
 
 
                                         <NavLink href="@($"resources/services/{Uri.EscapeDataString(service.Name)}")"
                                         <NavLink href="@($"resources/services/{Uri.EscapeDataString(service.Name)}")"
                                                  class="block">
                                                  class="block">
@@ -69,13 +70,34 @@ else
                                                     @if (!string.IsNullOrWhiteSpace(endpoint))
                                                     @if (!string.IsNullOrWhiteSpace(endpoint))
                                                     {
                                                     {
                                                         <span> - </span>
                                                         <span> - </span>
-                                                        <a href="@endpoint"
-                                                           target="_blank"
-                                                           rel="noopener noreferrer"
-                                                           class="underline hover:text-emerald-400"
-                                                           @onclick:stopPropagation>
-                                                            @endpoint
-                                                        </a>
+
+                                                        @if (!string.IsNullOrWhiteSpace(browsable))
+
+                                                        {
+
+                                                            <a href="@browsable"
+
+                                                               target="_blank"
+
+                                                               rel="noopener noreferrer"
+
+                                                               class="underline hover:text-emerald-400"
+
+                                                               @onclick:stopPropagation>
+
+                                                                @endpoint
+
+                                                            </a>
+
+                                                        }
+
+                                                        else
+
+                                                        {
+
+                                                            <span>@endpoint</span>
+
+                                                        }
                                                     }
                                                     }
                                                 </div>
                                                 </div>
                                             </div>
                                             </div>
@@ -107,10 +129,7 @@ else
     {
     {
         var endpoint = service.NetworkString();
         var endpoint = service.NetworkString();
 
 
-        if (string.IsNullOrWhiteSpace(endpoint))
-            return null;
-
-        return endpoint;
+        return string.IsNullOrWhiteSpace(endpoint) ? null : endpoint;
     }
     }
 
 
 }
 }

+ 4 - 21
Shared.Rcl/Services/ServiceCardComponent.razor

@@ -461,27 +461,10 @@
         Nav.NavigateTo($"resources/services/{Uri.EscapeDataString(newName)}");
         Nav.NavigateTo($"resources/services/{Uri.EscapeDataString(newName)}");
     }
     }
 
 
-    private string? GetBrowsableHref()
-    {
-        var ip = Service.Network?.Ip ?? EffectiveIp;
-        var port = Service.Network?.Port;
-
-        if (string.IsNullOrWhiteSpace(ip) || port is null)
-            return null;
-
-        var proto = Service.Network?.Protocol?.Trim().ToLowerInvariant();
-
-        var scheme = proto switch
-        {
-            "https" => "https",
-            "http" => "http",
-            _ => "http"
-        };
-
-        // Build a correct absolute URL
-        var ub = new UriBuilder(scheme, ip) { Port = port.Value };
-        return ub.Uri.ToString();
-    }
+    // A port is a convention, not a promise: ssh on 22 is not browsable and an http://
+    // link to it only wastes a click. The domain owns that judgement so this page, the
+    // dependency trees and anything else agree on it.
+    private string? GetBrowsableHref() => Service.BrowsableUrl(EffectiveIp);
 }
 }
 
 
 @code
 @code

+ 30 - 11
Shared.Rcl/Systems/SystemDependencyTreeComponent.razor

@@ -18,6 +18,7 @@ else
                 {
                 {
                     case Service service:
                     case Service service:
                         var endpoint = service.NetworkString();
                         var endpoint = service.NetworkString();
+                                        var browsable = service.BrowsableUrl();
 
 
                         <NavLink href="@($"resources/services/{Uri.EscapeDataString(service.Name)}")"
                         <NavLink href="@($"resources/services/{Uri.EscapeDataString(service.Name)}")"
                                  class="block">
                                  class="block">
@@ -32,13 +33,34 @@ else
                                     @if (!string.IsNullOrWhiteSpace(endpoint))
                                     @if (!string.IsNullOrWhiteSpace(endpoint))
                                     {
                                     {
                                         <span> - </span>
                                         <span> - </span>
-                                        <a href="@endpoint"
-                                           target="_blank"
-                                           rel="noopener noreferrer"
-                                           class="underline hover:text-emerald-400"
-                                           @onclick:stopPropagation>
-                                            @endpoint
-                                        </a>
+
+                                        @if (!string.IsNullOrWhiteSpace(browsable))
+
+                                        {
+
+                                            <a href="@browsable"
+
+                                               target="_blank"
+
+                                               rel="noopener noreferrer"
+
+                                               class="underline hover:text-emerald-400"
+
+                                               @onclick:stopPropagation>
+
+                                                @endpoint
+
+                                            </a>
+
+                                        }
+
+                                        else
+
+                                        {
+
+                                            <span>@endpoint</span>
+
+                                        }
                                     }
                                     }
                                 </div>
                                 </div>
 
 
@@ -83,10 +105,7 @@ else
     {
     {
         var endpoint = service.NetworkString();
         var endpoint = service.NetworkString();
 
 
-        if (string.IsNullOrWhiteSpace(endpoint))
-            return null;
-
-        return endpoint;
+        return string.IsNullOrWhiteSpace(endpoint) ? null : endpoint;
     }
     }
 
 
 }
 }

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

@@ -242,6 +242,7 @@
     - [system](/docs/cli-commands#rpk-discover-system) - Inspect this machine and emit it as a System resource
     - [system](/docs/cli-commands#rpk-discover-system) - Inspect this machine and emit it as a System resource
     - [docker](/docs/cli-commands#rpk-discover-docker) - Read the Docker API and emit each published container as a Service on this
     - [docker](/docs/cli-commands#rpk-discover-docker) - Read the Docker API and emit each published container as a Service on this
     - [proxmox](/docs/cli-commands#rpk-discover-proxmox) - Read a Proxmox cluster and emit its nodes and guests as Systems
     - [proxmox](/docs/cli-commands#rpk-discover-proxmox) - Read a Proxmox cluster and emit its nodes and guests as Systems
+    - [opnsense](/docs/cli-commands#rpk-discover-opnsense) - Read an OPNsense firewall's neighbour table and emit every machine on it
     - [network](/docs/cli-commands#rpk-discover-network) - Sweep a subnet and emit every answering host as a System resource
     - [network](/docs/cli-commands#rpk-discover-network) - Sweep a subnet and emit every answering host as a System resource
   - [ansible](/docs/cli-commands#rpk-ansible) - Generate and manage Ansible inventory
   - [ansible](/docs/cli-commands#rpk-ansible) - Generate and manage Ansible inventory
     - [inventory](/docs/cli-commands#rpk-ansible-inventory) - Generate an Ansible inventory
     - [inventory](/docs/cli-commands#rpk-ansible-inventory) - Generate an Ansible inventory

+ 42 - 5
Shared.Rcl/wwwroot/raw_docs/cli-commands.md

@@ -3966,11 +3966,14 @@ OPTIONS:
     -h, --help    Prints help information
     -h, --help    Prints help information
 
 
 COMMANDS:
 COMMANDS:
-    system     Inspect this machine and emit it as a System resource            
-    docker     Read the Docker API and emit each published container as a       
-               Service on this host's System                                    
-    proxmox    Read a Proxmox cluster and emit its nodes and guests as Systems  
-    network    Sweep a subnet and emit every answering host as a System resource
+    system      Inspect this machine and emit it as a System resource           
+    docker      Read the Docker API and emit each published container as a      
+                Service on this host's System                                   
+    proxmox     Read a Proxmox cluster and emit its nodes and guests as Systems 
+    opnsense    Read an OPNsense firewall's neighbour table and emit every      
+                machine on it                                                   
+    network     Sweep a subnet and emit every answering host as a System        
+                resource                                                        
 ```
 ```
 
 
 ## `rpk discover system`
 ## `rpk discover system`
@@ -4061,6 +4064,38 @@ OPTIONS:
                                    Proxmox ships with by default                
                                    Proxmox ships with by default                
 ```
 ```
 
 
+## `rpk discover opnsense`
+```
+DESCRIPTION:
+Read an OPNsense firewall's neighbour table and emit every machine on it
+
+USAGE:
+    rpk discover opnsense [OPTIONS]
+
+EXAMPLES:
+    rpk discover opnsense --host https://firewall.lan --insecure
+    rpk discover opnsense --host firewall.lan --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                           
+        --host <URL>         OPNsense host, e.g. https://firewall.lan. A bare   
+                             host name gets https                               
+        --key <KEY>          API key. Defaults to RPK_OPN_KEY                   
+        --secret <SECRET>    API secret. Defaults to RPK_OPN_SECRET             
+        --insecure           Accept a self-signed certificate, which OPNsense   
+                             ships with by default                              
+        --include-public     Also record neighbours on public addresses, such as
+                             the ISP equipment on the WAN leg                   
+```
+
 ## `rpk discover network`
 ## `rpk discover network`
 ```
 ```
 DESCRIPTION:
 DESCRIPTION:
@@ -4090,6 +4125,8 @@ OPTIONS:
                            e.g. 22,80,443. Defaults to a curated homelab list   
                            e.g. 22,80,443. Defaults to a curated homelab list   
         --timeout <MS>     Milliseconds to wait on each port probe              
         --timeout <MS>     Milliseconds to wait on each port probe              
         --parallel <N>     How many hosts to probe at once                      
         --parallel <N>     How many hosts to probe at once                      
+        --no-identify      Skip asking living hosts what they are — sweep for   
+                           liveness only                                        
 ```
 ```
 
 
 ## `rpk ansible`
 ## `rpk ansible`

+ 244 - 15
Shared.Rcl/wwwroot/raw_docs/discovery-guide.md

@@ -8,7 +8,8 @@ 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 |
+| `rpk discover opnsense` | an OPNsense firewall's neighbour table | one **System** per machine it has seen, on every subnet it routes |
+| `rpk discover network` | a subnet, from outside | one **System** per host that answers, plus a **Service** for each web application it recognises |
 
 
 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.
@@ -60,13 +61,41 @@ stored locally, from any machine you run the command on.
 What that buys you:
 What that buys you:
 
 
 * **Renaming is safe.** Call it `storage-01` in the web UI and the next discovery run
 * **Renaming is safe.** Call it `storage-01` in the web UI and the next discovery run
-  updates `storage-01`. It will never rename a resource you named.
+  updates `storage-01`. It will never rename a resource you named — see
+  [Names get better until you choose one](#names-get-better-until-you-choose-one).
 * **Existing resources are adopted.** If you already documented `nas01` by hand, the
 * **Existing resources are adopted.** If you already documented `nas01` by hand, the
   first discovery run attaches to it — keeping your notes and gaining an id — rather
   first discovery run attaches to it — keeping your notes and gaining an id — rather
   than creating a duplicate.
   than creating a duplicate.
 * **Two machines cannot collide.** A second machine that happens to share a hostname is
 * **Two machines cannot collide.** A second machine that happens to share a hostname is
   given a suffixed name instead of overwriting the first.
   given a suffixed name instead of overwriting the first.
 
 
+### Names get better until you choose one
+
+A collector names a card from whatever it could see, and when it could see nothing the
+name falls back to a slug of the card's own id — `host-1a2b3c4d` says only that something
+is there. A later run, or a collector that can see more, often *does* know the machine's
+name: a firewall knows what it handed out over DHCP, a hypervisor knows what its guest is
+called. So a placeholder gets replaced by a real name when one turns up, and the
+inventory improves as you point more collectors at it.
+
+The moment you rename a resource yourself, that stops. The rename records a `userNamed`
+flag, and nothing in discovery touches the name again:
+
+```yaml
+- kind: System
+  name: the-blue-one
+  userNamed: true
+  discoveryId: rpk1:net:9f2c1a7e40b3d582
+```
+
+Two rules keep this from turning into churn. The upgrade only ever goes from a
+placeholder to a real name, never the other way: a sweep that runs after the firewall
+knows *less*, and must not undo it. And one real name never replaces another — two
+collectors that each knew a different name for one box would otherwise rename it back and
+forth on every run, so the first real name wins and stays. A resource with no
+`discoveryId` at all is user-named whatever the flag says; nothing but a person could
+have written it.
+
 > **Cloned VM templates share `/etc/machine-id`.** If you clone a Proxmox or VMware
 > **Cloned VM templates share `/etc/machine-id`.** If you clone a Proxmox or VMware
 > template without resetting it, every clone reports the same identity. RackPeek rejects
 > template without resetting it, every clone reports the same identity. RackPeek rejects
 > a payload containing duplicate ids rather than silently merging the machines. Run
 > a payload containing duplicate ids rather than silently merging the machines. Run
@@ -227,10 +256,10 @@ and most installations keep it.
 
 
 **A node becomes two resources**, because it is two things:
 **A node becomes two resources**, because it is two things:
 
 
-* a **Server** named after the node (`kepler`) — the machine, carrying its processor
+* a **Server** named after the node (`pve-node-01`) — the machine, carrying its processor
   (model, cores and threads per socket), memory, physical disks with Proxmox's own
   (model, cores and threads per socket), memory, physical disks with Proxmox's own
   nvme/ssd/hdd classification, and its GPUs;
   nvme/ssd/hdd classification, and its GPUs;
-* a **System** of type `hypervisor` (`kepler-pve`) — the Proxmox install running on that
+* a **System** of type `hypervisor` (`pve-node-01-pve`) — the Proxmox install running on that
   machine, carrying the PVE version.
   machine, carrying the PVE version.
 
 
 Guests then run on the hypervisor, giving the full Hardware → System → System tree that
 Guests then run on the hypervisor, giving the full Hardware → System → System tree that
@@ -238,7 +267,7 @@ the graph views are built around.
 
 
 ```yaml
 ```yaml
 - kind: Server
 - kind: Server
-  name: kepler
+  name: pve-node-01
   cpus:
   cpus:
   - model: AMD Ryzen 5 5600G
   - model: AMD Ryzen 5 5600G
     cores: 6
     cores: 6
@@ -252,14 +281,14 @@ the graph views are built around.
   - model: GeForce RTX 3090
   - model: GeForce RTX 3090
   - model: GeForce RTX 3090
   - model: GeForce RTX 3090
 - kind: System
 - kind: System
-  name: kepler-pve
+  name: pve-node-01-pve
   type: hypervisor
   type: hypervisor
   os: Proxmox VE 8.2.2
   os: Proxmox VE 8.2.2
-  runsOn: [kepler]
+  runsOn: [pve-node-01]
 - kind: System
 - kind: System
   name: docker-01
   name: docker-01
   type: vm
   type: vm
-  runsOn: [kepler-pve]
+  runsOn: [pve-node-01-pve]
 ```
 ```
 
 
 Each QEMU guest becomes a `vm` and each LXC guest a `container`, with its allocated
 Each QEMU guest becomes a `vm` and each LXC guest a `container`, with its allocated
@@ -291,7 +320,7 @@ visible from either end:
   type: vm
   type: vm
   labels:
   labels:
     gpu: GeForce RTX 3090, GeForce RTX 3090
     gpu: GeForce RTX 3090, GeForce RTX 3090
-  runsOn: [kepler-pve]
+  runsOn: [pve-node-01-pve]
 ```
 ```
 
 
 A label rather than a field, because RackPeek has no first-class way to say "this device
 A label rather than a field, because RackPeek has no first-class way to say "this device
@@ -319,11 +348,111 @@ with no cluster uses its node name as the scope instead.
 
 
 ---
 ---
 
 
+### Where a guest's address comes from
+
+Proxmox only records an address in a guest's config when someone set one statically, so
+on a DHCP estate the config knows nothing. The guest itself does, and will say so: a VM
+through its **qemu-guest-agent**, a container through its running interfaces. Discovery
+asks every *running* guest, which is one extra call per guest and nothing at all for one
+that is switched off.
+
+A guest that runs containers has several interfaces — Docker's `docker0` and its
+per-network bridges, Home Assistant's `hassio`, any VPN tunnel — and recording
+`172.17.0.1` as the machine's address would be worse than recording nothing, because
+every container host on the estate reports the same one. So the NIC MACs Proxmox assigned
+are used as the discriminator: an interface carrying one is a NIC the hypervisor gave the
+guest, anything else is something the guest invented. Where the MACs cannot be read,
+nothing is claimed.
+
+No agent, a stopped guest, or a token without `VM.Monitor` all mean the same thing —
+the address stays unknown, exactly as before.
+
+**Why it matters beyond the address itself.** A guest's address is what lets
+`rpk discover network` recognise it. ARP is link-local, so a sweep of any subnet but its
+own gets no MAC and can only identify a host by address; once the hypervisor has reported
+that same address, the sweep's find is matched to the guest the hypervisor already
+described in full rather than becoming a second, emptier card beside it.
+
+---
+
+## `rpk discover opnsense`
+
+The collector for everything a sweep can see but not identify.
+
+ARP is link-local. Sweeping from one machine gets you a MAC for that machine's own
+segment and nothing but an address for every other subnet — and an address alone cannot
+survive a DHCP re-lease or be matched against anything else you have documented. The
+firewall routes every subnet, so its neighbour table has the MAC for all of them, plus
+the name it handed out and which leg it was seen on.
+
+```bash
+# Look at what the firewall knows
+rpk discover opnsense --host https://firewall.lan --insecure
+
+# Merge it into your server
+rpk discover opnsense --host firewall.lan --push
+```
+
+| Option | Meaning |
+|---|---|
+| `--host <URL>` | The firewall. A bare name gets `https`. |
+| `--key <KEY>` | API key. Defaults to `RPK_OPN_KEY`. |
+| `--secret <SECRET>` | API secret. Defaults to `RPK_OPN_SECRET`. |
+| `--insecure` | Accept the self-signed certificate OPNsense ships with. |
+| `--include-public` | Also record neighbours on public addresses (see below). |
+
+Plus the same `--push` / `--server` / `--api-key` / `--dry-run` options as every other
+collector.
+
+Create the credentials in the firewall under **System → Access → Users**, on a user that
+holds the **Diagnostics: ARP Table** privilege. Read-only is enough; nothing here writes.
+
+### What it records, and what it leaves out
+
+One **System** per machine, carrying its address, its MAC, and the vendor that MAC
+belongs to.
+
+What it does *not* record is which of the firewall's legs the machine answered on. That
+describes the firewall's wiring rather than the machine, and it changes the moment
+anything is re-cabled or a VLAN is renamed — where the address and the MAC are facts
+about the machine itself.
+
+Left out on purpose:
+
+* **The firewall's own addresses.** Every routed subnet contributes one and they are all
+  the same box — which is a Firewall, not the handful of Systems this would invent.
+* **Entries that have aged out.** They say where something used to be.
+* **Broadcast and multicast addresses**, which no machine owns.
+* **Neighbours on public addresses**, such as the ISP equipment on the WAN leg. They are
+  not your infrastructure, and recording one would put a public address into a file you
+  may well commit. Pass `--include-public` if you are documenting a fleet that lives on
+  them.
+
+A machine answering on two of the firewall's legs is one card, not two: its identity is
+its MAC.
+
+### Why it lines up with everything else
+
+A card from the firewall is seeded exactly as `rpk discover network` seeds its own —
+keyed on the MAC — because both describe the same thing by the same evidence: a machine
+observed on the network rather than asked about itself. So a host the firewall knows and
+a host a sweep found are **one card**, whichever collector ran first, with no special
+case anywhere to say so. Run both and the firewall fills in the identity a sweep of a
+routed subnet could never get.
+
+Order does not matter for names either. A card a sweep could only call `host-<hash>`
+takes the name the firewall handed out over DHCP as soon as you run this collector — see
+[Names get better until you choose one](#names-get-better-until-you-choose-one). A name
+you chose yourself is never touched.
+
+---
+
 ## `rpk discover network`
 ## `rpk discover network`
 
 
 The collector for machines nothing else can describe: no agent, no API — just an
 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.
+address that answers. It sweeps a subnet and emits one **System** per responding host —
+with its IP, a name, its MAC address, and the vendor that MAC belongs to — plus a
+**Service** for each web application that names itself.
 
 
 ```bash
 ```bash
 # Sweep this machine's own subnet and look at the result
 # Sweep this machine's own subnet and look at the result
@@ -342,6 +471,105 @@ proxmox, and friends); `--ports 22,80,443` narrows or widens it. The ports are o
 liveness check: the sweep records that the host exists, not what it serves — pair it
 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.
 with `rpk discover docker` or hand-written Service cards for that.
 
 
+### Where a scanned host's name comes from
+
+A homelab rarely has PTR records for everything, and a page of `host-1a2b3c4d` cards is
+not documentation. So once a host is known to be alive, the sweep asks it what it is,
+taking the first answer from:
+
+1. **A reverse-DNS (PTR) record** — the network's own answer, so it always wins.
+2. **A TLS certificate's Common Name**, read from 443, 8006 or 8443. The strongest
+   remaining evidence, because appliances ship a certificate naming themselves: a
+   Proxmox node presents `CN=pve-node-01.example.com`, OPNsense presents its hostname.
+   The certificate is read, never trusted — self-signed is the norm here.
+3. **An SSH greeting** on 22, reduced to the software (`SSH-2.0-dropbear` → `dropbear`).
+   That names what the host runs rather than the host, which still separates an
+   embedded appliance from a general-purpose box.
+4. **An HTTP page title or `Server` header**, on the usual web ports and on anything else
+   found open — how a Home Assistant or a Forgejo announces itself. Titles are trimmed to
+   the phrase before their first separator, so "Forgejo: Beyond coding. We Forge." names a
+   machine `forgejo`, and the titles of error pages are ignored entirely.
+
+Whatever answered is recorded in an `identified-by` label (`tls:8006 pve-node-01.example.com`)
+so you can see where a name came from and judge it. Nothing is sent to the host beyond a
+bare `GET /`, and a host that stays silent simply keeps its generated name.
+
+Identification costs a handful of short connections per *living* host — never per
+address — and `--no-identify` turns it off for a pure liveness sweep.
+
+### Open ports
+
+The liveness sweep stops at the first answer, because it only needs to know the host
+exists. Once a host has answered, it is checked against a wider list — cameras (554),
+MQTT brokers (1883), Home Assistant (8123), Portainer (9000), Plex, Postgres and so on.
+
+### Every open port becomes a Service
+
+Something listening on a port is a service, and a service is a resource in its own right
+rather than a note on the host. Each open port therefore gets its own **Service** card,
+named for the host and what that port serves:
+
+```yaml
+- kind: System
+  ip: 192.0.2.204
+  name: nebula
+  labels:
+    identified-by: http:8123 Home Assistant
+- kind: Service
+  name: nebula-ssh
+  network: { ip: 192.0.2.204, port: 22, protocol: TCP }
+  runsOn: [nebula]
+- kind: Service
+  name: home-assistant
+  network: { ip: 192.0.2.204, port: 8123, protocol: TCP }
+  runsOn: [nebula]
+```
+
+This replaces the old `open-ports` label, which was a comma-separated string nothing
+could link to, filter on, or hang a note from.
+
+What the service said about itself wins over what its port number implies, because a
+port is a convention and an answer is evidence: `8123` is Home Assistant by convention,
+but a page that says "Forgejo" is the machine telling you. Where nothing answered, the
+name falls back to the host plus the port's usual service — `nebula-ssh`, `nebula-https`,
+`nebula-smb` — and an unrecognised port keeps its number as `nebula-tcp-9987`, which is
+an honest record rather than a guess.
+
+One case is deliberately special: an appliance whose management page just says its own
+name back. A firewall already called `opnsense` gains an `opnsense-https`, not a second
+card called `opnsense`.
+
+Each port keeps its own stable id, so a rescan updates these cards rather than
+duplicating them. A port is also the best possible target for the banner probes above,
+which is how `9000` became "Portainer". Note that this is what answered out of the ports
+probed, not a full port scan; `--ports` widens the set.
+
+This is also why a page title does not name the *machine*: picking whichever application
+answered first would be arbitrary, and a certificate is the only answer that is a claim
+about the machine itself.
+
+### NIC vendor from the MAC
+
+Where the sweep has a MAC, the card also gets a `nic-vendor` label naming the organisation
+that OUI belongs to — `Espressif`, `Ubiquiti`, `Raspberry Pi`, `Proxmox`. For a silent
+device with no PTR record and no web UI, this is often the only thing that distinguishes
+it from an address.
+
+It is named for what it actually is. The OUI identifies whoever owns the network
+interface, which is not the same claim as who made the machine: a Proxmox guest's virtual
+NIC reads `Proxmox` while the box underneath it is a Dell.
+
+Two further details worth knowing. A hypervisor's own prefix wins over the
+locally-administered bit, so a KVM guest reads as `QEMU/KVM` rather than anonymous. And
+an address a device made up for itself — modern phones and laptops randomise per network
+for privacy — is reported as `Randomised (locally administered)`, because the OUI half of
+such an address names nobody and a lookup would otherwise attribute it to whichever
+company happens to own the matching block.
+
+The table is a curated subset of the IEEE registry covering the gear that turns up on a
+homelab, not all 40,000 assignments; an unknown prefix yields no label rather than a
+guess. Run `./generate-oui-table.py` against the registry to extend it.
+
 Sweeps are capped at a /16 (65,534 addresses). `--timeout` and `--parallel` tune how
 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.
 patient and how aggressive the sweep is; the defaults finish a quiet /24 in seconds.
 
 
@@ -349,8 +577,8 @@ A network of several VLANs is several sweeps — each merges into the same inven
 and the ids keep re-runs honest:
 and the ids keep re-runs honest:
 
 
 ```bash
 ```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
+rpk discover network --cidr 192.168.10.0/24 --push   # the LAN
+rpk discover network --cidr 192.168.50.0/24 --push   # the server VLAN
 ```
 ```
 
 
 ### Identity
 ### Identity
@@ -383,8 +611,9 @@ the same card**, whichever ran first:
 - Agent first: a later sweep recognises the box and just refreshes its address —
 - Agent first: a later sweep recognises the box and just refreshes its address —
   never touching the identity or anything you or the agent wrote.
   never touching the identity or anything you or the agent wrote.
 
 
-The card keeps whatever name it already had (names are always user-owned), so a
-scan-first card keeps its generated `host-…` name until you rename it once. A MAC that
+The card keeps whatever name it already had, except that a generated `host-…` name gives
+way to a real one when a collector turns up knowing it, and a name you chose never does.
+A MAC that
 two stored cards both claim unifies nothing — ambiguity always falls back to separate
 two stored cards both claim unifies nothing — ambiguity always falls back to separate
 cards — and agent-grade identities never unify with each other on a MAC alone (cloned
 cards — and agent-grade identities never unify with each other on a MAC alone (cloned
 VMs can share one; that is what machine-ids are for). The machine running the sweep
 VMs can share one; that is what machine-ids are for). The machine running the sweep

+ 86 - 0
Tests.Discovery/Fixtures/opnsense-arp.json

@@ -0,0 +1,86 @@
+[
+  {
+    "mac": "00:50:c2:00:1a:01",
+    "ip": "198.51.100.7",
+    "intf": "igb0",
+    "expired": false,
+    "expires": -1,
+    "permanent": false,
+    "type": "ethernet",
+    "manufacturer": "Cisco Systems",
+    "hostname": "",
+    "intf_description": "WAN"
+  },
+  {
+    "mac": "00:1b:21:00:1a:02",
+    "ip": "192.0.2.1",
+    "intf": "igb1",
+    "expired": false,
+    "expires": -1,
+    "permanent": true,
+    "type": "ethernet",
+    "manufacturer": "Intel Corporate",
+    "hostname": "",
+    "intf_description": "LAB"
+  },
+  {
+    "mac": "1c:6a:1b:00:1a:03",
+    "ip": "192.168.10.150",
+    "intf": "igb1_vlan20",
+    "expired": false,
+    "expires": 1132,
+    "permanent": false,
+    "type": "ethernet",
+    "manufacturer": "Ubiquiti Inc",
+    "hostname": "ap-outdoor",
+    "intf_description": "home"
+  },
+  {
+    "mac": "bc:24:11:00:1a:04",
+    "ip": "192.168.50.105",
+    "intf": "igb1_vlan50",
+    "expired": false,
+    "expires": 980,
+    "permanent": false,
+    "type": "ethernet",
+    "manufacturer": "Proxmox Server Solutions GmbH",
+    "hostname": "forgejo",
+    "intf_description": "HomeServices"
+  },
+  {
+    "mac": "aa:bb:cc:00:1a:05",
+    "ip": "192.168.10.99",
+    "intf": "igb1_vlan20",
+    "expired": true,
+    "expires": -1,
+    "permanent": false,
+    "type": "ethernet",
+    "manufacturer": "",
+    "hostname": "gone-away",
+    "intf_description": "home"
+  },
+  {
+    "mac": "ff:ff:ff:ff:ff:ff",
+    "ip": "192.168.10.255",
+    "intf": "igb1_vlan20",
+    "expired": false,
+    "expires": -1,
+    "permanent": false,
+    "type": "ethernet",
+    "manufacturer": "",
+    "hostname": "",
+    "intf_description": "home"
+  },
+  {
+    "mac": "1c:6a:1b:00:1a:03",
+    "ip": "192.168.30.150",
+    "intf": "igb1_vlan30",
+    "expired": false,
+    "expires": 900,
+    "permanent": false,
+    "type": "ethernet",
+    "manufacturer": "Ubiquiti Inc",
+    "hostname": "ap-outdoor",
+    "intf_description": "IOT"
+  }
+]

+ 36 - 0
Tests.Discovery/Fixtures/pve-agent-interfaces.json

@@ -0,0 +1,36 @@
+{
+  "data": {
+    "result": [
+      {
+        "name": "lo",
+        "hardware-address": "00:00:00:00:00:00",
+        "ip-addresses": [
+          { "ip-address": "127.0.0.1", "ip-address-type": "ipv4", "prefix": 8 },
+          { "ip-address": "::1", "ip-address-type": "ipv6", "prefix": 128 }
+        ]
+      },
+      {
+        "name": "ens18",
+        "hardware-address": "bc:24:11:00:1a:01",
+        "ip-addresses": [
+          { "ip-address": "192.0.2.105", "ip-address-type": "ipv4", "prefix": 24 },
+          { "ip-address": "fe80::be24:11ff:fe00:1a01", "ip-address-type": "ipv6", "prefix": 64 }
+        ]
+      },
+      {
+        "name": "docker0",
+        "hardware-address": "02:42:9a:11:22:33",
+        "ip-addresses": [
+          { "ip-address": "172.17.0.1", "ip-address-type": "ipv4", "prefix": 16 }
+        ]
+      },
+      {
+        "name": "br-0d764d475870",
+        "hardware-address": "02:42:7e:44:55:66",
+        "ip-addresses": [
+          { "ip-address": "172.18.0.1", "ip-address-type": "ipv4", "prefix": 16 }
+        ]
+      }
+    ]
+  }
+}

+ 16 - 0
Tests.Discovery/Fixtures/pve-lxc-interfaces.json

@@ -0,0 +1,16 @@
+{
+  "data": [
+    {
+      "name": "lo",
+      "hwaddr": "00:00:00:00:00:00",
+      "inet": "127.0.0.1/8",
+      "inet6": "::1/128"
+    },
+    {
+      "name": "eth0",
+      "hwaddr": "bc:24:11:00:1a:09",
+      "inet": "192.0.2.150/24",
+      "inet6": "fe80::be24:11ff:fe00:1a09/64"
+    }
+  ]
+}

+ 171 - 0
Tests.Discovery/IpBridgeSymmetryTests.cs

@@ -0,0 +1,171 @@
+using RackPeek.Domain.Discovery;
+using RackPeek.Domain.Resources;
+using RackPeek.Domain.Resources.Servers;
+using RackPeek.Domain.Resources.SystemResources;
+
+namespace Tests.Discovery;
+
+/// <summary>
+///     The address bridge has to read the same whichever collector ran first.
+///     <para>
+///         ARP is link-local, so a sweep of a routed subnet gets an address and no MAC,
+///         and the card it writes can only be called <c>host-&lt;hash&gt;</c>. A hypervisor
+///         knows that guest by name, by specification, and by the address it holds — so
+///         the address is the one thing tying the two together. Whether the sweep or the
+///         hypervisor got there first is an accident of what the person typed, and must
+///         not decide whether the inventory ends up with one card or two.
+///     </para>
+///     <para>
+///         A live run of nine subnets found this the hard way: sweeping before running
+///         the Proxmox and firewall collectors produced fourteen machines twice over,
+///         where the same commands in the other order produced one card each.
+///     </para>
+/// </summary>
+public class IpBridgeSymmetryTests {
+    private const string _ip = "10.0.50.105";
+    private const string _mac = "bc:24:11:00:4a:01";
+
+    /// <summary>What a sweep of a routed subnet can write: an address, and nothing else.</summary>
+    private static SystemResource ScanCard(string? ip = _ip) => new() {
+        Kind = SystemResource.KindLabel,
+        Name = "host-2ed3bfd7",
+        DiscoveryId = DiscoveryId.Create(DiscoveryId.NetworkScheme, $"ip:{ip}"),
+        Ip = ip
+    };
+
+    /// <summary>What the hypervisor knows about the same box.</summary>
+    private static SystemResource GuestCard(string name = "forgejo", string? ip = _ip) => new() {
+        Kind = SystemResource.KindLabel,
+        Name = name,
+        DiscoveryId = DiscoveryId.Create(ProxmoxResourceMapper.Scheme, "vmid-105"),
+        Type = "vm",
+        Os = "Linux",
+        Ip = ip,
+        Labels = { ["macs"] = _mac }
+    };
+
+    private static void Resolve(List<Resource> existing, List<Resource> incoming) =>
+        DiscoveryIdResolver.ResolveNames(existing, incoming, null, null, true);
+
+    [Fact]
+    public void The_hypervisor_claims_the_card_a_sweep_left_behind() {
+        // Sweep ran first. This is the direction that was silently broken.
+        List<Resource> existing = [ScanCard()];
+        List<Resource> incoming = [GuestCard()];
+
+        Resolve(existing, incoming);
+
+        // One card, not two: the guest lands on the stored one.
+        Assert.Equal(existing[0].Name, incoming[0].Name);
+    }
+
+    [Fact]
+    public void The_sweep_lands_on_the_card_the_hypervisor_left_behind() {
+        // The direction that already worked, kept honest.
+        List<Resource> existing = [GuestCard()];
+        List<Resource> incoming = [ScanCard()];
+
+        Resolve(existing, incoming);
+
+        Assert.Equal("forgejo", incoming[0].Name);
+    }
+
+    [Fact]
+    public void The_agent_identity_wins_whichever_way_round_it_arrives() {
+        // Arriving second, the hypervisor's id survives so the merge can upgrade the
+        // stored card from a stand-in to a real identity.
+        List<Resource> incoming = [GuestCard()];
+        Resolve([ScanCard()], incoming);
+        Assert.StartsWith("rpk1:pve:", incoming[0].DiscoveryId);
+
+        // Arriving second, the sweep's id is dropped so the merge cannot downgrade it.
+        incoming = [ScanCard()];
+        Resolve([GuestCard()], incoming);
+        Assert.Null(incoming[0].DiscoveryId);
+    }
+
+    [Fact]
+    public void The_placeholder_name_gives_way_to_the_real_one() {
+        // The point of unifying: the machine stops being a hash. This is the upgrade
+        // that could never fire while the cards stayed separate.
+        List<Resource> existing = [ScanCard()];
+
+        Resolve(existing, [GuestCard()]);
+
+        Assert.Equal("forgejo", existing[0].Name);
+    }
+
+    [Fact]
+    public void Two_stand_ins_at_one_address_still_unify_nothing() {
+        // Neither side knows anything the other does not, so an address proves nothing.
+        List<Resource> existing = [ScanCard()];
+        SystemResource other = ScanCard();
+        other.Name = "host-9999aaaa";
+        other.DiscoveryId = DiscoveryId.Create(DiscoveryId.NetworkScheme, "ip:other");
+
+        Resolve(existing, [other]);
+
+        Assert.Equal("host-9999aaaa", other.Name);
+    }
+
+    [Fact]
+    public void Two_agent_grade_cards_at_one_address_still_unify_nothing() {
+        // A guest and the machine-id of the OS inside it are both agent-grade. They are
+        // two cards on purpose; an address must not collapse them.
+        var fromAgent = new SystemResource {
+            Kind = SystemResource.KindLabel,
+            Name = "forgejo-inside",
+            DiscoveryId = DiscoveryId.Create(DiscoveryId.SystemScheme, "machine-a"),
+            Ip = _ip
+        };
+
+        Resolve([GuestCard()], [fromAgent]);
+
+        Assert.Equal("forgejo-inside", fromAgent.Name);
+    }
+
+    [Fact]
+    public void A_sweep_that_saw_a_mac_is_left_to_the_mac_bridge() {
+        // A MAC is better evidence than an address, whichever side is holding it. A scan
+        // card with one either unified on the MAC already or genuinely disagrees.
+        SystemResource scan = ScanCard();
+        scan.Labels["mac"] = "bc:24:11:00:4a:99";
+
+        Resolve([scan], [GuestCard()]);
+
+        Assert.Equal("host-2ed3bfd7", scan.Name);
+    }
+
+    [Fact]
+    public void A_different_kind_of_thing_at_the_same_address_is_not_the_same_thing() {
+        // The box documented as hardware and the OS a sweep saw on it are separate cards
+        // on purpose — the merge replaces on a type change, so unifying would delete one.
+        var server = new Server {
+            Kind = Server.KindLabel,
+            Name = "kepler",
+            DiscoveryId = DiscoveryId.Create(ProxmoxResourceMapper.Scheme, "node-kepler")
+        };
+
+        List<Resource> existing = [ScanCard()];
+
+        Resolve(existing, [server]);
+
+        Assert.Equal("kepler", server.Name);
+        Assert.Equal("host-2ed3bfd7", existing[0].Name);
+    }
+
+    [Fact]
+    public void An_address_two_stored_cards_claim_unifies_nothing() {
+        // Overlapping subnets, or a stale card. Ambiguity is not evidence.
+        SystemResource first = ScanCard();
+        SystemResource second = ScanCard();
+        second.Name = "host-bbbbcccc";
+        second.DiscoveryId = DiscoveryId.Create(DiscoveryId.NetworkScheme, "ip:dup");
+
+        List<Resource> incoming = [GuestCard()];
+
+        Resolve([first, second], incoming);
+
+        Assert.Equal("forgejo", incoming[0].Name);
+    }
+}

+ 96 - 0
Tests.Discovery/MacVendorLookupTests.cs

@@ -0,0 +1,96 @@
+using RackPeek.Domain.Discovery;
+
+namespace Tests.Discovery;
+
+/// <summary>
+///     The vendor a MAC implies. Every expectation here is a real IEEE assignment, and
+///     several are addresses observed on a live homelab — a lookup that silently drifted
+///     would be worse than no lookup, because a confident wrong vendor is unfalsifiable
+///     to whoever reads the card.
+/// </summary>
+public class MacVendorLookupTests {
+    [Theory]
+    [InlineData("bc:24:11:00:1a:01", "Proxmox")]
+    [InlineData("a0:36:9f:00:1a:02", "Intel")]
+    [InlineData("1c:6a:1b:00:1a:03", "Ubiquiti")]
+    [InlineData("48:b0:2d:00:1a:04", "NVIDIA")]
+    [InlineData("80:f3:da:00:1a:06", "Espressif")]
+    [InlineData("b8:27:eb:11:22:33", "Raspberry Pi")]
+    [InlineData("00:0c:29:aa:bb:cc", "VMware")]
+    public void Assigned_prefixes_resolve_to_their_owner(string mac, string expected) =>
+        Assert.Equal(expected, MacVendorLookup.Lookup(mac));
+
+    [Theory]
+    [InlineData("BC:24:11:14:59:DF")]
+    [InlineData("bc-24-11-14-59-df")]
+    [InlineData("bc2411145 9df")]
+    [InlineData("bc24.1114.59df")]
+    public void Separators_and_case_do_not_matter(string mac) =>
+        Assert.Equal("Proxmox", MacVendorLookup.Lookup(mac));
+
+    [Fact]
+    // KVM mints guest addresses from the locally-administered range rather than buying
+    // an OUI. Reporting "randomised" here would hide the most useful fact about the
+    // host — that it is a virtual machine.
+    public void A_hypervisors_own_prefix_beats_the_locally_administered_bit() =>
+        Assert.Equal("QEMU/KVM", MacVendorLookup.Lookup("52:54:00:12:34:56"));
+
+    [Theory]
+    [InlineData("92:16:01:00:1a:07")]
+    [InlineData("6a:34:ce:00:1a:08")]
+    public void Self_assigned_addresses_are_reported_as_randomised(string mac) {
+        // A phone or laptop randomising per network. The OUI half names no one, so
+        // saying so beats reporting whichever company owns the matching block.
+        Assert.Equal("Randomised (locally administered)", MacVendorLookup.Lookup(mac));
+        Assert.True(MacVendorLookup.IsLocallyAdministered(mac));
+    }
+
+    [Fact]
+    public void A_universally_administered_address_is_not_flagged_as_randomised() =>
+        Assert.False(MacVendorLookup.IsLocallyAdministered("bc:24:11:00:1a:01"));
+
+    [Theory]
+    [InlineData(null)]
+    [InlineData("")]
+    [InlineData("   ")]
+    [InlineData("not-a-mac")]
+    [InlineData("bc:24")]
+    public void Unreadable_input_yields_no_vendor(string? mac) {
+        Assert.Null(MacVendorLookup.Lookup(mac));
+        Assert.False(MacVendorLookup.IsLocallyAdministered(mac));
+    }
+
+    [Fact]
+    // 00:00:00 is assigned to Xerox and deliberately not in the curated table. Null
+    // means "not known", which is honest; a nearest-match would not be.
+    public void An_unlisted_but_assigned_prefix_is_null_rather_than_a_guess() =>
+        Assert.Null(MacVendorLookup.Lookup("00:00:00:11:22:33"));
+
+    [Fact]
+    public void Every_packed_record_resolves_to_a_real_vendor_name() {
+        // Guards the generated table's index encoding: an off-by-one in the index char
+        // would map prefixes to the wrong vendor, or off the end of the array. Also
+        // proves the records are sorted, which the binary search depends on.
+        var records = MacVendorTable.Records;
+        var count = records.Length / MacVendorTable.RecordLength;
+
+        Assert.True(count > 1000, $"The table holds only {count} records — did generation fail?");
+
+        string? previous = null;
+
+        for (var i = 0; i < count; i++) {
+            var prefix = records.Substring(i * MacVendorTable.RecordLength, 6);
+
+            if (previous != null)
+                Assert.True(
+                    string.CompareOrdinal(previous, prefix) < 0,
+                    $"Records are not sorted ascending: {previous} precedes {prefix}.");
+
+            previous = prefix;
+
+            Assert.False(
+                string.IsNullOrWhiteSpace(MacVendorLookup.Lookup(prefix + "000000")),
+                $"{prefix} resolved to nothing.");
+        }
+    }
+}

+ 217 - 0
Tests.Discovery/NetworkIdentityTests.cs

@@ -0,0 +1,217 @@
+using RackPeek.Domain.Discovery;
+using RackPeek.Domain.Resources;
+using RackPeek.Domain.Resources.Services;
+using RackPeek.Domain.Resources.SystemResources;
+
+namespace Tests.Discovery;
+
+/// <summary>
+///     How a swept host gets a name and a vendor. Sweeping a homelab that has no PTR
+///     records used to produce a page of "host-&lt;hash&gt;" cards; these tests own the
+///     rules that turn those into recognisable machines.
+/// </summary>
+public class NetworkIdentityTests {
+    private static NetworkHostFact Host(
+        string ip,
+        string? mac = null,
+        string? hostname = null,
+        ServiceIdentity? identity = null,
+        string? vendor = null,
+        IReadOnlyList<ServiceIdentity>? services = null) =>
+        new(ip, mac, hostname, true, []) {
+            Identity = identity,
+            Vendor = vendor,
+            Services = services ?? []
+        };
+
+    private static SystemResource Single(params NetworkHostFact[] hosts) =>
+        Assert.IsType<SystemResource>(Assert.Single(NetworkScanMapper.ToResources(hosts)));
+
+    [Fact]
+    public void A_certificate_name_becomes_the_card_name() {
+        SystemResource card = Single(Host(
+            "192.0.2.13",
+            identity: new ServiceIdentity("pve-node-01.example.com", IdentitySource.TlsCertificate, 8006)));
+
+        // The label is the host part, as it is for a PTR name.
+        Assert.Equal("pve-node-01", card.Name);
+    }
+
+    [Fact]
+    public void A_ptr_record_still_wins_over_a_service_banner() {
+        // The network's own answer beats whatever a certificate happens to say — a
+        // stale or copied certificate must never rename a host DNS already knows.
+        SystemResource card = Single(Host(
+            "192.0.2.13",
+            hostname: "pve-01.lan",
+            identity: new ServiceIdentity("pve-node-01.example.com", IdentitySource.TlsCertificate, 8006)));
+
+        Assert.Equal("pve-01", card.Name);
+    }
+
+    [Fact]
+    public void Without_any_name_the_card_still_falls_back_to_the_hash() {
+        SystemResource card = Single(Host("192.0.2.111"));
+
+        Assert.StartsWith("host-", card.Name);
+    }
+
+    [Fact]
+    public void What_the_host_said_is_recorded_even_when_it_named_the_card() {
+        SystemResource card = Single(Host(
+            "192.0.2.204",
+            identity: new ServiceIdentity("Home Assistant", IdentitySource.Http, 8123)));
+
+        // Whoever reads the card can see the name came from an HTTP title on 8123 and
+        // judge it accordingly, rather than trusting a name of unknown provenance.
+        Assert.Equal("http:8123 Home Assistant", card.Labels["identified-by"]);
+    }
+
+    [Fact]
+    public void An_ssh_greeting_annotates_but_never_names() {
+        // Every Linux box on a subnet runs the same daemon. Naming from the greeting
+        // produced five cards called "openssh" on a real sweep — no more use than five
+        // called "host-<hash>", and misleading about what was actually identified.
+        SystemResource card = Single(Host(
+            "192.0.2.209",
+            identity: new ServiceIdentity("OpenSSH", IdentitySource.SshBanner, 22)));
+
+        Assert.StartsWith("host-", card.Name);
+        Assert.Equal("ssh:22 OpenSSH", card.Labels["identified-by"]);
+    }
+
+    [Fact]
+    public void An_application_that_named_itself_over_http_becomes_a_service_on_its_host() {
+        List<Resource> cards = NetworkScanMapper.ToResources([
+            Host("192.0.2.204", services: [new ServiceIdentity("Home Assistant", IdentitySource.Http, 8123)])
+        ]);
+
+        SystemResource host = Assert.Single(cards.OfType<SystemResource>());
+        Service service = Assert.Single(cards.OfType<Service>());
+
+        Assert.Equal("home-assistant", service.Name);
+        Assert.Equal([host.Name], service.RunsOn);
+        Assert.Equal("192.0.2.204", service.Network?.Ip);
+        Assert.Equal(8123, service.Network?.Port);
+    }
+
+    [Fact]
+    public void A_page_title_names_the_service_and_leaves_the_host_a_hash() {
+        // A host may run several applications, so naming the machine after whichever
+        // answered first is arbitrary. Only a certificate names the machine itself.
+        List<Resource> cards = NetworkScanMapper.ToResources([
+            Host("192.0.2.204", services: [new ServiceIdentity("Home Assistant", IdentitySource.Http, 8123)])
+        ]);
+
+        Assert.StartsWith("host-", Assert.Single(cards.OfType<SystemResource>()).Name);
+    }
+
+    [Fact]
+    public void Several_applications_on_one_host_each_get_their_own_service() {
+        List<Resource> cards = NetworkScanMapper.ToResources([
+            Host("192.0.2.105", services: [
+                new ServiceIdentity("Portainer", IdentitySource.Http, 9000),
+                new ServiceIdentity("Grafana", IdentitySource.Http, 3000)
+            ])
+        ]);
+
+        var services = cards.OfType<Service>().ToList();
+
+        Assert.Equal(2, services.Count);
+        Assert.Equal([9000, 3000], services.Select(s => s.Network!.Port));
+        // Each port is its own thing, so each keeps its own stable id.
+        Assert.Equal(2, services.Select(s => s.DiscoveryId).Distinct().Count());
+    }
+
+    [Fact]
+    public void An_appliances_own_management_page_is_named_for_the_port_it_serves() {
+        // A firewall whose page says "OPNsense" on a card already called opnsense would
+        // otherwise gain a second card named opnsense-<hash>, saying nothing new. The
+        // port is what distinguishes the service from the box it runs on.
+        List<Resource> cards = NetworkScanMapper.ToResources([
+            Host(
+                "192.0.2.101",
+                identity: new ServiceIdentity("OPNsense.localdomain", IdentitySource.TlsCertificate, 443),
+                services: [new ServiceIdentity("OPNsense", IdentitySource.Http, 80)])
+        ]);
+
+        Assert.Equal("opnsense", Assert.Single(cards.OfType<SystemResource>()).Name);
+        Assert.Equal("opnsense-http", Assert.Single(cards.OfType<Service>()).Name);
+    }
+
+    [Fact]
+    public void A_services_id_survives_a_rescan_and_differs_per_port() {
+        NetworkHostFact host = Host(
+            "192.0.2.105",
+            "bc:24:11:00:1a:01",
+            services: [
+                new ServiceIdentity("Portainer", IdentitySource.Http, 9000),
+                new ServiceIdentity("Grafana", IdentitySource.Http, 3000)
+            ]);
+
+        var first = NetworkScanMapper.ToResources([host]).OfType<Service>().Select(s => s.DiscoveryId).ToList();
+        var second = NetworkScanMapper.ToResources([host]).OfType<Service>().Select(s => s.DiscoveryId).ToList();
+
+        Assert.Equal(first, second);
+        Assert.Equal(2, first.Distinct().Count());
+    }
+
+    [Fact]
+    public void A_vendor_is_labelled_when_the_mac_is_known() {
+        SystemResource card = Single(Host("192.0.2.64", "80:f3:da:00:1a:06", vendor: "Espressif"));
+
+        Assert.Equal("Espressif", card.Labels["nic-vendor"]);
+        Assert.Equal("80:f3:da:00:1a:06", card.Labels["mac"]);
+    }
+
+    [Fact]
+    public void No_vendor_label_is_invented_when_none_is_known() {
+        SystemResource card = Single(Host("192.0.2.111", "00:00:00:11:22:33"));
+
+        Assert.False(card.Labels.ContainsKey("nic-vendor"));
+    }
+
+    [Fact]
+    public void The_card_stays_sparse_whatever_identification_found() {
+        // The sparseness contract: a scan sees an address, never an OS or a core count.
+        // Anything written here would overwrite the real values on the next rescan of a
+        // card an agent collector has since filled in.
+        SystemResource card = Single(Host(
+            "192.0.2.13",
+            "bc:24:11:00:1a:01",
+            identity: new ServiceIdentity("pve-node-01.example.com", IdentitySource.TlsCertificate, 8006),
+            vendor: "Proxmox"));
+
+        Assert.Null(card.Type);
+        Assert.Null(card.Os);
+        Assert.Null(card.Cores);
+        Assert.Null(card.Ram);
+        Assert.Equal("192.0.2.13", card.Ip);
+    }
+
+    [Fact]
+    public void Identity_does_not_move_a_hosts_discovery_id() {
+        // Identity is seeded on the MAC; a name learned from a service must not change
+        // it, or every card would be recreated the day someone fixes a certificate.
+        NetworkHostFact withoutName = Host("192.0.2.13", "bc:24:11:00:1a:01");
+        NetworkHostFact withName = Host(
+            "192.0.2.13",
+            "bc:24:11:00:1a:01",
+            identity: new ServiceIdentity("pve-node-01", IdentitySource.TlsCertificate, 8006));
+
+        Assert.Equal(
+            Single(withoutName).DiscoveryId,
+            Single(withName).DiscoveryId);
+    }
+
+    [Fact]
+    public void Two_hosts_naming_themselves_the_same_still_get_distinct_names() {
+        // Every OPNsense VLAN gateway presents the same certificate CN.
+        List<Resource> cards = NetworkScanMapper.ToResources([
+            Host("192.0.2.101", identity: new ServiceIdentity("OPNsense.localdomain", IdentitySource.TlsCertificate, 443)),
+            Host("192.0.2.141", identity: new ServiceIdentity("OPNsense.localdomain", IdentitySource.TlsCertificate, 443))
+        ]);
+
+        Assert.Equal(2, cards.Select(c => c.Name).Distinct(StringComparer.OrdinalIgnoreCase).Count());
+    }
+}

+ 204 - 1
Tests.Discovery/NetworkScannerTests.cs

@@ -9,14 +9,175 @@ namespace Tests.Discovery;
 ///     The probe is the IO seam — everything above it is what these tests own.
 ///     The probe is the IO seam — everything above it is what these tests own.
 /// </summary>
 /// </summary>
 public class NetworkScannerTests {
 public class NetworkScannerTests {
+    // Identification is off unless a test asks for it, so the liveness tests keep
+    // measuring only liveness.
     private static NetworkScanOptions Options(string cidr = "10.0.0.0/30", params int[] ports) =>
     private static NetworkScanOptions Options(string cidr = "10.0.0.0/30", params int[] ports) =>
         new() {
         new() {
             Cidr = Cidr.Parse(cidr),
             Cidr = Cidr.Parse(cidr),
             Ports = ports.Length > 0 ? ports : [22, 80],
             Ports = ports.Length > 0 ? ports : [22, 80],
             PingTimeout = TimeSpan.FromMilliseconds(5),
             PingTimeout = TimeSpan.FromMilliseconds(5),
-            PortTimeout = TimeSpan.FromMilliseconds(5)
+            PortTimeout = TimeSpan.FromMilliseconds(5),
+            IdentifyServices = false
         };
         };
 
 
+    private static NetworkScanOptions IdentifyingOptions(string cidr = "10.0.0.0/30") =>
+        Options(cidr) with {
+            IdentifyServices = true,
+            IdentifyTimeout = TimeSpan.FromMilliseconds(5)
+        };
+
+    [Fact]
+    public async Task The_port_list_the_liveness_check_cut_short_is_finished_for_the_living() {
+        var probe = new ScriptedProbe { OpenPorts = [("10.0.0.2", 22), ("10.0.0.2", 80)] };
+
+        IReadOnlyList<NetworkHostFact> hosts = await NetworkScanner.ScanAsync(
+            probe,
+            IdentifyingOptions() with { Ports = [22, 80] });
+
+        // Liveness stopped at 22; the interrogation pass goes back for the rest, so the
+        // card records what the host actually serves rather than the first thing tried.
+        Assert.Equal([22, 80], Assert.Single(hosts).OpenPorts);
+    }
+
+    [Fact]
+    public async Task A_host_that_answered_ping_still_gets_its_ports_inventoried() {
+        // Liveness skips port probing entirely once ping answers, which used to leave
+        // every pingable host with no port evidence at all.
+        var probe = new ScriptedProbe {
+            PingReplies = ["10.0.0.1"],
+            OpenPorts = [("10.0.0.1", 80)]
+        };
+
+        IReadOnlyList<NetworkHostFact> hosts = await NetworkScanner.ScanAsync(
+            probe,
+            IdentifyingOptions() with { Ports = [22, 80] });
+
+        Assert.Equal([80], Assert.Single(hosts).OpenPorts);
+    }
+
+    [Fact]
+    public async Task A_dead_host_is_never_interrogated() {
+        var probe = new ScriptedProbe();
+
+        await NetworkScanner.ScanAsync(probe, IdentifyingOptions() with { Ports = [22, 80] });
+
+        Assert.Empty(probe.IdentityProbes);
+    }
+
+    [Fact]
+    public async Task Only_living_hosts_are_asked_what_they_are() {
+        // 443 has to be open for it to be asked: only ports the sweep found listening
+        // are interrogated, so a closed port costs no handshake.
+        var probe = new ScriptedProbe {
+            PingReplies = ["10.0.0.1"],
+            OpenPorts = [("10.0.0.1", 443)],
+            TlsSubjects = { [("10.0.0.1", 443)] = "CN=router.lan" }
+        };
+
+        IReadOnlyList<NetworkHostFact> hosts = await NetworkScanner.ScanAsync(probe, IdentifyingOptions());
+
+        Assert.Equal("router.lan", Assert.Single(hosts).Identity?.Name);
+        // 10.0.0.2 answered nothing, so it was never asked.
+        Assert.DoesNotContain(probe.IdentityProbes, p => p.Ip == "10.0.0.2");
+    }
+
+    [Fact]
+    public async Task A_certificate_names_the_host_even_when_other_ports_also_answer() {
+        var probe = new ScriptedProbe {
+            PingReplies = ["10.0.0.1"],
+            OpenPorts = [("10.0.0.1", 443), ("10.0.0.1", 22)],
+            TlsSubjects = { [("10.0.0.1", 443)] = "CN=pve-node-01.example.com" },
+            Banners = { [("10.0.0.1", 22)] = "SSH-2.0-OpenSSH_9.6" }
+        };
+
+        IReadOnlyList<NetworkHostFact> hosts = await NetworkScanner.ScanAsync(probe, IdentifyingOptions());
+
+        NetworkHostFact host = Assert.Single(hosts);
+        // Every open port is asked — an SSH greeting is still worth recording — but a
+        // certificate is the only answer that is a claim about the machine itself.
+        Assert.Equal("pve-node-01.example.com", host.Identity?.Name);
+        Assert.Equal(IdentitySource.TlsCertificate, host.Identity?.Source);
+    }
+
+    [Fact]
+    public async Task A_page_title_outranks_an_ssh_greeting() {
+        var probe = new ScriptedProbe {
+            PingReplies = ["10.0.0.1"],
+            OpenPorts = [("10.0.0.1", 22), ("10.0.0.1", 80)],
+            Banners = { [("10.0.0.1", 22)] = "SSH-2.0-dropbear" },
+            HttpHeads = { [("10.0.0.1", 80)] = "HTTP/1.0 200 OK\r\n\r\n<title>Home Assistant</title>" }
+        };
+
+        IReadOnlyList<NetworkHostFact> hosts = await NetworkScanner.ScanAsync(probe, IdentifyingOptions());
+
+        // Every Linux box answers SSH with the same daemon name; the title says which
+        // box this actually is, so SSH is asked last.
+        Assert.Equal("Home Assistant", Assert.Single(hosts).Identity?.Name);
+    }
+
+    [Fact]
+    public async Task An_open_port_the_sweep_found_is_also_asked() {
+        // The liveness sweep already paid to learn 8123 was open. A service on a port
+        // nobody curated is exactly the one worth asking.
+        var probe = new ScriptedProbe {
+            OpenPorts = [("10.0.0.2", 8123)],
+            HttpHeads = { [("10.0.0.2", 8123)] = "HTTP/1.0 200 OK\r\n\r\n<title>Home Assistant</title>" }
+        };
+
+        IReadOnlyList<NetworkHostFact> hosts = await NetworkScanner.ScanAsync(
+            probe,
+            IdentifyingOptions() with { Ports = [8123] });
+
+        NetworkHostFact host = Assert.Single(hosts);
+        Assert.Equal("Home Assistant", host.Identity?.Name);
+        Assert.Equal(8123, host.Identity?.Port);
+    }
+
+    [Fact]
+    public async Task A_host_that_says_nothing_is_still_reported() {
+        var probe = new ScriptedProbe { PingReplies = ["10.0.0.1"] };
+
+        IReadOnlyList<NetworkHostFact> hosts = await NetworkScanner.ScanAsync(probe, IdentifyingOptions());
+
+        NetworkHostFact host = Assert.Single(hosts);
+        Assert.Null(host.Identity);
+        Assert.Equal("10.0.0.1", host.Ip);
+    }
+
+    [Fact]
+    public async Task Turning_identification_off_asks_nothing() {
+        var probe = new ScriptedProbe {
+            PingReplies = ["10.0.0.1"],
+            TlsSubjects = { [("10.0.0.1", 443)] = "CN=router.lan" }
+        };
+
+        IReadOnlyList<NetworkHostFact> hosts = await NetworkScanner.ScanAsync(probe, Options());
+
+        Assert.Null(Assert.Single(hosts).Identity);
+        Assert.Empty(probe.IdentityProbes);
+    }
+
+    [Fact]
+    public async Task A_hosts_vendor_comes_from_its_arp_mac() {
+        var probe = new ScriptedProbe {
+            PingReplies = ["10.0.0.1"],
+            Arp = "? (10.0.0.1) at bc:24:11:00:1a:01 on en0 ifscope [ethernet]"
+        };
+
+        IReadOnlyList<NetworkHostFact> hosts = await NetworkScanner.ScanAsync(probe, Options());
+
+        Assert.Equal("Proxmox", Assert.Single(hosts).Vendor);
+    }
+
+    [Fact]
+    public async Task A_host_with_no_arp_entry_has_no_vendor() {
+        var probe = new ScriptedProbe { PingReplies = ["10.0.0.1"] };
+
+        IReadOnlyList<NetworkHostFact> hosts = await NetworkScanner.ScanAsync(probe, Options());
+
+        Assert.Null(Assert.Single(hosts).Vendor);
+    }
+
     [Fact]
     [Fact]
     public async Task A_host_that_answers_nothing_is_not_reported() {
     public async Task A_host_that_answers_nothing_is_not_reported() {
         var probe = new ScriptedProbe();
         var probe = new ScriptedProbe();
@@ -118,8 +279,13 @@ public class NetworkScannerTests {
         public Dictionary<string, string> Names { get; } = [];
         public Dictionary<string, string> Names { get; } = [];
         public TimeSpan PingDelay { get; init; } = TimeSpan.Zero;
         public TimeSpan PingDelay { get; init; } = TimeSpan.Zero;
 
 
+        public Dictionary<(string Ip, int Port), string> TlsSubjects { get; } = [];
+        public Dictionary<(string Ip, int Port), string> Banners { get; } = [];
+        public Dictionary<(string Ip, int Port), string> HttpHeads { get; } = [];
+
         public List<(string Ip, int Port)> PortProbes { get; } = [];
         public List<(string Ip, int Port)> PortProbes { get; } = [];
         public List<string> DnsLookups { get; } = [];
         public List<string> DnsLookups { get; } = [];
+        public List<(string Ip, int Port, string Kind)> IdentityProbes { get; } = [];
         public bool IsSupported => true;
         public bool IsSupported => true;
         public int MaxInFlight { get; private set; }
         public int MaxInFlight { get; private set; }
         public bool ArpReadAfterSweep { get; private set; }
         public bool ArpReadAfterSweep { get; private set; }
@@ -178,6 +344,43 @@ public class NetworkScannerTests {
             return Task.FromResult(Names.GetValueOrDefault(ip));
             return Task.FromResult(Names.GetValueOrDefault(ip));
         }
         }
 
 
+        public Task<string?> ReadTlsSubjectAsync(
+            string ip,
+            int port,
+            TimeSpan timeout,
+            CancellationToken cancellationToken = default) {
+            lock (_lock) {
+                IdentityProbes.Add((ip, port, "tls"));
+            }
+
+            return Task.FromResult(TlsSubjects.GetValueOrDefault((ip, port)));
+        }
+
+        public Task<string?> ReadTcpBannerAsync(
+            string ip,
+            int port,
+            TimeSpan timeout,
+            CancellationToken cancellationToken = default) {
+            lock (_lock) {
+                IdentityProbes.Add((ip, port, "banner"));
+            }
+
+            return Task.FromResult(Banners.GetValueOrDefault((ip, port)));
+        }
+
+        public Task<string?> ReadHttpHeadAsync(
+            string ip,
+            int port,
+            bool tls,
+            TimeSpan timeout,
+            CancellationToken cancellationToken = default) {
+            lock (_lock) {
+                IdentityProbes.Add((ip, port, "http"));
+            }
+
+            return Task.FromResult(HttpHeads.GetValueOrDefault((ip, port)));
+        }
+
         public Cidr? LocalSubnet() => null;
         public Cidr? LocalSubnet() => null;
     }
     }
 }
 }

+ 115 - 0
Tests.Discovery/OpenPortServiceTests.cs

@@ -0,0 +1,115 @@
+using RackPeek.Domain.Discovery;
+using RackPeek.Domain.Resources;
+using RackPeek.Domain.Resources.Services;
+using RackPeek.Domain.Resources.SystemResources;
+
+namespace Tests.Discovery;
+
+/// <summary>
+///     An open port is a service, not a note on the host.
+///     <para>
+///         It used to land in an "open-ports" label — a comma-separated string that
+///         nothing could link to, filter on, or hang a note from. RackPeek already has a
+///         resource for "a thing listening on an address and a port", so a sweep that
+///         finds 22 open on nebula should produce a Service called nebula-ssh running on
+///         nebula, exactly as if someone had written it in by hand.
+///     </para>
+/// </summary>
+public class OpenPortServiceTests {
+    private static List<Resource> Scan(string ip, string? mac, params int[] ports) =>
+        NetworkScanMapper.ToResources([
+            new NetworkHostFact(ip, mac, "nebula", true, ports)
+        ]);
+
+    private static List<Service> Services(params int[] ports) =>
+        Scan("192.0.2.20", "bc:24:11:00:2a:01", ports).OfType<Service>().ToList();
+
+    [Fact]
+    public void An_open_port_is_no_longer_a_label() {
+        SystemResource host = Scan("192.0.2.20", "bc:24:11:00:2a:01", 22, 80)
+            .OfType<SystemResource>()
+            .Single();
+
+        Assert.False(host.Labels.ContainsKey("open-ports"));
+    }
+
+    [Fact]
+    public void Each_open_port_becomes_a_service_on_the_host() {
+        List<Service> services = Services(22, 80);
+
+        Assert.Equal(2, services.Count);
+        Assert.All(services, s => Assert.Equal(["nebula"], s.RunsOn));
+    }
+
+    [Fact]
+    public void A_service_is_named_for_the_host_and_what_the_port_serves() {
+        Service ssh = Assert.Single(Services(22));
+
+        Assert.Equal("nebula-ssh", ssh.Name);
+    }
+
+    [Theory]
+    [InlineData(443, "nebula-https")]
+    [InlineData(445, "nebula-smb")]
+    [InlineData(1883, "nebula-mqtt")]
+    [InlineData(8006, "nebula-proxmox")]
+    [InlineData(32400, "nebula-plex")]
+    public void Well_known_ports_are_named_by_what_they_serve(int port, string expected) =>
+        Assert.Equal(expected, Assert.Single(Services(port)).Name);
+
+    [Fact]
+    // Better an honest tcp-9987 than a guess: the number is the only fact available.
+    public void An_unrecognised_port_keeps_its_number() =>
+        Assert.Equal("nebula-tcp-9987", Assert.Single(Services(9987)).Name);
+
+    [Fact]
+    public void A_service_that_named_itself_beats_what_its_port_implies() {
+        // A port is a convention and an answer is evidence. Home Assistant on 8123 is
+        // the convention; a page that says "Forgejo" is the machine telling you.
+        List<Resource> resources = NetworkScanMapper.ToResources([
+            new NetworkHostFact("192.0.2.21", "bc:24:11:00:2a:02", "nebula", true, [8123]) {
+                Services = [new ServiceIdentity("Forgejo", IdentitySource.Http, 8123)]
+            }
+        ]);
+
+        Assert.Equal("forgejo", Assert.Single(resources.OfType<Service>()).Name);
+    }
+
+    [Fact]
+    public void A_service_records_where_it_is_listening() {
+        Service ssh = Assert.Single(Services(22));
+
+        Assert.Equal("192.0.2.20", ssh.Network?.Ip);
+        Assert.Equal(22, ssh.Network?.Port);
+        Assert.Equal("TCP", ssh.Network?.Protocol);
+    }
+
+    [Fact]
+    public void A_services_identity_is_its_hosts_identity_and_the_port() {
+        // Stable across rescans, and distinct per port, so a rescan updates the same two
+        // cards rather than inventing a pair every time.
+        var first = Services(22, 80).Select(s => s.DiscoveryId).ToList();
+        var second = Services(22, 80).Select(s => s.DiscoveryId).ToList();
+
+        Assert.Equal(first, second);
+        Assert.Equal(2, first.Distinct().Count());
+    }
+
+    [Fact]
+    public void Two_hosts_running_the_same_thing_get_distinct_cards() {
+        // Naming after the host is what keeps these apart — "ssh" alone would collide on
+        // every machine in the rack.
+        List<Resource> resources = NetworkScanMapper.ToResources([
+            new NetworkHostFact("192.0.2.30", "bc:24:11:00:2a:03", "nebula", true, [22]),
+            new NetworkHostFact("192.0.2.31", "bc:24:11:00:2a:04", "orion", true, [22])
+        ]);
+
+        Assert.Equal(
+            ["nebula-ssh", "orion-ssh"],
+            resources.OfType<Service>().Select(s => s.Name).Order());
+    }
+
+    [Fact]
+    public void A_host_with_nothing_listening_yields_no_services() =>
+        Assert.Empty(Scan("192.0.2.40", "bc:24:11:00:2a:05").OfType<Service>());
+}

+ 177 - 0
Tests.Discovery/OpnsenseDiscoveryTests.cs

@@ -0,0 +1,177 @@
+using RackPeek.Domain.Discovery;
+using RackPeek.Domain.Resources;
+using RackPeek.Domain.Resources.SystemResources;
+
+namespace Tests.Discovery;
+
+/// <summary>
+///     A firewall's neighbour table in, System resources out.
+///     <para>
+///         This is the collector for everything a sweep can see but not identify. ARP is
+///         link-local, so sweeping from one host yields a MAC for that host's own segment
+///         and an address for everything else — and an address alone cannot survive a
+///         DHCP re-lease or be matched to anything. The firewall routes every subnet, so
+///         its table has the MAC, the name it handed out, and which leg it was seen on.
+///     </para>
+/// </summary>
+public class OpnsenseDiscoveryTests {
+    private static List<OpnsenseNeighbour> Neighbours() =>
+        OpnsenseResponseParser.ParseArp(Fixture.Read("opnsense-arp.json"));
+
+    private static List<Resource> Discover(bool includePublic = false) =>
+        OpnsenseDiscovery.ToResources(Neighbours(), includePublic);
+
+    [Fact]
+    public void The_table_yields_one_neighbour_per_machine() {
+        List<OpnsenseNeighbour> neighbours = Neighbours();
+
+        // Of seven rows: one is the firewall's own address, one has aged out, one is the
+        // broadcast address, and one is a second sighting of a host that answers on two
+        // of the firewall's legs.
+        Assert.Equal(3, neighbours.Count);
+    }
+
+    [Fact]
+    // Every routed subnet contributes one, and they are all the same box — which is a
+    // Firewall, not the handful of Systems this would otherwise invent.
+    public void An_address_the_firewall_holds_itself_is_not_a_neighbour() =>
+        Assert.DoesNotContain(Neighbours(), n => n.Mac == "00:1b:21:00:1a:02");
+
+    [Fact]
+    // It says where something used to be, which is not evidence that it is there now.
+    public void An_entry_that_has_aged_out_is_not_a_neighbour() =>
+        Assert.DoesNotContain(Neighbours(), n => n.Hostname == "gone-away");
+
+    [Fact]
+    public void The_broadcast_address_is_not_a_machine() =>
+        Assert.DoesNotContain(Neighbours(), n => n.Mac.StartsWith("ff:", StringComparison.Ordinal));
+
+    [Fact]
+    // Its identity is the MAC, so two sightings must not become two machines.
+    public void A_host_seen_on_two_legs_is_still_one_card() =>
+        Assert.Single(Discover().OfType<SystemResource>(), s => s.Labels["mac"] == "1c:6a:1b:00:1a:03");
+
+    [Fact]
+    // It is not the user's infrastructure, and recording it would put a public address
+    // into a file people commit.
+    public void The_isps_equipment_on_the_wan_leg_is_left_out_by_default() =>
+        Assert.DoesNotContain(Discover().OfType<SystemResource>(), s => s.Ip == "198.51.100.7");
+
+    [Fact]
+    // A fleet documented on public addresses is a real case; it just is not the default.
+    public void Public_neighbours_can_be_asked_for() =>
+        Assert.Contains(Discover(true).OfType<SystemResource>(), s => s.Ip == "198.51.100.7");
+
+    [Theory]
+    [InlineData("10.0.0.5")]
+    [InlineData("172.16.4.9")]
+    [InlineData("172.31.255.254")]
+    [InlineData("192.168.1.1")]
+    [InlineData("100.64.0.1")] // carrier-grade NAT
+    public void Private_and_carrier_ranges_count_as_ones_own_network(string ip) =>
+        Assert.True(OpnsenseDiscovery.IsPrivate(ip));
+
+    [Theory]
+    [InlineData("198.51.100.7")]
+    [InlineData("8.8.8.8")]
+    [InlineData("172.15.0.1")] // just below the private block
+    [InlineData("172.32.0.1")] // just above it
+    [InlineData("100.63.0.1")] // just below the carrier block
+    [InlineData("not-an-address")]
+    public void Everything_else_does_not(string ip) =>
+        Assert.False(OpnsenseDiscovery.IsPrivate(ip));
+
+    [Fact]
+    // The whole point: a sweep of another subnet can only call this host-<hash>.
+    public void The_name_the_firewall_handed_out_becomes_the_card_name() =>
+        Assert.Contains(Discover().OfType<SystemResource>(), s => s.Name == "forgejo");
+
+    [Fact]
+    public void A_neighbour_with_no_name_still_gets_a_stable_one() {
+        List<Resource> cards = Discover(true);
+
+        SystemResource wan = cards.OfType<SystemResource>().Single(s => s.Ip == "198.51.100.7");
+        Assert.StartsWith("host-", wan.Name);
+    }
+
+    [Fact]
+    public void A_card_carries_the_mac_and_who_made_the_nic() {
+        SystemResource card = Discover().OfType<SystemResource>().Single(s => s.Name == "forgejo");
+
+        Assert.Equal("bc:24:11:00:1a:04", card.Labels["mac"]);
+        Assert.Equal("Proxmox", card.Labels["nic-vendor"]);
+        Assert.Equal("192.168.50.105", card.Ip);
+    }
+
+    [Fact]
+    public void Which_leg_of_the_firewall_it_answered_on_is_not_recorded() {
+        // It describes the firewall's wiring, not the machine, and it changes the moment
+        // anything is re-cabled or a VLAN is renamed.
+        SystemResource card = Discover().OfType<SystemResource>().Single(s => s.Name == "forgejo");
+
+        Assert.False(card.Labels.ContainsKey("segment"));
+    }
+
+    [Fact]
+    public void The_firewalls_own_vendor_lookup_fills_the_gaps_in_ours() {
+        // Ours is a curated subset, so it answers "Proxmox" where the firewall says
+        // "Proxmox Server Solutions GmbH" — but the firewall carries the whole registry
+        // and answers for prefixes ours has never heard of.
+        List<Resource> cards = Discover(true);
+
+        SystemResource wan = cards.OfType<SystemResource>().Single(s => s.Ip == "198.51.100.7");
+        Assert.Equal("Cisco Systems", wan.Labels["nic-vendor"]);
+    }
+
+    [Fact]
+    public void A_card_is_seeded_exactly_as_a_sweep_would_seed_it() {
+        // The contract that makes the two collectors interchangeable: same evidence, same
+        // identity, so a host the firewall knows and a host a sweep found are one card
+        // whichever ran first — with no special case anywhere to say so.
+        SystemResource fromFirewall = Discover().OfType<SystemResource>().Single(s => s.Name == "forgejo");
+
+        SystemResource fromSweep = NetworkScanMapper.ToResources([
+            new NetworkHostFact("192.168.50.105", "bc:24:11:00:1a:04", null, true, [])
+        ]).OfType<SystemResource>().Single();
+
+        Assert.Equal(fromSweep.DiscoveryId, fromFirewall.DiscoveryId);
+    }
+
+    [Fact]
+    public void The_cards_stay_sparse() {
+        // The firewall knows where a machine is and what its NIC is, never what runs on
+        // it. A guess here would overwrite the real values on the next run of a collector
+        // that does know.
+        SystemResource card = Discover().OfType<SystemResource>().Single(s => s.Name == "forgejo");
+
+        Assert.Null(card.Type);
+        Assert.Null(card.Os);
+        Assert.Null(card.Cores);
+        Assert.Null(card.Ram);
+    }
+
+    [Fact]
+    public void The_paginated_shape_reads_the_same_as_the_flat_one() {
+        // The search wrapper returns {rows: [...]} while the direct call returns a bare
+        // array; a caller should not have to know which endpoint answered.
+        var rows = $$"""{"rows": {{Fixture.Read("opnsense-arp.json")}}, "total": 7}""";
+
+        Assert.Equal(
+            Neighbours().Select(n => n.Mac),
+            OpnsenseResponseParser.ParseArp(rows).Select(n => n.Mac));
+    }
+
+    [Theory]
+    [InlineData("""[{"mac":"bc:24:11:00:1a:04","ip":"192.168.50.105","permanent":"1"}]""")]
+    [InlineData("""[{"mac":"bc:24:11:00:1a:04","ip":"192.168.50.105","permanent":1}]""")]
+    [InlineData("""[{"mac":"bc:24:11:00:1a:04","ip":"192.168.50.105","permanent":true}]""")]
+    public void A_flag_is_read_however_the_firmware_spells_it(string json) =>
+        // OPNsense writes these as real booleans in some versions and as "1" in others.
+        Assert.Empty(OpnsenseResponseParser.ParseArp(json));
+
+    [Fact]
+    public void An_empty_table_is_not_an_error() {
+        Assert.Empty(OpnsenseResponseParser.ParseArp("[]"));
+        Assert.Empty(OpnsenseResponseParser.ParseArp("""{"rows":[],"total":0}"""));
+    }
+}

+ 321 - 0
Tests.Discovery/PlaceholderNameUpgradeTests.cs

@@ -0,0 +1,321 @@
+using RackPeek.Domain.Discovery;
+using RackPeek.Domain.Resources;
+using RackPeek.Domain.Resources.Connections;
+using RackPeek.Domain.Resources.Services;
+using RackPeek.Domain.Resources.SystemResources;
+
+namespace Tests.Discovery;
+
+/// <summary>
+///     A placeholder name is a stand-in, and a later collector that knows the machine's
+///     real name should be allowed to replace it.
+///     <para>
+///         A sweep of a routed subnet can see that something answers and nothing else, so
+///         it writes host-1a2b3c4d. Run the firewall collector and that same machine has
+///         a DHCP name; ask the hypervisor and it has a guest name. Without this the
+///         inventory would keep the hash for ever and the better name would be discarded
+///         on every run.
+///     </para>
+///     <para>
+///         The line it must not cross is a name a person typed. That is what
+///         <see cref="Resource.UserNamed" /> records, and once set it is never unset.
+///     </para>
+/// </summary>
+public class PlaceholderNameUpgradeTests {
+    private const string _mac = "bc:24:11:00:3a:01";
+
+    private static string PlaceholderFor(string discoveryId) =>
+        $"host-{DiscoveryId.ShortSuffix(discoveryId)}";
+
+    /// <summary>A sweep's card: an address, a MAC, and a name that is just its own hash.</summary>
+    private static SystemResource Stored(string? name = null, bool? userNamed = null) {
+        var id = DiscoveryId.Create(DiscoveryId.NetworkScheme, _mac);
+
+        return new SystemResource {
+            Kind = SystemResource.KindLabel,
+            Name = name ?? PlaceholderFor(id),
+            DiscoveryId = id,
+            UserNamed = userNamed,
+            Ip = "192.0.2.50",
+            Labels = { ["mac"] = _mac }
+        };
+    }
+
+    /// <summary>The firewall's view of the same box: same identity, but it knows the name.</summary>
+    private static SystemResource Incoming(string name = "forgejo") => new() {
+        Kind = SystemResource.KindLabel,
+        Name = name,
+        DiscoveryId = DiscoveryId.Create(DiscoveryId.NetworkScheme, _mac),
+        Ip = "192.0.2.50",
+        Labels = { ["mac"] = _mac }
+    };
+
+    private static List<Resource> Resolve(
+        Resource stored,
+        Resource incoming,
+        IReadOnlyList<Connection>? storedConnections = null,
+        params Resource[] alsoStored) {
+        List<Resource> existing = [stored, .. alsoStored];
+
+        DiscoveryIdResolver.ResolveNames(
+            existing,
+            [incoming],
+            null,
+            storedConnections,
+            true);
+
+        return existing;
+    }
+
+    [Fact]
+    public void A_real_name_replaces_a_placeholder_nobody_chose() {
+        SystemResource stored = Stored();
+
+        Resolve(stored, Incoming());
+
+        Assert.Equal("forgejo", stored.Name);
+    }
+
+    [Fact]
+    public void A_name_a_person_typed_is_never_touched() {
+        SystemResource stored = Stored("the-blue-one", true);
+
+        Resolve(stored, Incoming());
+
+        Assert.Equal("the-blue-one", stored.Name);
+    }
+
+    [Fact]
+    public void A_placeholder_a_person_chose_to_keep_is_still_theirs() {
+        // Renaming a card back to its hash is a strange thing to do, but it is a choice,
+        // and the flag is what records that rather than the shape of the name.
+        SystemResource stored = Stored(userNamed: true);
+        var before = stored.Name;
+
+        Resolve(stored, Incoming());
+
+        Assert.Equal(before, stored.Name);
+    }
+
+    [Fact]
+    public void A_placeholder_never_replaces_a_real_name() {
+        // The reverse direction: the sweep runs after the firewall and knows less. The
+        // upgrade is one-way or the card would flip names on alternate runs.
+        SystemResource stored = Stored("forgejo");
+        var placeholder = PlaceholderFor(stored.DiscoveryId!);
+
+        Resolve(stored, Incoming(placeholder));
+
+        Assert.Equal("forgejo", stored.Name);
+    }
+
+    [Fact]
+    public void One_real_name_does_not_replace_another() {
+        // Two collectors that each know a different name for one box would otherwise
+        // rename it back and forth every run. First real name wins and stays.
+        SystemResource stored = Stored("forgejo");
+
+        Resolve(stored, Incoming("git-server"));
+
+        Assert.Equal("forgejo", stored.Name);
+    }
+
+    [Fact]
+    public void A_hand_written_resource_counts_as_user_named_without_the_flag() {
+        // Nothing but a person could have written a resource with no discovery id, so
+        // the absent flag must not be read as permission.
+        var stored = new SystemResource {
+            Kind = SystemResource.KindLabel,
+            Name = "forgejo",
+            Ip = "192.0.2.50"
+        };
+
+        Assert.True(stored.IsUserNamed());
+        Assert.False(Stored().IsUserNamed());
+    }
+
+    [Fact]
+    public void Everything_pointing_at_the_old_name_follows_it() {
+        SystemResource stored = Stored();
+        var placeholder = stored.Name;
+
+        var service = new Service {
+            Kind = Service.KindLabel,
+            Name = "forgejo-https",
+            RunsOn = [placeholder]
+        };
+
+        Resolve(stored, Incoming(), null, service);
+
+        Assert.Equal(["forgejo"], service.RunsOn);
+    }
+
+    [Fact]
+    public void Stored_connections_follow_it_too() {
+        SystemResource stored = Stored();
+        var placeholder = stored.Name;
+
+        var connection = new Connection {
+            A = new PortReference { Resource = placeholder },
+            B = new PortReference { Resource = "core-switch", PortIndex = 12 }
+        };
+
+        Resolve(stored, Incoming(), [connection]);
+
+        Assert.Equal("forgejo", connection.A.Resource);
+    }
+
+    [Fact]
+    public void A_re_push_of_exported_yaml_follows_the_rename_too() {
+        // The payload still calls the machine by the name it was stored under, because
+        // that is what was exported. Its links have to land on the upgraded card.
+        SystemResource stored = Stored();
+        var placeholder = stored.Name;
+
+        var arriving = new Service {
+            Kind = Service.KindLabel,
+            Name = "forgejo-https",
+            DiscoveryId = DiscoveryId.Create(DiscoveryId.NetworkScheme, $"{_mac}:443"),
+            RunsOn = [placeholder]
+        };
+
+        DiscoveryIdResolver.ResolveNames([stored], [Incoming(), arriving], null, null, true);
+
+        Assert.Equal("forgejo", stored.Name);
+        Assert.Equal(["forgejo"], arriving.RunsOn);
+    }
+
+    [Fact]
+    public void A_service_named_after_the_host_follows_it() {
+        // The sweep names what it finds on a port after the host it found it on, so the
+        // leftover reads host-1a2b3c4d-ssh running on forgejo until this carries it over.
+        SystemResource stored = Stored();
+        var placeholder = stored.Name;
+
+        var ssh = new Service {
+            Kind = Service.KindLabel,
+            Name = $"{placeholder}-ssh",
+            DiscoveryId = DiscoveryId.Create(DiscoveryId.NetworkScheme, $"{_mac}:22"),
+            RunsOn = [placeholder]
+        };
+
+        Resolve(stored, Incoming(), null, ssh);
+
+        Assert.Equal("forgejo-ssh", ssh.Name);
+        Assert.Equal(["forgejo"], ssh.RunsOn);
+    }
+
+    [Fact]
+    public void A_service_a_person_named_keeps_its_name() {
+        SystemResource stored = Stored();
+        var placeholder = stored.Name;
+
+        var ssh = new Service {
+            Kind = Service.KindLabel,
+            Name = $"{placeholder}-ssh",
+            DiscoveryId = DiscoveryId.Create(DiscoveryId.NetworkScheme, $"{_mac}:22"),
+            UserNamed = true,
+            RunsOn = [placeholder]
+        };
+
+        Resolve(stored, Incoming(), null, ssh);
+
+        Assert.Equal($"{placeholder}-ssh", ssh.Name);
+    }
+
+    [Fact]
+    public void A_service_that_only_looks_like_the_host_is_not_renamed() {
+        // Named for the old host but running somewhere else entirely: a coincidence,
+        // and coincidences are not evidence of anything.
+        SystemResource stored = Stored();
+        var placeholder = stored.Name;
+
+        var stray = new Service {
+            Kind = Service.KindLabel,
+            Name = $"{placeholder}-ssh",
+            DiscoveryId = DiscoveryId.Create(DiscoveryId.NetworkScheme, "elsewhere:22"),
+            RunsOn = ["some-other-box"]
+        };
+
+        Resolve(stored, Incoming(), null, stray);
+
+        Assert.Equal($"{placeholder}-ssh", stray.Name);
+    }
+
+    [Fact]
+    public void A_service_whose_new_name_is_taken_keeps_the_old_one() {
+        SystemResource stored = Stored();
+        var placeholder = stored.Name;
+
+        var ssh = new Service {
+            Kind = Service.KindLabel,
+            Name = $"{placeholder}-ssh",
+            DiscoveryId = DiscoveryId.Create(DiscoveryId.NetworkScheme, $"{_mac}:22"),
+            RunsOn = [placeholder]
+        };
+
+        var occupier = new Service {
+            Kind = Service.KindLabel,
+            Name = "forgejo-ssh",
+            DiscoveryId = DiscoveryId.Create(DiscoveryId.NetworkScheme, "other:22"),
+            RunsOn = ["some-other-box"]
+        };
+
+        Resolve(stored, Incoming(), null, ssh, occupier);
+
+        Assert.Equal($"{placeholder}-ssh", ssh.Name);
+    }
+
+    [Fact]
+    public void A_connection_to_a_renamed_service_follows_it() {
+        SystemResource stored = Stored();
+        var placeholder = stored.Name;
+
+        var ssh = new Service {
+            Kind = Service.KindLabel,
+            Name = $"{placeholder}-ssh",
+            DiscoveryId = DiscoveryId.Create(DiscoveryId.NetworkScheme, $"{_mac}:22"),
+            RunsOn = [placeholder]
+        };
+
+        var connection = new Connection {
+            A = new PortReference { Resource = $"{placeholder}-ssh" },
+            B = new PortReference { Resource = "core-switch", PortIndex = 3 }
+        };
+
+        DiscoveryIdResolver.ResolveNames(
+            [stored, ssh], [Incoming()], null, [connection], true);
+
+        Assert.Equal("forgejo-ssh", connection.A.Resource);
+    }
+
+    [Fact]
+    public void A_name_already_in_use_is_left_alone() {
+        // Renaming onto an occupied name would collide two unrelated resources in the
+        // merge, which keys on name. Keeping the placeholder is the safe outcome.
+        SystemResource stored = Stored();
+        var placeholder = stored.Name;
+
+        var other = new SystemResource {
+            Kind = SystemResource.KindLabel,
+            Name = "forgejo",
+            Ip = "192.0.2.99"
+        };
+
+        Resolve(stored, Incoming(), null, other);
+
+        Assert.Equal(placeholder, stored.Name);
+    }
+
+    [Fact]
+    public void A_dry_run_leaves_the_stored_name_where_it_was() {
+        // The upgrade rewrites the inventory, so a caller that is only reporting what
+        // would happen must not ask for it — the default is off for exactly this reason.
+        SystemResource stored = Stored();
+        var placeholder = stored.Name;
+
+        DiscoveryIdResolver.ResolveNames([stored], [Incoming()]);
+
+        Assert.Equal(placeholder, stored.Name);
+    }
+}

+ 207 - 0
Tests.Discovery/ProxmoxGuestAddressTests.cs

@@ -0,0 +1,207 @@
+using RackPeek.Domain.Discovery;
+using RackPeek.Domain.Resources;
+using RackPeek.Domain.Resources.SystemResources;
+
+namespace Tests.Discovery;
+
+/// <summary>
+///     Learning where a guest actually is. Proxmox only records an address in a guest's
+///     config when someone set one statically, so on a DHCP estate every guest arrived
+///     without one — and an address is what lets a guest line up with the host a network
+///     sweep found. The guest itself knows, and will say so through its agent.
+///     <para>
+///         The hard part is that the agent reports every interface inside the machine,
+///         including the bridges Docker and Home Assistant create. Picking the wrong one
+///         would record 172.17.0.1 as the machine's address, which every container host
+///         on the estate would also report.
+///     </para>
+/// </summary>
+public class ProxmoxGuestAddressTests {
+    private const string _nicMac = "bc:24:11:00:1a:01";
+
+    private static List<ProxmoxGuestAddress> AgentAddresses() =>
+        ProxmoxResponseParser.ParseAgentInterfaces(Fixture.Read("pve-agent-interfaces.json"));
+
+    [Fact]
+    public void An_agent_reports_every_routable_ipv4_it_can_see() {
+        List<ProxmoxGuestAddress> addresses = AgentAddresses();
+
+        // Loopback and the IPv6 entries are dropped; the NIC and both docker bridges stay,
+        // because deciding between them is the caller's job, not the parser's.
+        Assert.Equal(["192.0.2.105", "172.17.0.1", "172.18.0.1"], addresses.Select(a => a.Ip));
+    }
+
+    [Fact]
+    public void The_guests_own_nic_wins_over_the_bridges_it_created() =>
+        Assert.Equal("192.0.2.105", ProxmoxDiscovery.SelectGuestIp(AgentAddresses(), [_nicMac]));
+
+    [Fact]
+    // Proxmox configs upper-case them; agents vary. Both go through the same normaliser
+    // the ARP reader uses, so a scan and this collector always agree.
+    public void The_mac_is_matched_however_either_side_spells_it() =>
+        Assert.Equal("192.0.2.105", ProxmoxDiscovery.SelectGuestIp(AgentAddresses(), ["BC-24-11-00-1A-01"]));
+
+    [Fact]
+    // Every address on offer belongs to something the guest invented. Recording one
+    // would be worse than recording nothing.
+    public void Nothing_is_claimed_when_no_interface_carries_a_configured_mac() =>
+        Assert.Null(ProxmoxDiscovery.SelectGuestIp(AgentAddresses(), ["bc:24:11:ff:ff:ff"]));
+
+    [Fact]
+    // A token that cannot read the guest config leaves no discriminator, so there is no
+    // way to tell a NIC from a bridge.
+    public void Nothing_is_claimed_when_the_configured_macs_are_unknown() =>
+        Assert.Null(ProxmoxDiscovery.SelectGuestIp(AgentAddresses(), []));
+
+    [Fact]
+    public void An_agent_that_answers_nothing_yields_nothing() {
+        Assert.Empty(ProxmoxResponseParser.ParseAgentInterfaces("""{"data":null}"""));
+        Assert.Empty(ProxmoxResponseParser.ParseAgentInterfaces("""{"data":{"result":[]}}"""));
+    }
+
+    [Fact]
+    public void A_container_reports_its_address_with_the_prefix_stripped() {
+        List<ProxmoxGuestAddress> addresses =
+            ProxmoxResponseParser.ParseContainerInterfaces(Fixture.Read("pve-lxc-interfaces.json"));
+
+        ProxmoxGuestAddress only = Assert.Single(addresses);
+        Assert.Equal("192.0.2.150", only.Ip);
+        Assert.Equal("eth0", only.Interface);
+    }
+
+    [Fact]
+    public void A_guest_that_failed_dhcp_is_not_recorded_at_its_self_assigned_address() {
+        // 169.254 means "I could not get an address", which is not an address worth
+        // writing into an inventory.
+        var json = """
+                   {"data":{"result":[{"name":"ens18","hardware-address":"bc:24:11:00:1a:01",
+                   "ip-addresses":[{"ip-address":"169.254.12.7","ip-address-type":"ipv4","prefix":16}]}]}}
+                   """;
+
+        Assert.Empty(ProxmoxResponseParser.ParseAgentInterfaces(json));
+    }
+
+    [Fact]
+    public async Task A_statically_configured_address_still_wins_over_the_agent() {
+        // The config is what the administrator asked for; the agent is what the guest
+        // happens to report. Where both exist they agree, and where they do not the
+        // configured one is the intent.
+        var client = new ScriptedProxmoxClient {
+            Guests = [Guest(100, "static-guest")],
+            Configs = { [100] = new ProxmoxGuestConfig("Linux", "192.0.2.9", [], [], [_nicMac]) },
+            Addresses = { [100] = [new ProxmoxGuestAddress("ens18", _nicMac, "192.0.2.105")] }
+        };
+
+        List<Resource> resources = await ProxmoxDiscovery.ReadAsync(client);
+
+        Assert.Equal("192.0.2.9", GuestCard(resources, "static-guest").Ip);
+    }
+
+    [Fact]
+    public async Task A_dhcp_guest_takes_the_address_its_agent_reports() {
+        var client = new ScriptedProxmoxClient {
+            Guests = [Guest(101, "dhcp-guest")],
+            Configs = { [101] = new ProxmoxGuestConfig("Linux", null, [], [], [_nicMac]) },
+            Addresses = { [101] = [new ProxmoxGuestAddress("ens18", _nicMac, "192.0.2.105")] }
+        };
+
+        List<Resource> resources = await ProxmoxDiscovery.ReadAsync(client);
+
+        Assert.Equal("192.0.2.105", GuestCard(resources, "dhcp-guest").Ip);
+    }
+
+    [Fact]
+    public async Task A_stopped_guest_is_never_asked_where_it_is() {
+        // It has no address to report, and asking costs a round trip per guest on an
+        // estate where most guests may be off.
+        var client = new ScriptedProxmoxClient {
+            Guests = [Guest(102, "stopped-guest", "stopped")],
+            Configs = { [102] = new ProxmoxGuestConfig("Linux", null, [], [], [_nicMac]) }
+        };
+
+        await ProxmoxDiscovery.ReadAsync(client);
+
+        Assert.Empty(client.AddressCalls);
+    }
+
+    [Fact]
+    public async Task The_guests_macs_reach_the_card() {
+        // The MCP tool used to run its own copy of this orchestration which dropped the
+        // MACs, silently costing every guest its chance of unifying with a scan.
+        var client = new ScriptedProxmoxClient {
+            Guests = [Guest(103, "mac-guest")],
+            Configs = { [103] = new ProxmoxGuestConfig("Linux", null, [], [], [_nicMac]) }
+        };
+
+        List<Resource> resources = await ProxmoxDiscovery.ReadAsync(client);
+
+        Assert.Equal(_nicMac, GuestCard(resources, "mac-guest").Labels["macs"]);
+    }
+
+    private static SystemResource GuestCard(List<Resource> resources, string name) =>
+        resources.OfType<SystemResource>().Single(r => r.Name == name);
+
+    private static ProxmoxGuest Guest(int vmId, string name, string status = "running") =>
+        new() {
+            VmId = vmId,
+            Node = "pve01",
+            Name = name,
+            Type = "vm",
+            Status = status
+        };
+
+    private sealed class ScriptedProxmoxClient : IProxmoxClient {
+        public List<ProxmoxGuest> Guests { get; init; } = [];
+        public Dictionary<int, ProxmoxGuestConfig> Configs { get; } = [];
+        public Dictionary<int, List<ProxmoxGuestAddress>> Addresses { get; } = [];
+        public List<int> AddressCalls { get; } = [];
+
+        public string Endpoint => "https://pve.example.com:8006";
+
+        public Task<string> GetIdentityScopeAsync(CancellationToken cancellationToken = default) =>
+            Task.FromResult("example-cluster");
+
+        public Task<IReadOnlyList<ProxmoxNode>> GetNodesAsync(CancellationToken cancellationToken = default) =>
+            Task.FromResult<IReadOnlyList<ProxmoxNode>>([new ProxmoxNode { Name = "pve01" }]);
+
+        public Task<ProxmoxNode> EnrichAsync(ProxmoxNode node, CancellationToken cancellationToken = default) =>
+            Task.FromResult(node);
+
+        public Task<IReadOnlyList<ProxmoxGuest>> GetGuestsAsync(
+            string node,
+            string endpoint,
+            CancellationToken cancellationToken = default) =>
+            Task.FromResult<IReadOnlyList<ProxmoxGuest>>(
+                endpoint == ProxmoxApiClient.QemuEndpoint ? Guests : []);
+
+        public Task<IReadOnlyList<ProxmoxDisk>> GetDisksAsync(
+            string node,
+            CancellationToken cancellationToken = default) =>
+            Task.FromResult<IReadOnlyList<ProxmoxDisk>>([]);
+
+        public Task<IReadOnlyList<ProxmoxGpu>> GetGpusAsync(
+            string node,
+            CancellationToken cancellationToken = default) =>
+            Task.FromResult<IReadOnlyList<ProxmoxGpu>>([]);
+
+        public Task<ProxmoxGuestConfig> GetGuestConfigAsync(
+            string node,
+            string endpoint,
+            int vmId,
+            CancellationToken cancellationToken = default) =>
+            Task.FromResult(Configs.TryGetValue(vmId, out ProxmoxGuestConfig? config)
+                ? config
+                : new ProxmoxGuestConfig(null, null, [], []));
+
+        public Task<IReadOnlyList<ProxmoxGuestAddress>> GetGuestAddressesAsync(
+            string node,
+            string endpoint,
+            int vmId,
+            CancellationToken cancellationToken = default) {
+            AddressCalls.Add(vmId);
+
+            return Task.FromResult<IReadOnlyList<ProxmoxGuestAddress>>(
+                Addresses.TryGetValue(vmId, out List<ProxmoxGuestAddress>? found) ? found : []);
+        }
+    }
+}

+ 63 - 0
Tests.Discovery/ProxmoxQuotedNumberTests.cs

@@ -0,0 +1,63 @@
+using RackPeek.Domain.Discovery;
+
+namespace Tests.Discovery;
+
+/// <summary>
+///     Proxmox's perl backend quotes numeric fields inconsistently — <c>"vmid":"100"</c>
+///     and <c>"maxdisk":"512110190592"</c> both turn up across versions. The
+///     <c>TryGetInt32</c> family does not return false for a string, it throws, so a
+///     single quoted number used to take the whole run down with a stack trace: the CLI
+///     printed "Unexpected error occurred" and MCP forwarded the BCL's
+///     "requires an element of type 'Number'" as if it were the user's fault.
+/// </summary>
+public class ProxmoxQuotedNumberTests {
+    [Fact]
+    public void A_guest_whose_numbers_are_quoted_is_read_rather_than_throwing() {
+        var json = """
+                   {"data":[{"vmid":"100","name":"quoted-guest","cpus":"4",
+                   "maxmem":"4294967296","maxdisk":"512110190592","status":"running"}]}
+                   """;
+
+        ProxmoxGuest guest = Assert.Single(ProxmoxResponseParser.ParseGuests(json, "pve01", "vm"));
+
+        Assert.Equal(100, guest.VmId);
+        Assert.Equal("quoted-guest", guest.Name);
+        Assert.Equal(4, guest.Cores);
+        Assert.Equal(4294967296, guest.MemoryBytes);
+        Assert.Equal(512110190592, guest.DiskBytes);
+    }
+
+    [Fact]
+    public void Plain_numbers_still_read_the_same_way() {
+        var json = """
+                   {"data":[{"vmid":101,"name":"plain-guest","cpus":2,
+                   "maxmem":2147483648,"maxdisk":34359738368,"status":"running"}]}
+                   """;
+
+        ProxmoxGuest guest = Assert.Single(ProxmoxResponseParser.ParseGuests(json, "pve01", "vm"));
+
+        Assert.Equal(101, guest.VmId);
+        Assert.Equal(2, guest.Cores);
+        Assert.Equal(2147483648, guest.MemoryBytes);
+    }
+
+    [Fact]
+    public void A_field_that_is_neither_a_number_nor_a_numeric_string_is_simply_absent() {
+        // Guessing at "N/A" would be worse than leaving the field empty, and it must
+        // still not throw.
+        var json = """{"data":[{"vmid":102,"name":"odd-guest","cpus":"N/A","maxmem":null}]}""";
+
+        ProxmoxGuest guest = Assert.Single(ProxmoxResponseParser.ParseGuests(json, "pve01", "vm"));
+
+        Assert.Equal(0, guest.Cores);
+        Assert.Equal(0, guest.MemoryBytes);
+    }
+
+    [Fact]
+    public void A_guest_whose_vmid_is_quoted_is_still_identified() {
+        // vmid is the guest's identity; dropping it would silently lose the guest.
+        var json = """{"data":[{"vmid":"103","name":"id-guest"}]}""";
+
+        Assert.Equal(103, Assert.Single(ProxmoxResponseParser.ParseGuests(json, "pve01", "vm")).VmId);
+    }
+}

+ 29 - 0
Tests.Discovery/RemoteDockerDiscoveryTests.cs

@@ -179,4 +179,33 @@ public class RemoteDockerDiscoveryTests {
 
 
         return host;
         return host;
     }
     }
+
+    // -- endpoints this cannot reach ---------------------------------------------------
+
+    [Theory]
+    // `docker context` sets this for a remote host, so it is a perfectly normal value to
+    // find in DOCKER_HOST.
+    [InlineData("ssh://user@nas")]
+    // The Windows default.
+    [InlineData("npipe:////./pipe/docker_engine")]
+    [InlineData("gibberish://nowhere")]
+    public void An_endpoint_scheme_that_cannot_be_dialled_is_refused_with_advice(string endpoint) {
+        // HttpClient accepts these URIs happily and only throws NotSupportedException on
+        // the first request — which the caller does not catch, so discovery used to die
+        // with a stack trace instead of saying what was wrong.
+        UriFormatException error = Assert.Throws<UriFormatException>(() => new DockerApiClient(endpoint));
+
+        Assert.Contains("ssh -L", error.Message);
+    }
+
+    [Theory]
+    [InlineData("tcp://192.0.2.10:2375")]
+    [InlineData("http://192.0.2.10:2375")]
+    [InlineData("unix:///var/run/docker.sock")]
+    [InlineData("unix:///run/user/1000/podman/podman.sock")]
+    public void The_endpoints_that_do_work_are_untouched(string endpoint) {
+        using var client = new DockerApiClient(endpoint);
+
+        Assert.Equal(endpoint, client.Endpoint);
+    }
 }
 }

+ 274 - 0
Tests.Discovery/RunsOnByIpTests.cs

@@ -0,0 +1,274 @@
+using RackPeek.Domain.Discovery;
+using RackPeek.Domain.Resources;
+using RackPeek.Domain.Resources.Services;
+using RackPeek.Domain.Resources.SystemResources;
+
+namespace Tests.Discovery;
+
+/// <summary>
+///     Giving a service the host it is plainly running on, when the collector could not.
+///     A docker collector talking to a remote engine has to guess its host's name and the
+///     link then dangles; the service's own address is evidence the guess is not. The
+///     rules that keep this from doing harm are what these tests own.
+/// </summary>
+public class RunsOnByIpTests {
+    private static SystemResource System(string name, string? ip, string? discoveryId = null) =>
+        new() {
+            Kind = SystemResource.KindLabel,
+            Name = name,
+            Ip = ip,
+            DiscoveryId = discoveryId
+        };
+
+    private static Service Service(string name, string? ip, string? runsOn = null, string? discoveryId = "rpk1:docker:1") =>
+        new() {
+            Kind = RackPeek.Domain.Resources.Services.Service.KindLabel,
+            Name = name,
+            DiscoveryId = discoveryId,
+            Network = ip == null ? null : new Network { Ip = ip },
+            RunsOn = runsOn == null ? [] : [runsOn]
+        };
+
+    [Fact]
+    public void A_service_whose_host_name_matches_nothing_is_anchored_to_the_system_at_its_address() {
+        // The remote-docker case: the engine called itself NAS01.lan, the inventory
+        // calls that machine nas01, and nothing linked the two.
+        List<Resource> existing = [System("nas01", "192.0.2.50")];
+        List<Resource> incoming = [Service("immich", "192.0.2.50", "NAS01.lan")];
+
+        DiscoveryIdResolver.ResolveNames(existing, incoming);
+
+        Assert.Equal(["nas01"], incoming.OfType<Service>().Single().RunsOn);
+    }
+
+    [Fact]
+    public void A_service_with_no_host_at_all_is_anchored_too() {
+        List<Resource> existing = [System("nas01", "192.0.2.50")];
+        List<Resource> incoming = [Service("immich", "192.0.2.50")];
+
+        DiscoveryIdResolver.ResolveNames(existing, incoming);
+
+        Assert.Equal(["nas01"], incoming.OfType<Service>().Single().RunsOn);
+    }
+
+    [Fact]
+    public void A_host_arriving_in_the_same_payload_counts() {
+        // discover docker emits the host alongside its services.
+        List<Resource> incoming = [
+            System("nas01", "192.0.2.50", "rpk1:sys:a"),
+            Service("immich", "192.0.2.50", "somewhere-else")
+        ];
+
+        DiscoveryIdResolver.ResolveNames([], incoming);
+
+        Assert.Equal(["nas01"], incoming.OfType<Service>().Single().RunsOn);
+    }
+
+    [Fact]
+    public void A_working_host_link_is_never_overwritten() {
+        // The service says it runs on a resource that exists. That is the collector's
+        // own answer and it beats an inference drawn from an address.
+        List<Resource> existing = [
+            System("nas01", "192.0.2.50"),
+            System("some-vm", "192.0.2.99")
+        ];
+        List<Resource> incoming = [Service("immich", "192.0.2.50", "some-vm")];
+
+        DiscoveryIdResolver.ResolveNames(existing, incoming);
+
+        Assert.Equal(["some-vm"], incoming.OfType<Service>().Single().RunsOn);
+    }
+
+    [Fact]
+    public void A_stored_host_beats_a_scanned_stand_in_at_the_same_address() {
+        // Once a hypervisor reports its guests' addresses, a sweep of that subnet finds
+        // the same machines again and contributes a sparse card per address. The service
+        // belongs on the guest the hypervisor described, not on the sweep's stand-in.
+        List<Resource> existing = [System("app-vm", "192.0.2.50")];
+        List<Resource> incoming = [
+            System("host-1a2b3c4d", "192.0.2.50", "rpk1:net:c"),
+            Service("immich", "192.0.2.50", "SOMEWHERE.lan")
+        ];
+
+        DiscoveryIdResolver.ResolveNames(existing, incoming);
+
+        Assert.Equal(["app-vm"], incoming.OfType<Service>().Single().RunsOn);
+    }
+
+    [Fact]
+    public void An_address_two_systems_claim_anchors_nothing() {
+        // Overlapping subnets across sites, or a stale card nobody cleaned up. An
+        // ambiguous address is no evidence, and a wrong parent is worse than none.
+        List<Resource> existing = [
+            System("site-a-host", "192.0.2.50"),
+            System("site-b-host", "192.0.2.50")
+        ];
+        List<Resource> incoming = [Service("immich", "192.0.2.50", "NAS01.lan")];
+
+        DiscoveryIdResolver.ResolveNames(existing, incoming);
+
+        Assert.Equal(["NAS01.lan"], incoming.OfType<Service>().Single().RunsOn);
+    }
+
+    [Fact]
+    public void A_service_with_no_address_is_left_alone() {
+        List<Resource> existing = [System("nas01", "192.0.2.50")];
+        List<Resource> incoming = [Service("immich", null, "NAS01.lan")];
+
+        DiscoveryIdResolver.ResolveNames(existing, incoming);
+
+        Assert.Equal(["NAS01.lan"], incoming.OfType<Service>().Single().RunsOn);
+    }
+
+    [Fact]
+    public void An_address_no_system_claims_anchors_nothing() {
+        List<Resource> existing = [System("nas01", "192.0.2.50")];
+        List<Resource> incoming = [Service("immich", "198.51.100.99", "NAS01.lan")];
+
+        DiscoveryIdResolver.ResolveNames(existing, incoming);
+
+        Assert.Equal(["NAS01.lan"], incoming.OfType<Service>().Single().RunsOn);
+    }
+
+    [Fact]
+    public void Systems_are_not_anchored_by_address() {
+        // Two systems sharing an address are the same machine or a conflict — never a
+        // parent and a child. Only services are hosted.
+        List<Resource> existing = [System("nas01", "192.0.2.50")];
+        List<Resource> incoming = [System("scanned-host", "192.0.2.50", "rpk1:net:b")];
+
+        DiscoveryIdResolver.ResolveNames(existing, incoming);
+
+        Assert.Empty(incoming.OfType<SystemResource>().Single().RunsOn);
+    }
+
+    // ---------------------------------------------------------------
+    // Unifying a sweep's find with the guest a hypervisor already described
+    // ---------------------------------------------------------------
+
+    private static SystemResource Scanned(string name, string ip, string? mac = null) {
+        var card = new SystemResource {
+            Kind = SystemResource.KindLabel,
+            Name = name,
+            Ip = ip,
+            DiscoveryId = DiscoveryId.Create(DiscoveryId.NetworkScheme, mac ?? $"ip:{ip}")
+        };
+
+        if (mac != null)
+            card.Labels["mac"] = mac;
+
+        return card;
+    }
+
+    private static SystemResource Guest(string name, string ip, string id = "abc123") =>
+        new() {
+            Kind = SystemResource.KindLabel,
+            Name = name,
+            Ip = ip,
+            DiscoveryId = $"rpk1:pve:{id}"
+        };
+
+    [Fact]
+    public void A_sweep_find_becomes_the_guest_the_hypervisor_already_described() {
+        // ARP is link-local, so a guest on another subnet gives the sweep no MAC and its
+        // identity falls back to the address. Now that the hypervisor reports that same
+        // address, it is the only thing tying the two records together.
+        List<Resource> existing = [Guest("app-vm", "192.0.2.105")];
+        List<Resource> incoming = [Scanned("host-1a2b3c4d", "192.0.2.105")];
+
+        DiscoveryIdResolver.ResolveNames(existing, incoming);
+
+        SystemResource card = incoming.OfType<SystemResource>().Single();
+        Assert.Equal("app-vm", card.Name);
+        // The sweep's weaker identity is dropped so the merge cannot downgrade the
+        // hypervisor's.
+        Assert.Null(card.DiscoveryId);
+    }
+
+    [Fact]
+    public void A_sweep_find_that_saw_a_mac_is_left_to_the_mac_rule() {
+        // A MAC is better evidence than an address. If it did not unify above, the two
+        // records disagree, and an address must not override that.
+        List<Resource> existing = [Guest("app-vm", "192.0.2.105")];
+        List<Resource> incoming = [Scanned("host-1a2b3c4d", "192.0.2.105", "bc:24:11:00:1a:01")];
+
+        DiscoveryIdResolver.ResolveNames(existing, incoming);
+
+        Assert.Equal("host-1a2b3c4d", incoming.OfType<SystemResource>().Single().Name);
+    }
+
+    [Fact]
+    public void Two_stored_systems_at_one_address_unify_nothing() {
+        // Overlapping subnets across sites, or a stale card. Ambiguity is not evidence.
+        List<Resource> existing = [
+            Guest("site-a-vm", "192.0.2.105", "aaa111"),
+            Guest("site-b-vm", "192.0.2.105", "bbb222")
+        ];
+        List<Resource> incoming = [Scanned("host-1a2b3c4d", "192.0.2.105")];
+
+        DiscoveryIdResolver.ResolveNames(existing, incoming);
+
+        Assert.Equal("host-1a2b3c4d", incoming.OfType<SystemResource>().Single().Name);
+    }
+
+    [Fact]
+    public void Two_sweep_finds_with_nothing_between_them_are_the_same_card_already() {
+        // Neither card is more than "something replied at this address", and an address
+        // is exactly what seeds their identity when no MAC was seen — so they carry the
+        // same id and the address rule never gets a say. The stored name wins, as it
+        // does for any re-run.
+        List<Resource> existing = [Scanned("host-99999999", "192.0.2.105")];
+        List<Resource> incoming = [Scanned("host-1a2b3c4d", "192.0.2.105")];
+
+        Assert.Equal(existing[0].DiscoveryId, incoming[0].DiscoveryId);
+
+        DiscoveryIdResolver.ResolveNames(existing, incoming);
+
+        Assert.Equal("host-99999999", incoming.OfType<SystemResource>().Single().Name);
+    }
+
+    [Fact]
+    public void A_sweep_find_folds_into_one_that_saw_the_mac() {
+        // Not two stand-ins: the stored card names the NIC answering at that address —
+        // a firewall's neighbour table does this for every subnet it routes — where the
+        // incoming one only knows something replied. The MAC is the better identity, so
+        // the address-only card folds into it rather than becoming a second machine.
+        List<Resource> existing = [Scanned("host-99999999", "192.0.2.105", "bc:24:11:00:1a:09")];
+        List<Resource> incoming = [Scanned("host-1a2b3c4d", "192.0.2.105")];
+
+        DiscoveryIdResolver.ResolveNames(existing, incoming);
+
+        SystemResource card = incoming.OfType<SystemResource>().Single();
+        Assert.Equal("host-99999999", card.Name);
+
+        // The weaker address-seeded identity is dropped so the merge cannot downgrade
+        // the MAC-seeded one it is landing on.
+        Assert.Null(card.DiscoveryId);
+    }
+
+    [Fact]
+    public void The_mac_wins_arriving_second_too() {
+        // Sweep the routed subnet first, ask the firewall after: same two facts, same
+        // one card. Here the MAC-seeded identity is the one that survives.
+        List<Resource> existing = [Scanned("host-1a2b3c4d", "192.0.2.105")];
+        List<Resource> incoming = [Scanned("host-99999999", "192.0.2.105", "bc:24:11:00:1a:09")];
+
+        DiscoveryIdResolver.ResolveNames(existing, incoming);
+
+        SystemResource card = incoming.OfType<SystemResource>().Single();
+        Assert.Equal("host-1a2b3c4d", card.Name);
+        Assert.NotNull(card.DiscoveryId);
+    }
+
+    [Fact]
+    public void Two_sweep_finds_that_each_saw_a_mac_never_unify() {
+        // Both name a NIC, and they name different ones. The MAC bridge has already had
+        // its say; sharing an address now is a conflict or an overlapping subnet.
+        List<Resource> existing = [Scanned("host-99999999", "192.0.2.105", "bc:24:11:00:1a:09")];
+        List<Resource> incoming = [Scanned("host-1a2b3c4d", "192.0.2.105", "bc:24:11:00:1a:0a")];
+
+        DiscoveryIdResolver.ResolveNames(existing, incoming);
+
+        Assert.Equal("host-1a2b3c4d", incoming.OfType<SystemResource>().Single().Name);
+    }
+}

+ 115 - 0
Tests.Discovery/ServiceEndpointTests.cs

@@ -0,0 +1,115 @@
+using RackPeek.Domain.Resources.Services;
+
+namespace Tests.Discovery;
+
+/// <summary>
+///     Where a service answers, and whether a browser can do anything with it.
+///     <para>
+///         Both of these were wrong in the UI. The endpoint was rendered as
+///         <c>Ip: 10.0.50.105:3000</c> and then used as a link target, so clicking it did
+///         nothing. And the scheme fell back to <c>http</c> for every port, so an SSH
+///         service showed <c>http://10.0.50.105:22/</c> — a link that looks real, invites
+///         a click and cannot load.
+///     </para>
+/// </summary>
+public class ServiceEndpointTests {
+    private static Network Net(int? port, string? protocol = "TCP", string? ip = "10.0.50.105", string? url = null) =>
+        new() { Ip = ip, Port = port, Protocol = protocol, Url = url };
+
+    private static Service Svc(Network? network) =>
+        new() { Kind = Service.KindLabel, Name = "svc", Network = network };
+
+    [Fact]
+    public void An_endpoint_reads_as_an_address_and_nothing_else() =>
+        // It goes on screen next to the service name; a label belongs in the markup.
+        Assert.Equal("10.0.50.105:3000", Svc(Net(3000)).NetworkString());
+
+    [Fact]
+    public void An_endpoint_with_no_port_is_just_the_address() =>
+        Assert.Equal("10.0.50.105", Svc(Net(null)).NetworkString());
+
+    [Fact]
+    public void A_service_with_no_network_has_no_endpoint() =>
+        Assert.Equal(string.Empty, Svc(null).NetworkString());
+
+    [Theory]
+    [InlineData(22)] // ssh
+    [InlineData(445)] // smb
+    [InlineData(1883)] // mqtt
+    [InlineData(53)] // dns
+    [InlineData(3306)] // mysql
+    [InlineData(9987)] // nothing curated
+    public void A_port_a_browser_cannot_open_gets_no_link(int port) =>
+        Assert.Null(Svc(Net(port)).BrowsableUrl());
+
+    [Theory]
+    [InlineData(80, "http://10.0.50.105/")]
+    [InlineData(3000, "http://10.0.50.105:3000/")]
+    [InlineData(8123, "http://10.0.50.105:8123/")]
+    [InlineData(9000, "http://10.0.50.105:9000/")]
+    public void A_web_port_gets_a_link(int port, string expected) =>
+        Assert.Equal(expected, Svc(Net(port)).BrowsableUrl());
+
+    [Theory]
+    [InlineData(443, "https://10.0.50.105/")]
+    [InlineData(8006, "https://10.0.50.105:8006/")]
+    [InlineData(8443, "https://10.0.50.105:8443/")]
+    public void A_tls_port_gets_an_https_link(int port, string expected) =>
+        // Proxmox on 8006 redirects plain HTTP, so guessing http costs a round trip.
+        Assert.Equal(expected, Svc(Net(port)).BrowsableUrl());
+
+    [Fact]
+    public void The_transport_is_not_mistaken_for_a_scheme() {
+        // Discovery writes "TCP" into protocol, which says nothing about what rides on
+        // top of it. Reading that as a scheme is what produced http:// on port 22.
+        Assert.Null(Svc(Net(22, "TCP")).BrowsableUrl());
+        Assert.Equal("http://10.0.50.105:3000/", Svc(Net(3000, "TCP")).BrowsableUrl());
+    }
+
+    [Theory]
+    [InlineData("https", "https://10.0.50.105:9999/")]
+    [InlineData("HTTP", "http://10.0.50.105:9999/")]
+    public void A_protocol_that_names_a_scheme_is_believed(string protocol, string expected) =>
+        // On an uncurated port this is the only thing that can answer.
+        Assert.Equal(expected, Svc(Net(9999, protocol)).BrowsableUrl());
+
+    [Fact]
+    public void A_url_someone_typed_always_wins() {
+        Service service = Svc(Net(22, "TCP", url: "https://git.example.com/"));
+
+        Assert.Equal("https://git.example.com/", service.BrowsableUrl());
+        Assert.Equal("https://git.example.com/", service.NetworkString());
+    }
+
+    [Fact]
+    public void A_service_with_no_address_of_its_own_can_borrow_its_hosts() {
+        // The card resolves the host's address when the service carries none.
+        Service service = Svc(Net(8080, ip: null));
+
+        Assert.Null(service.BrowsableUrl());
+        Assert.Equal("http://10.0.20.7:8080/", service.BrowsableUrl("10.0.20.7"));
+    }
+
+    [Fact]
+    public void A_service_with_no_port_is_not_guessed_at() =>
+        Assert.Null(Svc(Net(null)).BrowsableUrl());
+
+    [Theory]
+    [InlineData("host name with spaces")]
+    [InlineData("...")]
+    [InlineData("a/b")]
+    [InlineData("under_score.local")]
+    [InlineData("fe80::1")]
+    [InlineData("[::1]")]
+    public void An_address_no_url_can_be_built_from_yields_no_link_rather_than_throwing(string ip) {
+        // The address field holds whatever a person typed or a device reported, and
+        // neither is obliged to produce something a URL can be built from. This is
+        // rendered inside the hardware and system trees, so an exception here would
+        // blank the whole page rather than spoil one link.
+        Service service = Svc(Net(8080, ip: ip));
+
+        Exception? thrown = Record.Exception(() => service.BrowsableUrl());
+
+        Assert.Null(thrown);
+    }
+}

+ 138 - 0
Tests.Discovery/ServiceIdentityParserTests.cs

@@ -0,0 +1,138 @@
+using RackPeek.Domain.Discovery;
+
+namespace Tests.Discovery;
+
+/// <summary>
+///     Reading a host's name out of what its services volunteer. The fixtures follow the
+///     shape of what real homelab gear emits — a Proxmox node, an OPNsense firewall, a
+///     network printer — because the value of this parser is entirely in what it makes of
+///     awkward real-world output rather than well-formed examples. The names and
+///     addresses throughout are invented.
+/// </summary>
+public class ServiceIdentityParserTests {
+    [Theory]
+    // Proxmox ships a per-node certificate naming the node. This single line is what
+    // turns "host-1a2b3c4d" into "pve-node-01".
+    [InlineData("OU=PVE Cluster Node, O=Proxmox Virtual Environment, CN=pve-node-01.example.com", "pve-node-01.example.com")]
+    [InlineData("OU=PVE Cluster Node, O=Proxmox Virtual Environment, CN=pve-node-02.example", "pve-node-02.example")]
+    [InlineData("CN=OPNsense.localdomain, O=OPNsense self-signed", "OPNsense.localdomain")]
+    [InlineData("CN=printer-lobby.local", "printer-lobby.local")]
+    [InlineData("CN = spaced.example.lan, O = Org", "spaced.example.lan")]
+    public void A_certificate_common_name_is_read_from_its_subject(string subject, string expected) =>
+        Assert.Equal(expected, ServiceIdentityParser.ParseTlsSubject(subject));
+
+    [Theory]
+    [InlineData("CN=*.example.com, O=Org")] // names a domain, not this machine
+    [InlineData("CN=localhost")] // the installer's placeholder
+    [InlineData("CN=-6268337161588699610")] // an appliance serial: no letters, names nothing
+    [InlineData("O=Org Only, OU=No Common Name")]
+    [InlineData("")]
+    [InlineData(null)]
+    public void A_subject_that_names_nothing_useful_is_rejected(string? subject) =>
+        Assert.Null(ServiceIdentityParser.ParseTlsSubject(subject));
+
+    [Theory]
+    [InlineData("SSH-2.0-OpenSSH_9.6p1 Ubuntu-3ubuntu13.5", "OpenSSH")]
+    [InlineData("SSH-2.0-dropbear", "dropbear")]
+    [InlineData("SSH-2.0-dropbear_2022.83", "dropbear")]
+    [InlineData("SSH-1.99-Cisco-1.25", "Cisco")]
+    // The version is deliberately dropped: it goes stale on the next patch, and a
+    // resource named after a point release is worse than one named after the daemon.
+    public void An_ssh_greeting_yields_the_software_without_its_version(string banner, string expected) =>
+        Assert.Equal(expected, ServiceIdentityParser.ParseSshBanner(banner));
+
+    [Theory]
+    [InlineData("220 mail.example.com ESMTP Postfix")] // not SSH
+    [InlineData("HTTP/1.1 200 OK")]
+    [InlineData("")]
+    [InlineData(null)]
+    public void Something_that_is_not_an_ssh_greeting_yields_nothing(string? banner) =>
+        Assert.Null(ServiceIdentityParser.ParseSshBanner(banner));
+
+    [Fact]
+    public void A_page_title_is_preferred_over_the_server_header() {
+        // "Home Assistant" identifies the box; "nginx" identifies half the internet.
+        var head = "HTTP/1.0 200 OK\r\nServer: nginx/1.24.0\r\n\r\n<html><head><title>Home Assistant</title>";
+
+        Assert.Equal("Home Assistant", ServiceIdentityParser.ParseHttpIdentity(head));
+    }
+
+    [Fact]
+    public void The_server_header_is_used_when_there_is_no_title() {
+        var head = "HTTP/1.0 200 OK\r\nServer: llama.cpp\r\nContent-Type: text/html\r\n\r\n<html><body>";
+
+        Assert.Equal("llama.cpp", ServiceIdentityParser.ParseHttpIdentity(head));
+    }
+
+    [Fact]
+    public void A_server_headers_version_is_trimmed() {
+        var head = "HTTP/1.0 200 OK\r\nServer: nginx/1.24.0 (Ubuntu)\r\n\r\n";
+
+        Assert.Equal("nginx", ServiceIdentityParser.ParseHttpIdentity(head));
+    }
+
+    [Theory]
+    // Observed on a live sweep: the tagline became the card's name.
+    [InlineData("<title>Forgejo: Beyond coding. We Forge.</title>", "Forgejo")]
+    [InlineData("<title>Grafana | Dashboards</title>", "Grafana")]
+    [InlineData("<title>Jellyfin – Media</title>", "Jellyfin")]
+    [InlineData("<title>Home Assistant</title>", "Home Assistant")]
+    public void A_title_is_trimmed_to_the_phrase_before_its_separator(string body, string expected) =>
+        Assert.Equal(expected, ServiceIdentityParser.ParseHttpIdentity("HTTP/1.0 200 OK\r\n\r\n" + body));
+
+    [Fact]
+    public void A_title_whose_lead_is_too_short_to_stand_alone_is_kept_whole() {
+        // A stray separator near the start is not a product name.
+        var head = "HTTP/1.0 200 OK\r\n\r\n<title>my: little server</title>";
+
+        Assert.Equal("my: little server", ServiceIdentityParser.ParseHttpIdentity(head));
+    }
+
+    [Fact]
+    public void Whitespace_in_a_title_is_collapsed() {
+        var head = "HTTP/1.0 200 OK\r\n\r\n<title>\n   Home    Assistant\n</title>";
+
+        Assert.Equal("Home Assistant", ServiceIdentityParser.ParseHttpIdentity(head));
+    }
+
+    [Theory]
+    [InlineData("HTTP/1.0 401 Unauthorized\r\nWWW-Authenticate: Basic\r\n\r\n")]
+    [InlineData("HTTP/1.0 200 OK\r\n\r\n<title>   </title>")]
+    [InlineData("HTTP/1.0 200 OK\r\n\r\n<title>404</title>")] // digits name nothing
+    [InlineData("")]
+    [InlineData(null)]
+    public void An_http_response_that_names_nothing_yields_nothing(string? head) =>
+        Assert.Null(ServiceIdentityParser.ParseHttpIdentity(head));
+
+    [Fact]
+    public void An_error_pages_title_is_not_a_name() {
+        // Observed on a live sweep: a host answering 400 produced a card called
+        // "http-status-400-bad-request".
+        var head = "HTTP/1.1 400 Bad Request\r\nServer: Apache\r\n\r\n"
+                   + "<title>HTTP Status 400 – Bad Request</title>";
+
+        // The Server header is still accurate on an error response, so it is used.
+        Assert.Equal("Apache", ServiceIdentityParser.ParseHttpIdentity(head));
+    }
+
+    [Fact]
+    public void An_error_response_with_no_server_header_names_nothing() {
+        var head = "HTTP/1.1 500 Internal Server Error\r\n\r\n<title>Something broke</title>";
+
+        Assert.Null(ServiceIdentityParser.ParseHttpIdentity(head));
+    }
+
+    [Fact]
+    public void A_redirect_still_counts_as_a_working_page() {
+        var head = "HTTP/1.1 302 Found\r\nLocation: /ui\r\n\r\n<title>Home Assistant</title>";
+
+        Assert.Equal("Home Assistant", ServiceIdentityParser.ParseHttpIdentity(head));
+    }
+
+    [Fact]
+    public void An_absurdly_long_title_is_rejected_rather_than_becoming_a_name() {
+        var head = $"HTTP/1.0 200 OK\r\n\r\n<title>{new string('x', 300)}</title>";
+
+        Assert.Null(ServiceIdentityParser.ParseHttpIdentity(head));
+    }
+}

+ 24 - 0
Tests.Mcp/DiscoveryToolTests.cs

@@ -193,4 +193,28 @@ public class DiscoveryToolTests {
         Assert.Contains("Could not read", error);
         Assert.Contains("Could not read", error);
         Assert.Contains("http://127.0.0.1:1", error);
         Assert.Contains("http://127.0.0.1:1", error);
     }
     }
+
+    [Fact]
+    public async Task An_engine_with_no_ipv4_address_is_refused_rather_than_given_this_machines() {
+        // Services are recorded at their host's address. When the engine answers but has
+        // no IPv4 — the inventory holds IPv4 only — the address is genuinely unknown, and
+        // the code used to substitute the address of whatever machine ran the command.
+        // Every service then carried a confident, wrong address that flowed on into the
+        // ansible, ssh and hosts exports.
+        if (!System.Net.Sockets.Socket.OSSupportsIPv6)
+            return; // no loopback to bind; nothing to prove here on this host
+
+        await using FakeHttpServer engine = await FakeHttpServer.StartDockerEngineAsync(true);
+        using var api = new McpFixture();
+        await using McpClient client = await api.ConnectAsync();
+
+        Exception error = await Assert.ThrowsAnyAsync<Exception>(() =>
+            client.CallOkAsync<DiscoveryResult>("discover_docker", new Dictionary<string, object?> {
+                ["dockerHost"] = $"tcp://{engine.Host}"
+            }));
+
+        Assert.Contains("IPv4", error.Message);
+        // Nothing was recorded at a borrowed address.
+        Assert.DoesNotContain("jellyfin", api.StoredYaml);
+    }
 }
 }

+ 7 - 4
Tests.Mcp/FakeHttpServer.cs

@@ -21,10 +21,13 @@ internal sealed class FakeHttpServer : IAsyncDisposable {
 
 
     public string Host => new Uri(BaseUrl).Authority;
     public string Host => new Uri(BaseUrl).Authority;
 
 
-    public static async Task<FakeHttpServer> StartAsync(Action<WebApplication> map) {
+    public static async Task<FakeHttpServer> StartAsync(Action<WebApplication> map, bool ipv6 = false) {
         WebApplicationBuilder builder = WebApplication.CreateBuilder();
         WebApplicationBuilder builder = WebApplication.CreateBuilder();
         builder.Logging.ClearProviders();
         builder.Logging.ClearProviders();
-        builder.WebHost.UseUrls("http://127.0.0.1:0");
+
+        // An IPv6-only endpoint is the one case where an engine answers but has no IPv4
+        // address to record its services at.
+        builder.WebHost.UseUrls(ipv6 ? "http://[::1]:0" : "http://127.0.0.1:0");
 
 
         WebApplication app = builder.Build();
         WebApplication app = builder.Build();
         map(app);
         map(app);
@@ -34,13 +37,13 @@ internal sealed class FakeHttpServer : IAsyncDisposable {
     }
     }
 
 
     /// <summary>A fake Docker Engine API with the shared captured fixtures.</summary>
     /// <summary>A fake Docker Engine API with the shared captured fixtures.</summary>
-    public static Task<FakeHttpServer> StartDockerEngineAsync() =>
+    public static Task<FakeHttpServer> StartDockerEngineAsync(bool ipv6 = false) =>
         StartAsync(app => {
         StartAsync(app => {
             app.MapGet("/containers/json", () => Results.Content(
             app.MapGet("/containers/json", () => Results.Content(
                 TestData.Fixture("docker-containers.json"), "application/json"));
                 TestData.Fixture("docker-containers.json"), "application/json"));
             app.MapGet("/info", () => Results.Content(
             app.MapGet("/info", () => Results.Content(
                 TestData.Fixture("docker-info.json"), "application/json"));
                 TestData.Fixture("docker-info.json"), "application/json"));
-        });
+        }, ipv6);
 
 
     /// <summary>
     /// <summary>
     ///     A fake Proxmox VE API. Both fixture nodes answer with the same guest lists,
     ///     A fake Proxmox VE API. Both fixture nodes answer with the same guest lists,

+ 33 - 0
Tests/EndToEnd/DiscoveryTests/DiscoverDockerEndpointTests.cs

@@ -0,0 +1,33 @@
+using Tests.EndToEnd.Infra;
+using Xunit.Abstractions;
+
+namespace Tests.EndToEnd.DiscoveryTests;
+
+/// <summary>
+///     `rpk discover docker` against endpoints it cannot reach. Every case here fails
+///     before any container is listed, so these tests never talk to a daemon.
+/// </summary>
+[Collection("Yaml CLI tests")]
+public class DiscoverDockerEndpointTests(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("ssh://user@nas")]
+    [InlineData("npipe:////./pipe/docker_engine")]
+    public async Task an_endpoint_that_cannot_be_dialled_says_so_instead_of_crashing(string endpoint) {
+        // `docker context` sets ssh:// for a remote host and npipe:// is the Windows
+        // default, so both are ordinary values to find in DOCKER_HOST. They used to
+        // reach HttpClient, which accepts the URI and throws NotSupportedException on
+        // the first request — past the command's catch list, so the user got
+        // "Unexpected error occurred" and a stack trace.
+        var output = await ExecuteAsync("discover", "docker", "--docker-host", endpoint);
+
+        Assert.DoesNotContain("Unexpected error", output);
+        Assert.DoesNotContain("at RackPeek.", output);
+
+        // And the message says what to do about it.
+        Assert.Contains("ssh -L", output);
+    }
+}

+ 209 - 0
generate-oui-table.py

@@ -0,0 +1,209 @@
+#!/usr/bin/env python3
+"""
+Regenerates RackPeek.Domain/Discovery/MacVendorTable.g.cs from the IEEE OUI registry.
+
+    curl -sL -o /tmp/oui.csv https://standards-oui.ieee.org/oui/oui.csv
+    ./generate-oui-table.py /tmp/oui.csv
+
+The registry holds ~40,000 assignments; shipping all of them would bloat the
+single-file binaries for little gain, so this keeps a curated subset: the vendors
+whose hardware actually turns up on a homelab network. Add a pattern to VENDORS
+below and re-run to extend it.
+
+Records are packed as fixed-width "PPPPPPv" (6 hex prefix chars + one vendor index
+char) in a sorted string so the lookup is a binary search with no allocation and no
+dictionary to build at startup.
+"""
+import csv
+import re
+import sys
+
+# Display name -> pattern matched against the registry's Organization Name.
+# Anchored where a loose substring would drag in unrelated companies.
+VENDORS = {
+    "Apple": r"^Apple, Inc\.?$|^Apple Inc",
+    "Intel": r"^Intel Corporate$|^Intel Corporation$",
+    "Ubiquiti": r"Ubiquiti",
+    "Raspberry Pi": r"Raspberry Pi",
+    "Espressif": r"Espressif|^Shanghai High-Flying|Ai-Thinker",
+    "Proxmox": r"Proxmox",
+    "VMware": r"VMware",
+    "QEMU/KVM": r"^QEMU",
+    "VirtualBox": r"PCS Systemtechnik|VirtualBox",
+    "Microsoft": r"^Microsoft Corporation$",
+    "Parallels": r"^Parallels",
+    "Synology": r"Synology",
+    "QNAP": r"QNAP",
+    "Netgear": r"NETGEAR|Netgear",
+    "TP-Link": r"TP-LINK|TP-Link|Tp-Link",
+    "D-Link": r"D-Link|D\-LINK",
+    "MikroTik": r"MikroTik|Mikrotik|Routerboard",
+    "Cisco": r"^Cisco Systems|^Cisco$",
+    "Aruba": r"Aruba",
+    "Zyxel": r"ZyXEL|Zyxel",
+    "Realtek": r"Realtek",
+    "Broadcom": r"^Broadcom",
+    "NVIDIA": r"NVIDIA|Nvidia",
+    "AMD": r"^Advanced Micro Devices",
+    "Dell": r"^Dell Inc|^Dell Computer|^Dell EMC",
+    "HPE/HP": r"Hewlett Packard|^HP Inc",
+    "Supermicro": r"Super Micro|Supermicro",
+    "ASUS": r"^ASUSTek|^ASUS",
+    "ASRock": r"ASRock",
+    "Gigabyte": r"GIGA-BYTE|Gigabyte",
+    "MSI": r"Micro-Star",
+    "Lenovo": r"^Lenovo",
+    "Samsung": r"^Samsung Electro|^Samsung Electronics",
+    "LG": r"^LG Electronics",
+    "Sony": r"^Sony ",
+    "Google": r"^Google, Inc|^Google LLC",
+    "Amazon": r"^Amazon Technologies",
+    "Sonos": r"^Sonos",
+    "Signify (Hue)": r"Signify|Philips Lighting",
+    "Philips": r"^Philips",
+    "Xiaomi": r"Xiaomi|XIAOMI",
+    "IKEA": r"IKEA|Inter IKEA",
+    "Aqara": r"Lumi United|Aqara",
+    "Reolink": r"Reolink",
+    "Hikvision": r"Hikvision|HIKVISION",
+    "Dahua": r"Dahua",
+    "Axis": r"^Axis Communication",
+    "AVM (Fritz!Box)": r"^AVM ",
+    "Juniper": r"^Juniper",
+    "Fortinet": r"^Fortinet",
+    "Netgate": r"Netgate",
+    "Seagate": r"^Seagate",
+    "Western Digital": r"Western Digital",
+    "Buffalo": r"^BUFFALO|^Buffalo",
+    "Asustor": r"ASUSTOR|Asustor",
+    "TerraMaster": r"TerraMaster|Terra-?Master",
+    "Sophos": r"^Sophos",
+    "Arris/CommScope": r"^ARRIS|CommScope",
+    "Technicolor": r"Technicolor",
+    "Sagemcom": r"Sagemcom",
+    "Roku": r"^Roku",
+    "Nintendo": r"^Nintendo",
+    "Texas Instruments": r"^Texas Instruments",
+    "Tuya": r"^Tuya|Hangzhou Tuya",
+    "Shelly": r"Allterco|Shelly",
+    "Zotac": r"ZOTAC",
+    "Beelink": r"Beelink|AZW",
+    "Minisforum": r"Minisforum|MINIX",
+    "Ring": r"^Ring LLC|Ring Inc",
+    "Arlo": r"^Arlo",
+    "Wyze": r"^Wyze",
+    "Sonoff": r"ITEAD|SONOFF",
+    "Mellanox": r"Mellanox",
+    "Chelsio": r"Chelsio",
+    "Aquantia": r"Aquantia",
+    "Edimax": r"Edimax|EDIMAX",
+    "Tenda": r"^Tenda|Shenzhen Tenda",
+    "Huawei": r"^HUAWEI|^Huawei",
+    "Sercomm": r"^Sercomm",
+    "Silicon Labs": r"Silicon Laborator",
+    "Nordic Semiconductor": r"Nordic Semiconductor",
+    "Pine64": r"^Pine ?64|PINE64",
+    "Hardkernel (ODROID)": r"Hardkernel",
+    "Radxa": r"^Radxa|Rockchip",
+    "FriendlyELEC": r"FriendlyARM|FriendlyELEC",
+    "Khadas": r"Khadas|Shenzhen Wesion",
+}
+
+# Prefixes that identify software but are not IEEE assignments, because hypervisors
+# mint addresses out of the locally-administered range rather than buying an OUI.
+# Without these a KVM guest would be reported as merely "randomised".
+EXTRAS = {
+    "525400": "QEMU/KVM",
+    "00163E": "Xen",
+}
+
+# Vendor index is stored as one printable char, so the table cannot exceed this.
+_FIRST_INDEX_CHAR = 33  # '!'
+_MAX_VENDORS = 126 - _FIRST_INDEX_CHAR
+
+
+def main(csv_path: str) -> int:
+    names = sorted(set(VENDORS) | set(EXTRAS.values()))
+
+    if len(names) > _MAX_VENDORS:
+        sys.exit(f"{len(names)} vendors exceeds the {_MAX_VENDORS} a single index char can address.")
+
+    index_of = {name: i for i, name in enumerate(names)}
+    compiled = [(re.compile(pat), name) for name, pat in VENDORS.items()]
+
+    seen: dict[str, str] = {}
+
+    with open(csv_path, encoding="utf-8", errors="replace") as handle:
+        for row in csv.DictReader(handle):
+            prefix = (row.get("Assignment") or "").strip().upper()
+            org = (row.get("Organization Name") or "").strip()
+
+            if len(prefix) != 6 or prefix in seen:
+                continue
+
+            for pattern, name in compiled:
+                if pattern.search(org):
+                    seen[prefix] = name
+                    break
+
+    seen.update(EXTRAS)
+
+    records = "".join(
+        prefix + chr(_FIRST_INDEX_CHAR + index_of[name])
+        for prefix, name in sorted(seen.items())
+    )
+
+    # 140 is a whole number of records, so each source line holds exactly 20 of them.
+    chunks = [records[i:i + 140] for i in range(0, len(records), 140)]
+
+    # The index alphabet starts at '!' and runs past '"' and '\', both of which have to
+    # be escaped in the C# literal. Escaping changes only the source text; the string
+    # the runtime builds still holds the original characters, so offsets stay correct.
+    def escape(chunk: str) -> str:
+        return chunk.replace("\\", "\\\\").replace('"', '\\"')
+
+    literal = "\n".join(f'        "{escape(chunk)}" +' for chunk in chunks)
+    literal = literal.rstrip(" +") + ";"
+
+    vendor_literal = ",\n".join(f'        "{name}"' for name in names)
+
+    out = f'''// <auto-generated>
+//     Generated by generate-oui-table.py from the IEEE OUI registry
+//     (https://standards-oui.ieee.org/oui/oui.csv). Do not edit by hand — add a
+//     vendor pattern to the generator and re-run it instead.
+//
+//     {len(seen)} assignments across {len(names)} vendors, packed as fixed-width
+//     "PPPPPPv" records (6 hex prefix chars + one vendor index char), sorted so
+//     {'MacVendorLookup'} can binary-search them without building a dictionary.
+// </auto-generated>
+
+namespace RackPeek.Domain.Discovery;
+
+internal static class MacVendorTable {{
+    internal const int RecordLength = 7;
+
+    internal const char FirstIndexChar = '{chr(_FIRST_INDEX_CHAR)}';
+
+    internal static readonly string[] Vendors = [
+{vendor_literal}
+    ];
+
+    internal static readonly string Records =
+{literal}
+}}
+'''
+
+    target = "RackPeek.Domain/Discovery/MacVendorTable.g.cs"
+
+    with open(target, "w", encoding="utf-8") as handle:
+        handle.write(out)
+
+    print(f"Wrote {target}: {len(seen)} assignments, {len(names)} vendors, {len(records)} chars.")
+    return 0
+
+
+if __name__ == "__main__":
+    if len(sys.argv) != 2:
+        sys.exit(__doc__)
+
+    raise SystemExit(main(sys.argv[1]))

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

@@ -68,6 +68,10 @@
           "description": "Stable machine-generated identity set by 'rpk discover'. Absent on hand-written resources. The leading rpk<n> is the format version, so the way the id is derived can change without old ids being mistaken for new ones.",
           "description": "Stable machine-generated identity set by 'rpk discover'. Absent on hand-written resources. The leading rpk<n> is the format version, so the way the id is derived can change without old ids being mistaken for new ones.",
           "pattern": "^rpk[0-9]+:[a-z0-9]+:[0-9a-f]{16}$"
           "pattern": "^rpk[0-9]+:[a-z0-9]+:[0-9a-f]{16}$"
         },
         },
+        "userNamed": {
+          "type": "boolean",
+          "description": "True when a person chose this resource's name, which discovery then never changes."
+        },
         "tags": {
         "tags": {
           "type": "array",
           "type": "array",
           "items": {
           "items": {