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

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

Discovery: services from ports, self-improving names, and symmetric identity
Tim Jones 1 день назад
Родитель
Сommit
b9b35be6a1
60 измененных файлов с 5677 добавлено и 190 удалено
  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;
         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
         // 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
             .GroupBy(r => r.Name, StringComparer.OrdinalIgnoreCase)
@@ -123,7 +132,6 @@ public class UpsertInventoryUseCase(
             else if (oldYaml != newYaml) response.Updated.Add(incoming.Name);
         }
 
-        IReadOnlyList<Connection> currentConnections = await repo.GetConnectionsAsync();
         List<Connection>? mergedConnections = ConnectionMerger.Merge(
             currentConnections,
             incomingRoot.Connections,

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

@@ -1,6 +1,8 @@
 using System.ComponentModel.DataAnnotations;
 using RackPeek.Domain.Resources;
 using RackPeek.Domain.Resources.Connections;
+using RackPeek.Domain.Resources.Services;
+using RackPeek.Domain.Resources.SystemResources;
 
 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
     ///     between incoming resources — and the payload's <paramref name="connections" />,
     ///     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>
     public static void ResolveNames(
         IReadOnlyList<Resource> existing,
         IReadOnlyList<Resource> incoming,
-        IReadOnlyList<Connection>? connections = null) {
+        IReadOnlyList<Connection>? connections = null,
+        IReadOnlyList<Connection>? storedConnections = null,
+        bool improveStoredNames = false) {
         var incomingWithId = incoming
             .Where(r => !string.IsNullOrWhiteSpace(r.DiscoveryId))
             .ToList();
@@ -39,6 +51,7 @@ public static class DiscoveryIdResolver {
             .ToDictionary(r => r.DiscoveryId!, r => r, StringComparer.OrdinalIgnoreCase);
 
         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
         // 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 storedRenames = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
+
         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;
+            }
 
-            renames[resource.Name] = resolved;
+            renames[offeredName] = resolved;
             resource.Name = resolved;
         }
 
@@ -64,7 +101,190 @@ public static class DiscoveryIdResolver {
             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);
+        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>
@@ -103,7 +323,8 @@ public static class DiscoveryIdResolver {
         Resource resource,
         Dictionary<string, Resource> existingById,
         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.
         if (existingById.TryGetValue(resource.DiscoveryId!, out Resource? matched))
             return matched.Name;
@@ -113,6 +334,9 @@ public static class DiscoveryIdResolver {
         if (TryUnifyByMac(resource, existingByMac, out var unifiedName))
             return unifiedName;
 
+        if (TryUnifyByIp(resource, existingByIp, out unifiedName))
+            return unifiedName;
+
         // Unknown id and the name is free: nothing to reconcile.
         if (!existingByName.TryGetValue(resource.Name, out Resource? sameName))
             return resource.Name;
@@ -191,6 +415,110 @@ public static class DiscoveryIdResolver {
         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>
     private static IEnumerable<string> MacsOf(Resource resource) {
         IEnumerable<string?> raw = [

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

@@ -98,12 +98,31 @@ public sealed class DockerApiClient : IDockerClient, IDisposable {
             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.
         var uri = new Uri(dockerHost.Replace("tcp://", "http://", StringComparison.OrdinalIgnoreCase));
 
         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) {
         var handler = new SocketsHttpHandler {
             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>
     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>
     ///     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.

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

@@ -41,4 +41,16 @@ public interface IProxmoxClient {
         string endpoint,
         int vmId,
         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.NetworkInformation;
+using System.Net.Security;
 using System.Net.Sockets;
+using System.Security.Cryptography.X509Certificates;
+using System.Text;
 using RackPeek.Domain.Resources.Services.Networking;
 
 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() {
         try {
             foreach (NetworkInterface nic in NetworkInterface.GetAllNetworkInterfaces()) {

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

@@ -12,7 +12,27 @@ public sealed record NetworkHostFact(
     string? Mac,
     string? Hostname,
     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>
 public sealed record NetworkScanOptions {
@@ -31,4 +51,21 @@ public sealed record NetworkScanOptions {
 
     /// <summary>How many hosts are probed at once.</summary>
     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.Services;
 using RackPeek.Domain.Resources.Services.Networking;
 using RackPeek.Domain.Resources.SystemResources;
 
@@ -18,11 +19,18 @@ public static class NetworkScanMapper {
                 DiscoveryId.NetworkScheme,
                 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 {
                 Kind = SystemResource.KindLabel,
                 Name = DiscoveryNaming.Unique(
                     DiscoveryNaming.Suggest(
-                        DiscoveryNaming.HostLabel(host.Hostname),
+                        label,
                         "host",
                         discoveryId),
                     discoveryId,
@@ -37,15 +45,96 @@ public static class NetworkScanMapper {
             if (host.Mac != null)
                 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)
                 system.Labels["ips"] = string.Join(",", allIps);
 
             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;
     }
 
+    /// <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>
     ///     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

+ 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);
 
         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
@@ -85,6 +103,157 @@ public static class NetworkScanner {
             .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>
     private static async Task<(string Ip, bool Ping, List<int> Open)?> SweepHostAsync(
         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;
 
 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) {
         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; } = [];
 
+    /// <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>
     public string? Os { get; init; }
 
@@ -424,6 +430,7 @@ public static class ProxmoxResponseParser {
             Cores = GetInt(element, "cpus") ?? 0,
             MemoryBytes = GetLong(element, "maxmem") ?? 0,
             DiskBytes = GetLong(element, "maxdisk") ?? 0,
+            Status = GetString(element, "status"),
             Tags = ParseTags(GetString(element, "tags"))
         };
     }
@@ -455,18 +462,138 @@ public static class ProxmoxResponseParser {
             ? value.GetString()
             : 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>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(
     string? Os,
     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
         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(
                 resourceCollection.Resources,
                 incomingResources,
-                incomingRoot.Connections);
+                incomingRoot.Connections,
+                resourceCollection.Connections,
+                true);
 
             List<Resource> merged = ResourceCollectionMerger.Merge(
                 resourceCollection.Resources,

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

@@ -6,6 +6,13 @@
         <Nullable>enable</Nullable>
     </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>
         <PackageReference Include="DocMigrator.Yaml" Version="10.0.3" />
         <PackageReference Include="LibGit2Sharp" Version="0.32.0" />

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

@@ -59,6 +59,34 @@ public abstract class Resource {
     /// </summary>
     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 Dictionary<string, string> Labels { get; set; } = new();
     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;
 
 public class Service : Resource {
     public const string KindLabel = "Service";
     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 {

+ 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.");
 
         original.Name = newName;
+
+        // A person has now chosen this name, so discovery must stop improving on it.
+        original.UserNamed = true;
+
         await repo.UpdateAsync(original);
 
         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 Microsoft.Extensions.Configuration;
 using Microsoft.Extensions.DependencyInjection;
@@ -79,9 +80,17 @@ public sealed class DiscoveryTools(IServiceProvider services) {
                 ? host.MachineId ?? host.Hostname
                 : 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
                 ? 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);
 
@@ -125,7 +134,7 @@ public sealed class DiscoveryTools(IServiceProvider services) {
 
             List<Resource> resources;
             try {
-                resources = await ReadProxmoxAsync(client, cancellationToken);
+                resources = await ProxmoxDiscovery.ReadAsync(client, cancellationToken);
             }
             catch (HttpRequestException ex) {
                 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) {
         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.",
           "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": {
           "type": "array",
           "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.",
           "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": {
           "type": "array",
           "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", "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")
                     .WithDescription("Sweep a subnet and emit every answering host as a System resource.")
                     .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;
 
         // 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
             ? 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);
 

+ 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.")]
     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>
     public NetworkCidr? ParsedCidr =>
         NetworkCidr.TryParse(Cidr, out NetworkCidr parsed) ? parsed : null;
@@ -128,7 +132,8 @@ public sealed class DiscoverNetworkCommand(INetworkProbe probe)
             Cidr = cidr,
             Ports = settings.ResolvedPorts,
             PortTimeout = TimeSpan.FromMilliseconds(settings.Timeout),
-            Concurrency = settings.Parallel
+            Concurrency = settings.Parallel,
+            IdentifyServices = !settings.NoIdentify
         };
 
         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;
 
         try {
-            resources = await ReadAsync(client, cancellationToken);
+            resources = await ProxmoxDiscovery.ReadAsync(client, cancellationToken);
         }
         catch (HttpRequestException ex) {
             AnsiConsole.MarkupLine(
@@ -98,45 +98,4 @@ public sealed class DiscoverProxmoxCommand : AsyncCommand<DiscoverProxmoxSetting
 
         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:
                                         var endpoint = service.NetworkString();
+                                        var browsable = service.BrowsableUrl();
 
                                         <NavLink href="@($"resources/services/{Uri.EscapeDataString(service.Name)}")"
                                                  class="block">
@@ -69,13 +70,34 @@ else
                                                     @if (!string.IsNullOrWhiteSpace(endpoint))
                                                     {
                                                         <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>
@@ -107,10 +129,7 @@ else
     {
         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)}");
     }
 
-    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

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

@@ -18,6 +18,7 @@ else
                 {
                     case Service service:
                         var endpoint = service.NetworkString();
+                                        var browsable = service.BrowsableUrl();
 
                         <NavLink href="@($"resources/services/{Uri.EscapeDataString(service.Name)}")"
                                  class="block">
@@ -32,13 +33,34 @@ else
                                     @if (!string.IsNullOrWhiteSpace(endpoint))
                                     {
                                         <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>
 
@@ -83,10 +105,7 @@ else
     {
         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
     - [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
+    - [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
   - [ansible](/docs/cli-commands#rpk-ansible) - Generate and manage 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
 
 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`
@@ -4061,6 +4064,38 @@ OPTIONS:
                                    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`
 ```
 DESCRIPTION:
@@ -4090,6 +4125,8 @@ OPTIONS:
                            e.g. 22,80,443. Defaults to a curated homelab list   
         --timeout <MS>     Milliseconds to wait on each port probe              
         --parallel <N>     How many hosts to probe at once                      
+        --no-identify      Skip asking living hosts what they are — sweep for   
+                           liveness only                                        
 ```
 
 ## `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 docker` | the Docker Engine API | one **Service** per published container, plus the **System** they run on |
 | `rpk discover proxmox` | a Proxmox VE cluster | a **Server** and **System** per node, a **System** per guest, already wired together |
-| `rpk discover network` | a subnet, from outside | one **System** per host that answers ping or a well-known TCP port |
+| `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
 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:
 
 * **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
   first discovery run attaches to it — keeping your notes and gaining an id — rather
   than creating a duplicate.
 * **Two machines cannot collide.** A second machine that happens to share a hostname is
   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
 > template without resetting it, every clone reports the same identity. RackPeek rejects
 > 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 **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
   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.
 
 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
 - kind: Server
-  name: kepler
+  name: pve-node-01
   cpus:
   - model: AMD Ryzen 5 5600G
     cores: 6
@@ -252,14 +281,14 @@ the graph views are built around.
   - model: GeForce RTX 3090
   - model: GeForce RTX 3090
 - kind: System
-  name: kepler-pve
+  name: pve-node-01-pve
   type: hypervisor
   os: Proxmox VE 8.2.2
-  runsOn: [kepler]
+  runsOn: [pve-node-01]
 - kind: System
   name: docker-01
   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
@@ -291,7 +320,7 @@ visible from either end:
   type: vm
   labels:
     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
@@ -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`
 
 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
 # 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
 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
 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:
 
 ```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
@@ -383,8 +611,9 @@ the same card**, whichever ran first:
 - Agent first: a later sweep recognises the box and just refreshes its address —
   never touching the identity or anything you or the agent wrote.
 
-The card keeps whatever name it already had (names are always user-owned), so a
-scan-first card keeps its generated `host-…` name until you rename it once. A MAC that
+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
 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

+ 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.
 /// </summary>
 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) =>
         new() {
             Cidr = Cidr.Parse(cidr),
             Ports = ports.Length > 0 ? ports : [22, 80],
             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]
     public async Task A_host_that_answers_nothing_is_not_reported() {
         var probe = new ScriptedProbe();
@@ -118,8 +279,13 @@ public class NetworkScannerTests {
         public Dictionary<string, string> Names { get; } = [];
         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> DnsLookups { get; } = [];
+        public List<(string Ip, int Port, string Kind)> IdentityProbes { get; } = [];
         public bool IsSupported => true;
         public int MaxInFlight { get; private set; }
         public bool ArpReadAfterSweep { get; private set; }
@@ -178,6 +344,43 @@ public class NetworkScannerTests {
             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;
     }
 }

+ 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;
     }
+
+    // -- 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("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 static async Task<FakeHttpServer> StartAsync(Action<WebApplication> map) {
+    public static async Task<FakeHttpServer> StartAsync(Action<WebApplication> map, bool ipv6 = false) {
         WebApplicationBuilder builder = WebApplication.CreateBuilder();
         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();
         map(app);
@@ -34,13 +37,13 @@ internal sealed class FakeHttpServer : IAsyncDisposable {
     }
 
     /// <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 => {
             app.MapGet("/containers/json", () => Results.Content(
                 TestData.Fixture("docker-containers.json"), "application/json"));
             app.MapGet("/info", () => Results.Content(
                 TestData.Fixture("docker-info.json"), "application/json"));
-        });
+        }, ipv6);
 
     /// <summary>
     ///     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.",
           "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": {
           "type": "array",
           "items": {