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

Merge pull request #341 from Timmoth/staging

Staging
Tim Jones 8 часов назад
Родитель
Сommit
37717e5620
100 измененных файлов с 10648 добавлено и 253 удалено
  1. 1 1
      .github/workflows/generate-docs.yaml
  2. 2 2
      .github/workflows/publish-cli.yml
  3. 2 2
      .github/workflows/publish-docker.yaml
  4. 53 0
      .github/workflows/test.yml
  5. 4 0
      .gitignore
  6. 3 0
      .vscode/settings.json
  7. 3 2
      AGENTS.md
  8. 22 111
      README.md
  9. 76 0
      RackPeek.Domain/Api/InventoryDocumentJson.cs
  10. 3 0
      RackPeek.Domain/Api/InventoryResponse.cs
  11. 84 24
      RackPeek.Domain/Api/UpsertInventoryUseCase.cs
  12. 107 0
      RackPeek.Domain/Discovery/ArpTableParser.cs
  13. 15 0
      RackPeek.Domain/Discovery/DiscoveryDocument.cs
  14. 49 0
      RackPeek.Domain/Discovery/DiscoveryId.cs
  15. 594 0
      RackPeek.Domain/Discovery/DiscoveryIdResolver.cs
  16. 90 0
      RackPeek.Domain/Discovery/DiscoveryNaming.cs
  17. 95 0
      RackPeek.Domain/Discovery/DiscoveryPublisher.cs
  18. 146 0
      RackPeek.Domain/Discovery/DockerApiClient.cs
  19. 107 0
      RackPeek.Domain/Discovery/DockerContainer.cs
  20. 48 0
      RackPeek.Domain/Discovery/DockerEngineInfo.cs
  21. 86 0
      RackPeek.Domain/Discovery/DockerServiceMapper.cs
  22. 21 0
      RackPeek.Domain/Discovery/IDockerClient.cs
  23. 61 0
      RackPeek.Domain/Discovery/INetworkProbe.cs
  24. 56 0
      RackPeek.Domain/Discovery/IProxmoxClient.cs
  25. 30 0
      RackPeek.Domain/Discovery/ISystemProbe.cs
  26. 67 0
      RackPeek.Domain/Discovery/LinuxSystemProbe.cs
  27. 76 0
      RackPeek.Domain/Discovery/MacSystemProbe.cs
  28. 106 0
      RackPeek.Domain/Discovery/MacVendorLookup.cs
  29. 653 0
      RackPeek.Domain/Discovery/MacVendorTable.g.cs
  30. 259 0
      RackPeek.Domain/Discovery/NetworkProbe.cs
  31. 71 0
      RackPeek.Domain/Discovery/NetworkScanFacts.cs
  32. 183 0
      RackPeek.Domain/Discovery/NetworkScanMapper.cs
  33. 287 0
      RackPeek.Domain/Discovery/NetworkScanner.cs
  34. 128 0
      RackPeek.Domain/Discovery/OpnsenseApiClient.cs
  35. 105 0
      RackPeek.Domain/Discovery/OpnsenseDiscovery.cs
  36. 120 0
      RackPeek.Domain/Discovery/OpnsenseModels.cs
  37. 223 0
      RackPeek.Domain/Discovery/ProxmoxApiClient.cs
  38. 107 0
      RackPeek.Domain/Discovery/ProxmoxDiscovery.cs
  39. 602 0
      RackPeek.Domain/Discovery/ProxmoxModels.cs
  40. 253 0
      RackPeek.Domain/Discovery/ProxmoxResourceMapper.cs
  41. 211 0
      RackPeek.Domain/Discovery/ServiceIdentityParser.cs
  42. 81 0
      RackPeek.Domain/Discovery/SystemFacts.cs
  43. 182 0
      RackPeek.Domain/Discovery/SystemFactsParser.cs
  44. 133 0
      RackPeek.Domain/Discovery/SystemProbeCommon.cs
  45. 42 0
      RackPeek.Domain/Discovery/SystemResourceMapper.cs
  46. 97 0
      RackPeek.Domain/Discovery/WellKnownPorts.cs
  47. 18 0
      RackPeek.Domain/Helpers/ConfigLoadException.cs
  48. 16 0
      RackPeek.Domain/Helpers/PortSummaries.cs
  49. 5 2
      RackPeek.Domain/Helpers/ThrowIfInvalid.cs
  50. 1 0
      RackPeek.Domain/Persistence/IResourceCollection.cs
  51. 3 3
      RackPeek.Domain/Persistence/ResourceCollectionMerger.cs
  52. 49 1
      RackPeek.Domain/Persistence/Yaml/ITextFileStore.cs
  53. 55 5
      RackPeek.Domain/Persistence/Yaml/RackPeekConfigMigrationDeserializer.cs
  54. 188 32
      RackPeek.Domain/Persistence/Yaml/YamlResourceCollection.cs
  55. 10 3
      RackPeek.Domain/RackPeek.Domain.csproj
  56. 2 0
      RackPeek.Domain/Resources/Laptops/DescribeLaptopUseCase.cs
  57. 2 1
      RackPeek.Domain/Resources/Laptops/Laptop.cs
  58. 2 0
      RackPeek.Domain/Resources/OtherHardware/DescribeOtherUseCase.cs
  59. 5 1
      RackPeek.Domain/Resources/OtherHardware/Other.cs
  60. 35 0
      RackPeek.Domain/Resources/Resource.cs
  61. 17 0
      RackPeek.Domain/Resources/Services/Networking/Cidr.cs
  62. 12 5
      RackPeek.Domain/Resources/Services/Networking/IpHelper.cs
  63. 17 22
      RackPeek.Domain/Resources/Services/Service.cs
  64. 87 0
      RackPeek.Domain/Resources/Services/ServiceEndpoint.cs
  65. 1 6
      RackPeek.Domain/Resources/Services/UseCases/ServiceSubnetsUseCase.cs
  66. 4 1
      RackPeek.Domain/Resources/SubResources/Nic.cs
  67. 2 0
      RackPeek.Domain/Resources/UpsUnits/DescribeUpsUseCase.cs
  68. 5 1
      RackPeek.Domain/Resources/UpsUnits/Ups.cs
  69. 1 1
      RackPeek.Domain/RpkConstants.cs
  70. 1 1
      RackPeek.Domain/Search/GlobalSearchService.cs
  71. 8 0
      RackPeek.Domain/ServiceCollectionExtensions.cs
  72. 7 0
      RackPeek.Domain/UseCases/Ansible/AnsibleInventoryGenerator.cs
  73. 5 0
      RackPeek.Domain/UseCases/CloneAccessPointUseCase.cs
  74. 12 2
      RackPeek.Domain/UseCases/RenameResourceUseCase.cs
  75. 25 0
      RackPeek.Mcp/McpSetup.cs
  76. 30 0
      RackPeek.Mcp/RackPeek.Mcp.csproj
  77. 54 0
      RackPeek.Mcp/ResourceKindDispatch.cs
  78. 38 0
      RackPeek.Mcp/ToolErrors.cs
  79. 83 0
      RackPeek.Mcp/Tools/ExportTools.cs
  80. 95 0
      RackPeek.Mcp/Tools/GitTools.cs
  81. 170 0
      RackPeek.Mcp/Tools/MutationTools.cs
  82. 220 0
      RackPeek.Mcp/Tools/QueryTools.cs
  83. 13 2
      RackPeek.Web.Viewer/App.razor
  84. 2 2
      RackPeek.Web.Viewer/RackPeek.Web.Viewer.csproj
  85. 28 2
      RackPeek.Web.Viewer/wwwroot/schemas/v3/schema.v3.json
  86. 718 0
      RackPeek.Web.Viewer/wwwroot/schemas/v4/schema.v4.json
  87. 724 0
      RackPeek.Web.Viewer/wwwroot/schemas/v5/schema.v5.json
  88. 14 0
      RackPeek.Web/Api/ApiStatusEndpoints.cs
  89. 83 0
      RackPeek.Web/Api/InventoryReadEndpoints.cs
  90. 12 2
      RackPeek.Web/Components/Routes.razor
  91. 40 0
      RackPeek.Web/Program.cs
  92. 6 1
      RackPeek.Web/RackPeek.Web.csproj
  93. 28 2
      RackPeek.Web/wwwroot/schemas/v3/schema.v3.json
  94. 718 0
      RackPeek.Web/wwwroot/schemas/v4/schema.v4.json
  95. 724 0
      RackPeek.Web/wwwroot/schemas/v5/schema.v5.json
  96. 105 0
      RackPeek.sln
  97. 8 8
      RackPeek/RackPeek.csproj
  98. 99 5
      Shared.Rcl/CliBootstrap.cs
  99. 139 0
      Shared.Rcl/Commands/Discovery/DiscoverDockerCommand.cs
  100. 158 0
      Shared.Rcl/Commands/Discovery/DiscoverNetworkCommand.cs

+ 1 - 1
.github/workflows/generate-docs.yaml

@@ -35,6 +35,6 @@ jobs:
         run: |
           git config user.name "github-actions"
           git config user.email "github-actions@github.com"
-          git add Shared.Rcl/wwwroot/raw_docs/CommandIndex.md Shared.Rcl/wwwroot/raw_docs/Commands.md
+          git add Shared.Rcl/wwwroot/raw_docs/cli-commands-index.md Shared.Rcl/wwwroot/raw_docs/cli-commands.md
           git commit -m "Update CLI docs" || echo "No changes to commit"
           git push

+ 2 - 2
.github/workflows/publish-cli.yml

@@ -4,9 +4,9 @@ on:
   workflow_dispatch:
     inputs:
       version:
-        description: "RackPeek version (e.g. 0.1.0)"
+        description: "RackPeek version (e.g. 2.3.0)"
         required: true
-        default: "0.0.3"
+        default: "2.3.0"
 
 permissions:
   contents: write

+ 2 - 2
.github/workflows/publish-docker.yaml

@@ -4,9 +4,9 @@ on:
   workflow_dispatch:
     inputs:
       version:
-        description: "RackPeek version (e.g. v1.0.0)"
+        description: "RackPeek version (e.g. v2.3.0)"
         required: true
-        default: "v0.0.12"
+        default: "v2.3.0"
 
 permissions:
   contents: read

+ 53 - 0
.github/workflows/test.yml

@@ -26,6 +26,59 @@ jobs:
         run: dotnet format --verify-no-changes
 
 
+  discovery-tests:
+    name: Discovery Tests (${{ matrix.os }})
+    runs-on: ${{ matrix.os }}
+    needs: format
+
+    # Discovery runs on the machines being inventoried rather than on the RackPeek
+    # host, so its tests are the ones that have to pass on every platform. They are
+    # driven entirely from captured fixtures and never read the runner itself.
+    # windows-latest joins this list when the Windows probe lands.
+    strategy:
+      fail-fast: false
+      matrix:
+        os: [ubuntu-latest, macos-latest]
+
+    steps:
+      - name: Checkout
+        uses: actions/checkout@v4
+
+      - name: Setup .NET
+        uses: actions/setup-dotnet@v4
+        with:
+          dotnet-version: 10.0.x
+
+      - name: Restore
+        run: dotnet restore Tests.Discovery
+
+      - name: Run Discovery Tests
+        run: dotnet test Tests.Discovery --configuration Release --verbosity normal
+
+
+  mcp-tests:
+    name: MCP Tests
+    runs-on: ubuntu-latest
+    needs: format
+
+    # Every MCP test is end-to-end: a real MCP client speaking streamable HTTP to the
+    # real server over WebApplicationFactory, asserting on the YAML that lands on disk.
+    steps:
+      - name: Checkout
+        uses: actions/checkout@v4
+
+      - name: Setup .NET
+        uses: actions/setup-dotnet@v4
+        with:
+          dotnet-version: 10.0.x
+
+      - name: Restore
+        run: dotnet restore Tests.Mcp
+
+      - name: Run MCP Tests
+        run: dotnet test Tests.Mcp --configuration Release --verbosity normal
+
+
   cli-tests:
     name: CLI Tests
     runs-on: ubuntu-latest

+ 4 - 0
.gitignore

@@ -427,3 +427,7 @@ FodyWeavers.xsd
 
 # macOS
 .DS_Store
+ISSUE_TRIAGE.md
+
+# Local working notes, credentials, and deployment configuration
+/temp/

+ 3 - 0
.vscode/settings.json

@@ -0,0 +1,3 @@
+{
+    "liveServer.settings.port": 5501
+}

+ 3 - 2
AGENTS.md

@@ -207,7 +207,7 @@ When you change persisted YAML shape, the PR **must** include:
 
 ## 7. CLI surface
 
-The full command tree is documented in `docs/Commands.md` and `docs/CommandIndex.md` (auto-generated by `generate-docs.sh`). At a glance:
+The full command tree is documented in `Shared.Rcl/wwwroot/raw_docs/cli-commands.md` and `Shared.Rcl/wwwroot/raw_docs/cli-commands-index.md` (auto-generated by `generate-docs.sh`). At a glance:
 
 ```
 rpk <kind> <verb> [name] [flags]
@@ -311,8 +311,9 @@ Default branches: feature work targets `staging`; releases flow `staging → mai
 | `docs/development/contribution-guidelines.md` | PR process |
 | `docs/development/dev-cheat-sheet.md` | Build / release / Docker / Playwright details |
 | `docs/development/dev-setup.md` | First-time environment setup |
+| `docs/development/release-guide.md` | End-to-end release process (version bump, docs regen, publish workflows) |
 | `docs/development/testing-guidelines.md` | Testing philosophy + examples |
-| `docs/Commands.md` / `docs/CommandIndex.md` | Auto-generated CLI reference |
+| `Shared.Rcl/wwwroot/raw_docs/cli-commands.md` / `cli-commands-index.md` | Auto-generated CLI reference |
 | `schemas/v1,v2,v3/` | Versioned YAML schemas |
 | `README.md` | User-facing overview, Docker install, links |
 | `LICENSE` | License terms |

+ 22 - 111
README.md

@@ -1,130 +1,41 @@
-[![RackPeek demo](./assets/rackpeek_banner_thin.png)](./assets/rackpeek_banner_thin.png)
+[![RackPeek](./assets/rackpeek_banner_thin.png)](https://timmoth.github.io/RackPeek/)
 
-![Version](https://img.shields.io/badge/Version-2.1.0-2ea44f) ![Status](https://img.shields.io/badge/Status-Stable-success)
-[![Join our Discord](https://img.shields.io/badge/Discord-Join%20Us-7289DA?logo=discord&logoColor=white)](https://discord.gg/egXRPdesee) [![Live Demo](https://img.shields.io/badge/Live%20Demo-Try%20RackPeek%20Online-2ea44f?logo=githubpages&logoColor=white)](https://timmoth.github.io/RackPeek/) [![Docker Hub](https://img.shields.io/badge/Docker%20Hub-rackpeek-2496ED?logo=docker&logoColor=white)](https://hub.docker.com/r/aptacode/rackpeek/)
+![Version](https://img.shields.io/badge/Version-2.3.0-2ea44f) ![Status](https://img.shields.io/badge/Status-Stable-success)
+[![Discord](https://img.shields.io/badge/Discord-Join%20Us-7289DA?logo=discord&logoColor=white)](https://discord.gg/egXRPdesee)
+[![Live Demo](https://img.shields.io/badge/Live%20Demo-Try%20RackPeek%20Online-2ea44f?logo=githubpages&logoColor=white)](https://timmoth.github.io/RackPeek/)
+[![Docker Hub](https://img.shields.io/badge/Docker%20Hub-rackpeek-2496ED?logo=docker&logoColor=white)](https://hub.docker.com/r/aptacode/rackpeek/)
 
-RackPeek is a webui & CLI tool for documenting and managing home lab and small-scale IT infrastructure.
+RackPeek is a self-hosted CLI and web app for documenting home labs and small IT environments. Track hardware, systems, services, and connections in a portable YAML inventory. Use the CLI or web app to manage it, discover existing infrastructure, and export data for tools such as Ansible.
 
-It helps you track hardware, services, networks, and their relationships in a clear, scriptable, and reusable way without enterprise bloat or proprietary lock-in or drowning in unnecessary metadata or process.
+## Quick start
 
-### The roadmap for the next wave of features is actively being discussed, please make your voice heard! 
+Run RackPeek with a persistent Docker volume:
 
-[![DB Tech — Finally Document Your Home Lab the Easy Way (Docker Install)](https://img.shields.io/badge/DB%20Tech%20[video]-Finally%20Document%20Your%20Home%20Lab%20the%20Easy%20Way-blue?style=for-the-badge)](https://www.youtube.com/watch?v=RJtMO8kIsqU)
-[![Brandon Lee — I’m Documenting My Entire Home Lab as Code \[video\]](https://img.shields.io/badge/Brandon%20Lee%20\[video\]-I%E2%80%99m%20Documenting%20My%20Entire%20Home%20Lab%20as%20Code-blue?style=for-the-badge)](https://www.youtube.com/watch?v=wY1DgT3GD6U)
-[![Brandon Lee — I’m Documenting My Entire Home Lab](https://img.shields.io/badge/Brandon%20Lee%20[article]-I%E2%80%99m%20Documenting%20My%20Entire%20Home%20Lab%20as%20Code-blue?style=for-the-badge)](https://www.virtualizationhowto.com/2026/02/im-documenting-my-entire-home-lab-as-code-with-rackpeek/)
-[![Jared Heinrichs — How to Document Your Entire Homelab](https://img.shields.io/badge/Jared%20Heinrichs%20[article]-How%20to%20Document%20Your%20Entire%20Homelab-blue?style=for-the-badge)](https://jaredheinrichs.substack.com/p/how-to-document-your-entire-homelab)
-
-
-[![RackPeek demo](./vhs/rpk-demo.gif)](./rpk-demo.gif)
-[![RackPeek demo](./vhs/webui_screenshots/output.gif)](./rpk-webui-demo.gif)
-
-## Running RackPeek with Docker
-```text
-# Named volume
+```sh
 docker volume create rackpeek-config
 docker run -d \
   --name rackpeek \
   -p 8080:8080 \
   -v rackpeek-config:/app/config \
   aptacode/rackpeek:latest
-
-# Bind mount
-mkdir -p config
-sudo chown 1654:1654 config
-docker run -d \
-  --name rackpeek \
-  -p 8080:8080 \
-  -v $(pwd)/config:/app/config:Z \
-  aptacode/rackpeek:latest
-
-# RackPeek runs as UID/GID 1654:1654 inside the container, so a bind-mounted
-# host directory must be writable by that user. The :Z suffix is needed on
-# SELinux-enabled systems such as Fedora, RHEL, and CentOS; it can be omitted
-# on systems without SELinux.
-
-# To verify the container user:
-docker exec rackpeek id
-
-# Note - RackPeek stores its state in YAML
-config/
-└── config.yaml
-```
-Or Docker compose
-```yaml
-version: "3.9"
-
-services:
-  rackpeek:
-    image: aptacode/rackpeek:latest
-    container_name: rackpeek
-    ports:
-      - "8080:8080"
-    volumes:
-      - rackpeek-config:/app/config
-    restart: unless-stopped
-    healthcheck:
-      test: ["CMD", "curl", "-fsS", "http://localhost:8080/health"]
-      interval: 30s
-      timeout: 5s
-      start_period: 15s
-      retries: 3
-
-volumes:
-  rackpeek-config:
-
 ```
 
-## Docs
-
-* 
-  [**Overview**](https://timmoth.github.io/RackPeek/docs/overview)
-
-* 
-  [**Installation Guide**](https://timmoth.github.io/RackPeek/docs/install-guide)
-
-* 
-  [**Ansible Inventory Generator Guide**](https://timmoth.github.io/RackPeek/docs/ansible-generator-guide)
-
-* 
-  [**CLI Commands Reference**](https://timmoth.github.io/RackPeek/docs/cli-commands)
-
-* 
-  [**Versioning**](https://timmoth.github.io/RackPeek/docs/versioning)
-
-
-## Questionnaire
-
-We’re gathering feedback from homelabbers to validate direction and prioritize features.  
-Answer whichever questions stand out to you, your input directly shapes the project.
-
-[![User Questionnaire](https://img.shields.io/badge/Questionnaire-Share%20Feedback-orange?logo=googleforms&logoColor=white)](https://forms.gle/KKA4bqfGAeRYvGxT6)
-
-## Core Values
-
-**Simplicity**  
-RackPeek focuses on clarity and usefulness. Its scope is intentionally kept narrow to avoid unnecessary abstraction and feature creep.
-
-**Ease of Deployment**  
-The tool exists to reduce operational complexity. Installation, upgrades, and day-to-day usage should be straightforward and low-friction.
-
-**Openness**  
-RackPeek uses open, non-proprietary data formats. You fully own your data and should be free to easily inspect, migrate, or reuse it however you choose.
-
-**Community**  
-Contributors of all experience levels are welcome. Knowledge sharing, mentorship, and collaboration are core to the project’s culture.
+Open [http://localhost:8080](http://localhost:8080). RackPeek stores its inventory in `config.yaml` inside the volume. For bind mounts, the host directory must be writable by container user `1654`; add `:Z` on SELinux systems.
 
-**Privacy & Security**  
-No telemetry, no ads, no tracking, and no artificial restrictions. What runs on your infrastructure stays on your infrastructure.
+## Mobile app
 
-**Dogfooding**  
-RackPeek is built to solve real problems we actively have. If a feature isn’t useful in practice, it doesn’t belong.
+The iOS and Android app supports local inventory editing and syncs with a RackPeek server when connected. The app is in early access; [join the Discord](https://discord.gg/egXRPdesee) for updates and testing information.
 
-**Opinionated**  
-The project is optimized for home labs and self-hosted environments, not enterprise CMDBs or corporate documentation workflows.
+## Documentation
 
+- [Overview](https://timmoth.github.io/RackPeek/docs/overview)
+- [Installation](https://timmoth.github.io/RackPeek/docs/install-guide)
+- [CLI reference](https://timmoth.github.io/RackPeek/docs/cli-commands)
+- [Discovery](https://timmoth.github.io/RackPeek/docs/discovery-guide)
+- [Inventory API](https://timmoth.github.io/RackPeek/docs/inventory-api)
+- [MCP server](https://timmoth.github.io/RackPeek/docs/mcp-guide)
+- [Ansible inventory](https://timmoth.github.io/RackPeek/docs/ansible-generator-guide)
 
-## Development Docs
+## Development
 
-* [`contribution-guidelines.md`](docs/development/contribution-guidelines.md) – How to propose changes and submit PRs
-* [`dev-cheat-sheet.md`](docs/development/dev-cheat-sheet.md) – Build, release, Docker, and testing commands
-* [`testing-guidelines.md`](docs/development/testing-guidelines.md) – Testing principles and standards
+See the [developer setup](docs/development/dev-setup.md), [testing guide](docs/development/testing-guidelines.md), and [contribution guide](docs/development/contribution-guidelines.md). The release process is in [release-guide.md](docs/development/release-guide.md).

+ 76 - 0
RackPeek.Domain/Api/InventoryDocumentJson.cs

@@ -0,0 +1,76 @@
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using System.Text.Json.Serialization;
+using RackPeek.Domain.Persistence;
+using RackPeek.Domain.Persistence.Yaml;
+using RackPeek.Domain.Resources;
+using RackPeek.Domain.Resources.AccessPoints;
+using RackPeek.Domain.Resources.Connections;
+using RackPeek.Domain.Resources.Desktops;
+using RackPeek.Domain.Resources.Firewalls;
+using RackPeek.Domain.Resources.Laptops;
+using RackPeek.Domain.Resources.OtherHardware;
+using RackPeek.Domain.Resources.Routers;
+using RackPeek.Domain.Resources.Servers;
+using RackPeek.Domain.Resources.Services;
+using RackPeek.Domain.Resources.Switches;
+using RackPeek.Domain.Resources.SystemResources;
+using RackPeek.Domain.Resources.UpsUnits;
+
+namespace RackPeek.Domain.Api;
+
+/// <summary>
+///     Reads the inventory as a JSON document in the same shape <c>POST /api/inventory</c>
+///     accepts: camelCase with a per-resource "kind" discriminator. The discriminator is
+///     written from the CLR type, so what this produces can be fed straight back to the
+///     import endpoint — including the AccessPoint/Accesspoint casing difference that
+///     trips hand-written documents.
+/// </summary>
+public static class InventoryDocumentJson {
+    public static readonly JsonSerializerOptions Options = new() {
+        PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+        DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+        ReferenceHandler = ReferenceHandler.IgnoreCycles
+    };
+
+    public static string KindLabel(Resource resource) => resource switch {
+        Server => Server.KindLabel,
+        Switch => Switch.KindLabel,
+        Firewall => Firewall.KindLabel,
+        Router => Router.KindLabel,
+        Desktop => Desktop.KindLabel,
+        Laptop => Laptop.KindLabel,
+        AccessPoint => AccessPoint.KindLabel,
+        Ups => Ups.KindLabel,
+        Other => Other.KindLabel,
+        SystemResource => SystemResource.KindLabel,
+        Service => Service.KindLabel,
+        _ => throw new InvalidOperationException($"Unknown resource type: {resource.GetType().Name}")
+    };
+
+    public static JsonNode ToNode(Resource resource) {
+        // Serialize against the concrete type: no polymorphism resolver, so the
+        // Resource.Kind property does not collide with the discriminator name.
+        // The discriminator must come first — the import endpoint's reader cannot
+        // buffer derived-type fields (ports, network, …) until the type is resolved.
+        JsonObject body = JsonSerializer.SerializeToNode(resource, resource.GetType(), Options)!.AsObject();
+        body.Remove("kind");
+
+        var node = new JsonObject { ["kind"] = KindLabel(resource) };
+        foreach (KeyValuePair<string, JsonNode?> property in body)
+            node[property.Key] = property.Value?.DeepClone();
+
+        return node;
+    }
+
+    public static async Task<JsonObject> BuildDocumentAsync(IResourceCollection repo) {
+        (IReadOnlyList<Resource> resources, IReadOnlyList<Connection> connections) =
+            await repo.GetInventorySnapshotAsync();
+
+        return new JsonObject {
+            ["version"] = RackPeekConfigMigrationDeserializer.ListOfMigrations.Count,
+            ["resources"] = new JsonArray([.. resources.Select(ToNode)]),
+            ["connections"] = new JsonArray([.. connections.Select(c => JsonSerializer.SerializeToNode(c, Options))])
+        };
+    }
+}

+ 3 - 0
RackPeek.Domain/Api/InventoryResponse.cs

@@ -5,6 +5,9 @@ public class ImportYamlResponse {
     public List<string> Updated { get; set; } = new();
     public List<string> Replaced { get; set; } = new();
 
+    public Dictionary<string, string> Renamed { get; set; }
+        = new(StringComparer.OrdinalIgnoreCase);
+
     public Dictionary<string, string> OldYaml { get; set; }
         = new(StringComparer.OrdinalIgnoreCase);
 

+ 84 - 24
RackPeek.Domain/Api/UpsertInventoryUseCase.cs

@@ -2,6 +2,7 @@ using System.Collections.Specialized;
 using System.ComponentModel.DataAnnotations;
 using System.Text.Json;
 using System.Text.Json.Serialization;
+using RackPeek.Domain.Discovery;
 using RackPeek.Domain.Persistence;
 using RackPeek.Domain.Persistence.Yaml;
 using RackPeek.Domain.Resources;
@@ -52,6 +53,13 @@ public class UpsertInventoryUseCase(
                                _jsonOptions)
                            ?? throw new ValidationException("Invalid JSON structure.");
 
+            // The JSON "kind" is consumed as the polymorphism discriminator, so the
+            // Kind property stays empty. Stamp it from the resolved type — the same
+            // label the discriminator carried — so diff snapshots and the re-serialized
+            // YAML match what a YAML upload would produce.
+            foreach (Resource resource in incomingRoot.Resources ?? [])
+                resource.Kind = InventoryDocumentJson.KindLabel(resource);
+
             yamlInput = YamlResourceCollection.SerializeRootAsync(incomingRoot);
         }
 
@@ -60,17 +68,11 @@ public class UpsertInventoryUseCase(
 
         // 2️Compute Diff
         List<Resource>? incomingResources = incomingRoot.Resources;
-        IReadOnlyList<Resource> currentResources = await repo.GetAllOfTypeAsync<Resource>();
-
-        IGrouping<string, Resource>? duplicate = incomingResources
-            .GroupBy(r => r.Name, StringComparer.OrdinalIgnoreCase)
-            .FirstOrDefault(g => g.Count() > 1);
+        IReadOnlyList<Resource> storedResources = await repo.GetAllOfTypeAsync<Resource>();
+        List<Resource> currentResources = ResourceCollectionMerger.DeepCloneResources(storedResources);
 
-        if (duplicate != null)
-            throw new ValidationException($"Duplicate resource name: {duplicate.Key}");
-
-        var currentDict = currentResources
-            .ToDictionary(r => r.Name, StringComparer.OrdinalIgnoreCase);
+        IReadOnlyList<Connection> storedConnections = await repo.GetConnectionsAsync();
+        var currentConnections = storedConnections.Select(CloneConnection).ToList();
 
         ISerializer serializerDiff = new SerializerBuilder()
             .WithNamingConvention(CamelCaseNamingConvention.Instance)
@@ -79,12 +81,33 @@ public class UpsertInventoryUseCase(
                 DefaultValuesHandling.OmitEmptyCollections)
             .Build();
 
-        var oldSnapshots = currentResources
+        var oldSnapshots = storedResources
             .ToDictionary(
                 r => r.Name,
                 r => serializerDiff.Serialize(r),
                 StringComparer.OrdinalIgnoreCase);
 
+        // 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.
+        // Reconcile a snapshot so validation and dry-run previews cannot mutate the
+        // repository's live objects. The real merge repeats this under its file lock.
+        Dictionary<string, string> storedRenames = DiscoveryIdResolver.ResolveNames(
+            currentResources,
+            incomingResources,
+            incomingRoot.Connections,
+            currentConnections,
+            true);
+
+        IGrouping<string, Resource>? duplicate = incomingResources
+            .GroupBy(r => r.Name, StringComparer.OrdinalIgnoreCase)
+            .FirstOrDefault(g => g.Count() > 1);
+
+        if (duplicate != null)
+            throw new ValidationException($"Duplicate resource name: {duplicate.Key}");
+
+        var currentDict = currentResources
+            .ToDictionary(r => r.Name, StringComparer.OrdinalIgnoreCase);
+
         List<Resource> mergedResources = ResourceCollectionMerger.Merge(
             currentResources,
             incomingResources,
@@ -94,6 +117,19 @@ public class UpsertInventoryUseCase(
             .ToDictionary(r => r.Name, StringComparer.OrdinalIgnoreCase);
 
         var response = new ImportYamlResponse();
+        response.Renamed = storedRenames;
+
+        foreach ((var oldName, var newName) in storedRenames) {
+            if (!oldSnapshots.TryGetValue(oldName, out var oldYaml)
+                || !mergedDict.TryGetValue(newName, out Resource? renamed))
+                continue;
+
+            response.OldYaml[newName] = oldYaml;
+            response.NewYaml[newName] = serializerDiff.Serialize(renamed);
+            if (request.Mode != MergeMode.Replace
+                || !incomingResources.Any(r => r.Name.Equals(newName, StringComparison.OrdinalIgnoreCase)))
+                response.Updated.Add(newName);
+        }
 
         foreach (Resource incoming in incomingResources) {
             if (!mergedDict.TryGetValue(incoming.Name, out Resource? merged))
@@ -107,7 +143,17 @@ public class UpsertInventoryUseCase(
                 continue;
             }
 
-            var oldYaml = oldSnapshots[incoming.Name];
+            var oldName = incoming.Name;
+            foreach ((var storedName, var renamedName) in storedRenames) {
+                if (renamedName.Equals(incoming.Name, StringComparison.OrdinalIgnoreCase)) {
+                    oldName = storedName;
+                    break;
+                }
+            }
+
+            if (!oldSnapshots.TryGetValue(oldName, out var oldYaml))
+                continue;
+
             response.OldYaml[incoming.Name] = oldYaml;
 
             Resource existing = currentDict[incoming.Name];
@@ -115,29 +161,43 @@ public class UpsertInventoryUseCase(
             if (request.Mode == MergeMode.Replace ||
                 existing.GetType() != incoming.GetType())
                 response.Replaced.Add(incoming.Name);
-            else if (oldYaml != newYaml) response.Updated.Add(incoming.Name);
+            else if (oldYaml != newYaml && !response.Updated.Contains(incoming.Name, StringComparer.OrdinalIgnoreCase))
+                response.Updated.Add(incoming.Name);
         }
 
-        IReadOnlyList<Connection> currentConnections = await repo.GetConnectionsAsync();
         List<Connection>? mergedConnections = ConnectionMerger.Merge(
             currentConnections,
             incomingRoot.Connections,
             request.Mode);
 
-        if (mergedConnections != null) {
-            response.ConnectionsAdded = mergedConnections
-                .Select(ConnectionMerger.Describe)
-                .Except(currentConnections.Select(ConnectionMerger.Describe))
-                .ToList();
+        IReadOnlyList<Connection> finalConnections = mergedConnections ?? currentConnections;
+        response.ConnectionsAdded = finalConnections
+            .Select(ConnectionMerger.Describe)
+            .Except(storedConnections.Select(ConnectionMerger.Describe))
+            .ToList();
 
-            response.ConnectionsRemoved = currentConnections
-                .Select(ConnectionMerger.Describe)
-                .Except(mergedConnections.Select(ConnectionMerger.Describe))
-                .ToList();
-        }
+        response.ConnectionsRemoved = storedConnections
+            .Select(ConnectionMerger.Describe)
+            .Except(finalConnections.Select(ConnectionMerger.Describe))
+            .ToList();
 
         if (!request.DryRun) await repo.Merge(yamlInput, request.Mode);
 
         return response;
     }
+
+    private static Connection CloneConnection(Connection connection) => new() {
+        A = new PortReference {
+            Resource = connection.A.Resource,
+            PortGroup = connection.A.PortGroup,
+            PortIndex = connection.A.PortIndex
+        },
+        B = new PortReference {
+            Resource = connection.B.Resource,
+            PortGroup = connection.B.PortGroup,
+            PortIndex = connection.B.PortIndex
+        },
+        Label = connection.Label,
+        Notes = connection.Notes
+    };
 }

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

@@ -0,0 +1,107 @@
+using System.Net;
+using System.Net.Sockets;
+
+namespace RackPeek.Domain.Discovery;
+
+/// <summary>
+///     Reads an ARP table into ip → MAC, from either format the probe can produce:
+///     Linux's <c>/proc/net/arp</c> or BSD/macOS <c>arp -an</c> output. MACs are
+///     normalised (lowercase, zero-padded octets) because macOS prints <c>1:0:5e:…</c>
+///     where Linux prints <c>01:00:5e:…</c> — and the MAC seeds the discovery id, so
+///     the same machine must hash the same from every workstation. Pure; never throws.
+/// </summary>
+public static class ArpTableParser {
+    public static IReadOnlyDictionary<string, string> Parse(string? text) {
+        var result = new Dictionary<string, string>(StringComparer.Ordinal);
+
+        if (string.IsNullOrWhiteSpace(text))
+            return result;
+
+        foreach (var line in text.Split('\n')) {
+            (string Ip, string Mac)? entry = ParseLine(line.Trim());
+
+            if (entry != null)
+                result.TryAdd(entry.Value.Ip, entry.Value.Mac);
+        }
+
+        return result;
+    }
+
+    private static (string Ip, string Mac)? ParseLine(string line) {
+        if (line.Length == 0)
+            return null;
+
+        // BSD/macOS: "? (192.168.1.1) at a4:91:b1:4e:3c:20 on en0 ifscope [ethernet]"
+        var open = line.IndexOf('(');
+        var close = line.IndexOf(')');
+
+        if (open >= 0 && close > open) {
+            var ip = line[(open + 1)..close];
+            var at = line.IndexOf(" at ", close, StringComparison.Ordinal);
+
+            if (at < 0 || !IsIpv4(ip))
+                return null;
+
+            var rest = line[(at + 4)..];
+            var end = rest.IndexOf(' ');
+            var mac = NormaliseMac(end > 0 ? rest[..end] : rest);
+
+            return mac == null ? null : (ip, mac);
+        }
+
+        var columns = line.Split(' ', '\t', StringSplitOptions.RemoveEmptyEntries);
+
+        if (columns.Length < 2 || !IsIpv4(columns[0]))
+            return null;
+
+        // Linux /proc/net/arp: "192.168.1.1  0x1  0x2  a4:91:b1:4e:3c:20  *  eth0"
+        if (columns.Length >= 4 && columns[1].StartsWith("0x", StringComparison.Ordinal)) {
+            // Flags 0x0 marks an entry the kernel gave up resolving.
+            if (columns[2] == "0x0")
+                return null;
+
+            var linuxMac = NormaliseMac(columns[3]);
+
+            return linuxMac == null ? null : (columns[0], linuxMac);
+        }
+
+        // Windows arp -a: "192.168.1.1           a4-91-b1-4e-3c-20     dynamic"
+        var windowsMac = NormaliseMac(columns[1]);
+
+        return windowsMac == null ? null : (columns[0], windowsMac);
+    }
+
+    /// <summary>
+    ///     Lowercase, colon-separated, zero-padded — or null for anything that is not a
+    ///     usable MAC. Accepts Windows' dash separators so the same machine hashes the
+    ///     same from every platform's ARP output.
+    /// </summary>
+    public static string? NormaliseMac(string? raw) {
+        if (string.IsNullOrWhiteSpace(raw))
+            return null;
+
+        var parts = raw.Trim().Split(':', '-');
+
+        if (parts.Length != 6)
+            return null;
+
+        var octets = new string[6];
+
+        for (var i = 0; i < 6; i++) {
+            var part = parts[i];
+
+            if (part.Length is 0 or > 2 || !part.All(Uri.IsHexDigit))
+                return null;
+
+            octets[i] = part.Length == 1 ? "0" + char.ToLowerInvariant(part[0]) : part.ToLowerInvariant();
+        }
+
+        var mac = string.Join(':', octets);
+
+        // All-zero means the neighbour never answered — no identity there.
+        return mac == "00:00:00:00:00:00" ? null : mac;
+    }
+
+    private static bool IsIpv4(string value) =>
+        IPAddress.TryParse(value, out IPAddress? ip) && ip.AddressFamily == AddressFamily.InterNetwork;
+}

+ 15 - 0
RackPeek.Domain/Discovery/DiscoveryDocument.cs

@@ -0,0 +1,15 @@
+using RackPeek.Domain.Persistence.Yaml;
+using RackPeek.Domain.Resources;
+
+namespace RackPeek.Domain.Discovery;
+
+/// <summary>Renders discovered resources as a RackPeek YAML document.</summary>
+public static class DiscoveryDocument {
+    public static string ToYaml(IEnumerable<Resource> resources) {
+        return YamlResourceCollection.SerializeRootAsync(new YamlRoot {
+            Version = RackPeekConfigMigrationDeserializer.ListOfMigrations.Count,
+            Resources = resources.ToList(),
+            Connections = []
+        });
+    }
+}

+ 49 - 0
RackPeek.Domain/Discovery/DiscoveryId.cs

@@ -0,0 +1,49 @@
+using System.Security.Cryptography;
+using System.Text;
+
+namespace RackPeek.Domain.Discovery;
+
+/// <summary>
+///     Deterministic, globally unique identity for a discovered resource.
+///     Format: <c>rpk1:{scheme}:{16 hex chars}</c>
+///     The hash keeps low-value identifiers (machine-id, MAC) out of a config file
+///     that is frequently committed to a public repository. Note this is
+///     obfuscation rather than secrecy: the salt is public, so a low entropy seed
+///     such as a MAC address remains recoverable by brute force.
+/// </summary>
+public static class DiscoveryId {
+    public const string Prefix = "rpk1";
+    public const string SystemScheme = "sys";
+    public const string DockerScheme = "docker";
+    public const string NetworkScheme = "net";
+
+    public static string Create(string scheme, string seed) {
+        if (string.IsNullOrWhiteSpace(scheme))
+            throw new ArgumentException("Scheme is required.", nameof(scheme));
+
+        if (string.IsNullOrWhiteSpace(seed))
+            throw new ArgumentException("Seed is required.", nameof(seed));
+
+        var hash = SHA256.HashData(Encoding.UTF8.GetBytes($"{Prefix}:{scheme}:{seed}"));
+
+        return $"{Prefix}:{scheme}:{Convert.ToHexString(hash, 0, 8).ToLowerInvariant()}";
+    }
+
+    /// <summary>The scheme segment of an id — "sys" for rpk1:sys:… — or null for anything malformed.</summary>
+    public static string? Scheme(string? discoveryId) {
+        if (string.IsNullOrWhiteSpace(discoveryId))
+            return null;
+
+        var parts = discoveryId.Split(':');
+
+        return parts.Length == 3 ? parts[1] : null;
+    }
+
+    /// <summary>Short, stable fragment used to disambiguate generated names.</summary>
+    public static string ShortSuffix(string discoveryId) {
+        var lastColon = discoveryId.LastIndexOf(':');
+        var hash = lastColon >= 0 ? discoveryId[(lastColon + 1)..] : discoveryId;
+
+        return hash.Length <= 8 ? hash : hash[..8];
+    }
+}

+ 594 - 0
RackPeek.Domain/Discovery/DiscoveryIdResolver.cs

@@ -0,0 +1,594 @@
+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;
+
+/// <summary>
+///     Reconciles incoming discovered resources against what is already stored, by
+///     <see cref="Resource.DiscoveryId" /> rather than by name.
+///     <para>
+///         It rewrites incoming names to match stored resources. When allowed, it can
+///         also improve generated placeholder names in the existing inventory. Callers
+///         that allow stored-name improvement must pass a detached snapshot and publish
+///         it only after validation, so failed previews cannot mutate the store.
+///     </para>
+/// </summary>
+public static class DiscoveryIdResolver {
+    /// <summary>
+    ///     Rewrites <paramref name="incoming" /> in place so that its names line up with
+    ///     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 working on a detached snapshot 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 Dictionary<string, string> ResolveNames(
+        IReadOnlyList<Resource> existing,
+        IReadOnlyList<Resource> incoming,
+        IReadOnlyList<Connection>? connections = null,
+        IReadOnlyList<Connection>? storedConnections = null,
+        bool improveStoredNames = false) {
+        var incomingWithId = incoming
+            .Where(r => !string.IsNullOrWhiteSpace(r.DiscoveryId))
+            .ToList();
+
+        if (incomingWithId.Count == 0)
+            return new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
+
+        GuardAgainstDuplicates(incomingWithId, "payload");
+        GuardAgainstDuplicates(existing.Where(r => !string.IsNullOrWhiteSpace(r.DiscoveryId)), "inventory");
+
+        var existingById = existing
+            .Where(r => !string.IsNullOrWhiteSpace(r.DiscoveryId))
+            .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.
+        var existingByName = new Dictionary<string, Resource>(StringComparer.OrdinalIgnoreCase);
+
+        foreach (Resource resource in existing)
+            existingByName.TryAdd(resource.Name, resource);
+
+        var renames = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
+
+        var storedRenames = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
+
+        foreach (Resource resource in incomingWithId) {
+            // 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;
+
+                continue;
+            }
+
+            renames[offeredName] = resolved;
+            resource.Name = resolved;
+        }
+
+        if (renames.Count > 0) {
+            RewriteRunsOn(incoming, renames);
+            RewriteConnections(connections, renames);
+        }
+
+        // The stored side has its own references to fix up, and its own connections. The
+        // incoming side gets the same treatment because a payload may well be a re-push of
+        // previously exported YAML, which still names the resource the way it was stored.
+        if (storedRenames.Count > 0) {
+            RewriteRunsOn(existing, storedRenames);
+
+            // Now that the hosts answer to their new names, the services named after the
+            // old ones follow. Done here so the renames below travel together.
+            foreach ((var from, var to) in RenameServicesAfterTheirHost(existing, storedRenames, existingByName))
+                storedRenames[from] = to;
+
+            RewriteConnections(storedConnections, storedRenames);
+            RewriteRunsOn(incoming, storedRenames);
+            RewriteConnections(connections, storedRenames);
+        }
+
+        PreserveStoredRunsOn(incomingWithId, incoming, existingById, existingByName);
+        AnchorRunsOnByIp(existing, incoming, existingByName);
+
+        return storedRenames;
+    }
+
+    /// <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>
+    ///     A collector that cannot see its host — docker discovery over TCP — sends
+    ///     <c>runsOn</c> as a bare hostname it cannot reconcile after the user renames
+    ///     that host. When an update's runsOn points at nothing at all while the stored
+    ///     resource already points at something real, the stored link is the user's truth
+    ///     and re-discovery must not tear it up. A runsOn that resolves — even to a
+    ///     resource arriving in the same payload — is left alone: that is a genuine move.
+    /// </summary>
+    private static void PreserveStoredRunsOn(
+        IReadOnlyList<Resource> incomingWithId,
+        IReadOnlyList<Resource> incoming,
+        Dictionary<string, Resource> existingById,
+        Dictionary<string, Resource> existingByName) {
+        var incomingNames = new HashSet<string>(incoming.Select(r => r.Name), StringComparer.OrdinalIgnoreCase);
+
+        foreach (Resource resource in incomingWithId) {
+            // MAC unification may have nulled a scan card's id so the merge cannot
+            // downgrade the stored identity — such a card has nothing to look up here.
+            if (resource.RunsOn.Count == 0
+                || string.IsNullOrWhiteSpace(resource.DiscoveryId)
+                || !existingById.TryGetValue(resource.DiscoveryId, out Resource? stored)
+                || stored.RunsOn.Count == 0)
+                continue;
+
+            var anchored = resource.RunsOn.Any(name =>
+                existingByName.ContainsKey(name) || incomingNames.Contains(name));
+
+            if (!anchored)
+                resource.RunsOn = [.. stored.RunsOn];
+        }
+    }
+
+    private static string ResolveName(
+        Resource resource,
+        Dictionary<string, Resource> existingById,
+        Dictionary<string, Resource> existingByName,
+        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;
+
+        // Unknown id, but a MAC in common with exactly one stored card: the same
+        // physical machine seen by two collectors, unified onto the stored card.
+        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;
+
+        // Taken by something carrying a different id — another machine's resource.
+        var belongsToAnotherMachine = !string.IsNullOrWhiteSpace(sameName.DiscoveryId)
+                                      && !sameName.DiscoveryId.Equals(
+                                          resource.DiscoveryId,
+                                          StringComparison.OrdinalIgnoreCase);
+
+        // Taken by a different kind of thing. Very common: the box is documented as a
+        // Server by hand and discovery reports the operating system on it as a System.
+        // The merge replaces on a type change, so adopting here would delete the
+        // hardware the user wrote.
+        var describesSomethingElse = sameName.GetType() != resource.GetType();
+
+        if (belongsToAnotherMachine || describesSomethingElse)
+            return DiscoveryNaming.WithSuffix(
+                resource.Name,
+                DiscoveryId.ShortSuffix(resource.DiscoveryId!));
+
+        // Same kind, no competing id: this is the adoption case, where the merge stamps
+        // the id onto the resource the user already wrote and keeps everything in it.
+        return resource.Name;
+    }
+
+    /// <summary>
+    ///     The bridge between collectors that cannot derive each other's ids: the agent
+    ///     records the machine's MACs (a "macs" label), the scan identifies it by one (a
+    ///     "mac" label). A shared MAC on a stored card of the same kind means the same
+    ///     box — the incoming card adopts the stored card's name so the merge lands on
+    ///     it, and the stronger identity wins: an agent id replaces a scan id, a scan id
+    ///     never replaces anything (it is nulled here so the merge cannot downgrade).
+    ///     Ids from two agent-grade collectors sharing a MAC (cloned VMs, or Proxmox's
+    ///     view of a guest) are never unified — that is what machine-ids are for.
+    /// </summary>
+    private static bool TryUnifyByMac(
+        Resource resource,
+        Dictionary<string, Resource> existingByMac,
+        out string unifiedName) {
+        unifiedName = string.Empty;
+
+        foreach (var mac in MacsOf(resource)) {
+            if (!existingByMac.TryGetValue(mac, out Resource? stored))
+                continue;
+
+            // The box the user documented as a Server and the OS a scan saw on it are
+            // different cards on purpose; unification is for same-kind cards only.
+            if (stored.GetType() != resource.GetType())
+                continue;
+
+            var incomingIsNet = DiscoveryId.Scheme(resource.DiscoveryId) == DiscoveryId.NetworkScheme;
+
+            // A stored card with a MAC but no id yet: adoption, same as the name-based
+            // adoption case — the incoming id gets stamped onto it by the merge.
+            if (string.IsNullOrWhiteSpace(stored.DiscoveryId)) {
+                unifiedName = stored.Name;
+
+                return true;
+            }
+
+            var storedIsNet = DiscoveryId.Scheme(stored.DiscoveryId) == DiscoveryId.NetworkScheme;
+
+            // Both scan-grade or both agent-grade: not safe to unify on a MAC alone.
+            if (incomingIsNet == storedIsNet)
+                continue;
+
+            if (incomingIsNet)
+                resource.DiscoveryId = null;
+
+            unifiedName = stored.Name;
+
+            return true;
+        }
+
+        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 = [
+            resource.Labels.GetValueOrDefault("mac"),
+            .. (resource.Labels.GetValueOrDefault("macs") ?? string.Empty).Split(
+                ',',
+                StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
+        ];
+
+        return raw
+            .Select(ArpTableParser.NormaliseMac)
+            .Where(mac => mac != null)
+            .Select(mac => mac!)
+            .Distinct();
+    }
+
+    /// <summary>
+    ///     mac → the one stored resource claiming it. A MAC claimed by two stored
+    ///     resources identifies nothing and is dropped: ambiguity never unifies.
+    /// </summary>
+    private static Dictionary<string, Resource> BuildMacMap(IReadOnlyList<Resource> existing) {
+        var map = new Dictionary<string, Resource>(StringComparer.OrdinalIgnoreCase);
+        var ambiguous = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
+
+        foreach (Resource resource in existing)
+            foreach (var mac in MacsOf(resource))
+                if (!ambiguous.Contains(mac) && !map.TryAdd(mac, resource) && !ReferenceEquals(map[mac], resource)) {
+                    map.Remove(mac);
+                    ambiguous.Add(mac);
+                }
+
+        return map;
+    }
+
+    private static void RewriteRunsOn(IReadOnlyList<Resource> incoming, Dictionary<string, string> renames) {
+        foreach (Resource resource in incoming)
+            for (var i = 0; i < resource.RunsOn.Count; i++)
+                if (renames.TryGetValue(resource.RunsOn[i], out var renamed))
+                    resource.RunsOn[i] = renamed;
+    }
+
+    private static void RewriteConnections(IReadOnlyList<Connection>? connections, Dictionary<string, string> renames) {
+        if (connections == null)
+            return;
+
+        foreach (Connection connection in connections) {
+            if (connection.A?.Resource != null && renames.TryGetValue(connection.A.Resource, out var a))
+                connection.A.Resource = a;
+
+            if (connection.B?.Resource != null && renames.TryGetValue(connection.B.Resource, out var b))
+                connection.B.Resource = b;
+        }
+    }
+
+    private static void GuardAgainstDuplicates(IEnumerable<Resource> resources, string scope) {
+        IGrouping<string, Resource>? duplicate = resources
+            .GroupBy(r => r.DiscoveryId!, StringComparer.OrdinalIgnoreCase)
+            .FirstOrDefault(g => g.Count() > 1);
+
+        if (duplicate == null)
+            return;
+
+        var names = string.Join(", ", duplicate.Select(r => r.Name));
+
+        throw new ValidationException(
+            $"Duplicate discoveryId '{duplicate.Key}' in the {scope} ({names}). " +
+            "Machines cloned from a VM template often share /etc/machine-id; " +
+            "run 'systemd-machine-id-setup' on the clones to give them distinct identities.");
+    }
+}

+ 90 - 0
RackPeek.Domain/Discovery/DiscoveryNaming.cs

@@ -0,0 +1,90 @@
+using System.Text;
+using RackPeek.Domain.Helpers;
+
+namespace RackPeek.Domain.Discovery;
+
+/// <summary>Turns machine-supplied strings into names a human would have typed.</summary>
+public static class DiscoveryNaming {
+    /// <summary>
+    ///     The limit RackPeek's own validation enforces. The import API does not check
+    ///     it, so a longer name would be accepted and then be un-editable from the CLI.
+    /// </summary>
+    public const int MaxNameLength = ThrowIfInvalid.MaxResourceNameLength;
+
+    /// <summary>
+    ///     Lowercase, alphanumeric and dashes only. Returns an empty string when the
+    ///     input contains nothing usable, so callers can fall back to an id-derived name.
+    /// </summary>
+    public static string Slug(string? value) {
+        if (string.IsNullOrWhiteSpace(value))
+            return string.Empty;
+
+        var builder = new StringBuilder(value.Length);
+
+        foreach (var c in value.Trim().ToLowerInvariant())
+            if (char.IsAsciiLetterOrDigit(c))
+                builder.Append(c);
+            else if ((c == '-' || c == '.' || c == '_' || char.IsWhiteSpace(c)) && builder.Length > 0 &&
+                     builder[^1] != '-')
+                builder.Append('-');
+
+        return builder.ToString().Trim('-');
+    }
+
+    /// <summary>
+    ///     The first label of a host name: <c>nas01.lan</c> becomes <c>nas01</c>, which is
+    ///     what people call the machine.
+    /// </summary>
+    public static string HostLabel(string? hostname) {
+        if (string.IsNullOrWhiteSpace(hostname))
+            return string.Empty;
+
+        var dot = hostname.IndexOf('.');
+
+        return dot > 0 ? hostname[..dot] : hostname;
+    }
+
+    /// <summary>
+    ///     The name a collector proposes: the machine's own name where it has a usable
+    ///     one, otherwise derived from the id so it is still deterministic and unique.
+    /// </summary>
+    public static string Suggest(string? preferred, string kind, string discoveryId) {
+        var slug = Slug(preferred);
+
+        return slug.Length > 0
+            ? Truncate(slug)
+            : $"{Slug(kind)}-{DiscoveryId.ShortSuffix(discoveryId)}";
+    }
+
+    /// <summary>Cuts a name down to the allowed length without leaving a trailing dash.</summary>
+    public static string Truncate(string name) =>
+        name.Length <= MaxNameLength ? name : name[..MaxNameLength].TrimEnd('-');
+
+    /// <summary>
+    ///     Appends a disambiguating suffix, shortening the name to make room. Compose
+    ///     puts the replica index at the end of a container name, so two long names
+    ///     often differ only in the part truncation removes — the suffix is what keeps
+    ///     them apart.
+    /// </summary>
+    public static string WithSuffix(string name, string suffix) {
+        var room = MaxNameLength - suffix.Length - 1;
+        var head = name.Length <= room ? name : name[..Math.Max(1, room)];
+
+        return $"{head.TrimEnd('-')}-{suffix}";
+    }
+
+    /// <summary>
+    ///     Returns a name not already in <paramref name="taken" />, adding it to the set.
+    ///     Guards the case where two resources in one payload truncate to the same name,
+    ///     which the import rejects outright as a duplicate.
+    /// </summary>
+    public static string Unique(string candidate, string discoveryId, ISet<string> taken) {
+        var name = taken.Add(candidate)
+            ? candidate
+            : WithSuffix(candidate, DiscoveryId.ShortSuffix(discoveryId));
+
+        taken.Add(name);
+
+        return name;
+    }
+}

+ 95 - 0
RackPeek.Domain/Discovery/DiscoveryPublisher.cs

@@ -0,0 +1,95 @@
+using System.Net.Http.Headers;
+using System.Text;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using RackPeek.Domain.Api;
+using RackPeek.Domain.Persistence;
+
+namespace RackPeek.Domain.Discovery;
+
+/// <summary>
+///     Sends a discovery document to a running RackPeek server's inventory API.
+///     Always merges — discovery adds and updates what it finds, and must never be able
+///     to remove what it did not.
+/// </summary>
+public sealed class DiscoveryPublisher : IDisposable {
+    public const string ServerEnvironmentVariable = "RPK_SERVER";
+    public const string ApiKeyEnvironmentVariable = "RPK_API_KEY";
+
+    private static readonly JsonSerializerOptions _jsonOptions = new() {
+        PropertyNameCaseInsensitive = true,
+        DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
+    };
+
+    private readonly HttpClient _httpClient;
+
+    public DiscoveryPublisher(string serverUrl, string apiKey, HttpClient? httpClient = null) {
+        _httpClient = httpClient ?? new HttpClient();
+        _httpClient.BaseAddress = new Uri(serverUrl.TrimEnd('/') + "/");
+        _httpClient.DefaultRequestHeaders.Remove("X-Api-Key");
+        _httpClient.DefaultRequestHeaders.Add("X-Api-Key", apiKey);
+    }
+
+    public void Dispose() => _httpClient.Dispose();
+
+    public static string? ResolveServer(string? explicitValue) =>
+        Resolve(explicitValue, ServerEnvironmentVariable);
+
+    public static string? ResolveApiKey(string? explicitValue) =>
+        Resolve(explicitValue, ApiKeyEnvironmentVariable);
+
+    /// <summary>An explicit value wins; a blank one falls back to the environment.</summary>
+    public static string? Resolve(string? explicitValue, string environmentVariable) =>
+        Coalesce(explicitValue, Environment.GetEnvironmentVariable(environmentVariable));
+
+    public async Task<ImportYamlResponse> PublishAsync(
+        string yaml,
+        bool dryRun,
+        CancellationToken cancellationToken = default) {
+        var payload = JsonSerializer.Serialize(
+            new ImportYamlRequest { Yaml = yaml, Mode = MergeMode.Merge, DryRun = dryRun },
+            _jsonOptions);
+
+        using var content = new StringContent(payload, Encoding.UTF8);
+        content.Headers.ContentType = new MediaTypeHeaderValue("application/json");
+
+        using HttpResponseMessage response =
+            await _httpClient.PostAsync("api/inventory", content, cancellationToken);
+
+        var body = await response.Content.ReadAsStringAsync(cancellationToken);
+
+        if (!response.IsSuccessStatusCode)
+            throw new InvalidOperationException(Describe(response.StatusCode, body));
+
+        return JsonSerializer.Deserialize<ImportYamlResponse>(body, _jsonOptions)
+               ?? new ImportYamlResponse();
+    }
+
+    private static string Describe(System.Net.HttpStatusCode statusCode, string body) {
+        var detail = ExtractError(body);
+
+        return statusCode switch {
+            System.Net.HttpStatusCode.Unauthorized =>
+                "Rejected by the server (401). Check the API key matches RPK_API_KEY on the server.",
+            System.Net.HttpStatusCode.ServiceUnavailable =>
+                "The server has no API key configured (503). Set RPK_API_KEY on the RackPeek server.",
+            _ => $"Upload failed ({(int)statusCode}). {detail}".TrimEnd()
+        };
+    }
+
+    private static string ExtractError(string body) {
+        try {
+            using var document = JsonDocument.Parse(body);
+
+            return document.RootElement.TryGetProperty("error", out JsonElement error)
+                ? error.GetString() ?? string.Empty
+                : body;
+        }
+        catch {
+            return body;
+        }
+    }
+
+    private static string? Coalesce(params string?[] values) =>
+        values.FirstOrDefault(v => !string.IsNullOrWhiteSpace(v));
+}

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

@@ -0,0 +1,146 @@
+using System.Net.Sockets;
+
+namespace RackPeek.Domain.Discovery;
+
+/// <summary>
+///     Talks to the Docker Engine API over either a unix socket or TCP. Podman's socket
+///     speaks the same API, so <c>--docker-host unix:///run/user/1000/podman/podman.sock</c>
+///     works without anything extra.
+/// </summary>
+public sealed class DockerApiClient : IDockerClient, IDisposable {
+    public const string DefaultSocketPath = "/var/run/docker.sock";
+
+    private readonly HttpClient _httpClient;
+
+    public DockerApiClient(string? dockerHost = null) {
+        var host = string.IsNullOrWhiteSpace(dockerHost)
+            ? Environment.GetEnvironmentVariable("DOCKER_HOST")
+            : dockerHost;
+
+        (_httpClient, Endpoint) = Create(host);
+        _httpClient.Timeout = TimeSpan.FromSeconds(30);
+    }
+
+    public string Endpoint { get; }
+
+    /// <summary>
+    ///     Whether the endpoint is a socket on this machine. Over TCP the engine is some
+    ///     other machine, so facts probed locally must not be attributed to it.
+    /// </summary>
+    public bool IsLocal => Endpoint.StartsWith("unix://", StringComparison.OrdinalIgnoreCase);
+
+    /// <summary>
+    ///     The host part of a TCP endpoint — where the containers actually live — or null
+    ///     for a local socket. A name or an address, exactly as the user dialled it.
+    /// </summary>
+    public string? RemoteHost => IsLocal
+        ? null
+        : new Uri(Endpoint.Replace("tcp://", "http://", StringComparison.OrdinalIgnoreCase)).DnsSafeHost;
+
+    public void Dispose() => _httpClient.Dispose();
+
+    public async Task<IReadOnlyList<DockerContainer>> ListContainersAsync(
+        CancellationToken cancellationToken = default) {
+        using HttpResponseMessage response =
+            await _httpClient.GetAsync("/containers/json", cancellationToken);
+        response.EnsureSuccessStatusCode();
+
+        var json = await response.Content.ReadAsStringAsync(cancellationToken);
+
+        return DockerContainerParser.Parse(json);
+    }
+
+    public async Task<DockerEngineInfo?> GetInfoAsync(CancellationToken cancellationToken = default) {
+        try {
+            using HttpResponseMessage response = await _httpClient.GetAsync("/info", cancellationToken);
+
+            if (!response.IsSuccessStatusCode)
+                return null;
+
+            return DockerEngineInfoParser.Parse(await response.Content.ReadAsStringAsync(cancellationToken));
+        }
+        catch (Exception ex) when (
+            ex is HttpRequestException or IOException or TimeoutException
+            || (ex is TaskCanceledException && !cancellationToken.IsCancellationRequested)) {
+            // /info being unreadable never fails discovery; the caller degrades instead.
+            return null;
+        }
+    }
+
+    /// <summary>
+    ///     The address the user dialled, as IPv4: taken verbatim when it is a literal,
+    ///     resolved once when it is a name. The inventory schema holds IPv4 only, so an
+    ///     IPv6-only endpoint yields null and the caller falls back.
+    /// </summary>
+    public static async Task<string?> ResolveIpv4Async(string host, CancellationToken cancellationToken = default) {
+        if (System.Net.IPAddress.TryParse(host, out System.Net.IPAddress? literal))
+            return literal.AddressFamily == AddressFamily.InterNetwork ? host : null;
+
+        try {
+            System.Net.IPAddress[] addresses = await System.Net.Dns.GetHostAddressesAsync(host, cancellationToken);
+
+            return addresses.FirstOrDefault(a => a.AddressFamily == AddressFamily.InterNetwork)?.ToString();
+        }
+        catch (Exception ex) when (ex is SocketException or ArgumentException or PlatformNotSupportedException) {
+            // Unresolvable, malformed, or no DNS on this platform (the browser console):
+            // the address is a nicety, never worth failing discovery over.
+            return null;
+        }
+    }
+
+    private static (HttpClient Client, string Endpoint) Create(string? dockerHost) {
+        if (string.IsNullOrWhiteSpace(dockerHost))
+            return (UnixSocketClient(DefaultSocketPath), $"unix://{DefaultSocketPath}");
+
+        if (dockerHost.StartsWith("unix://", StringComparison.OrdinalIgnoreCase)) {
+            var path = dockerHost["unix://".Length..];
+
+            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) => {
+                var socket = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified);
+
+                try {
+                    await socket.ConnectAsync(new UnixDomainSocketEndPoint(socketPath), cancellationToken);
+
+                    return new NetworkStream(socket, true);
+                }
+                catch {
+                    socket.Dispose();
+                    throw;
+                }
+            }
+        };
+
+        // The host part is ignored for a unix socket but HttpClient still requires one.
+        return new HttpClient(handler) { BaseAddress = new Uri("http://localhost") };
+    }
+}

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

@@ -0,0 +1,107 @@
+using System.Text.Json;
+using System.Net;
+
+namespace RackPeek.Domain.Discovery;
+
+/// <summary>A published port binding on the host side.</summary>
+public sealed record DockerPortBinding(int HostPort, string Protocol, string HostIp = "") {
+    /// <summary>
+    ///     Bound to a loopback address, so only the host itself can reach it. Docker
+    ///     writes the address as given (<c>127.0.0.1</c> mostly, but any 127.x works).
+    /// </summary>
+    public bool IsLoopback => IPAddress.TryParse(HostIp, out IPAddress? address) && IPAddress.IsLoopback(address);
+
+    /// <summary>Bound to every interface rather than one address.</summary>
+    public bool IsWildcard {
+        get {
+            if (string.IsNullOrWhiteSpace(HostIp))
+                return true;
+
+            return IPAddress.TryParse(HostIp, out IPAddress? address)
+                   && (address.Equals(IPAddress.Any) || address.Equals(IPAddress.IPv6Any));
+        }
+    }
+}
+
+/// <summary>
+///     A container, reduced to the parts RackPeek models. Everything here comes from a
+///     single <c>GET /containers/json</c> call — the per-container inspect adds nothing
+///     a Service resource can hold.
+/// </summary>
+public sealed record DockerContainer {
+    public required string Name { get; init; }
+    public required string Image { get; init; }
+    public string? ComposeProject { get; init; }
+    public IReadOnlyList<DockerPortBinding> PublishedPorts { get; init; } = [];
+}
+
+/// <summary>Parses the Docker Engine list response. Pure, so it tests from a fixture.</summary>
+public static class DockerContainerParser {
+    private const string _composeProjectLabel = "com.docker.compose.project";
+
+    public static List<DockerContainer> Parse(string json) {
+        using var document = JsonDocument.Parse(json);
+
+        if (document.RootElement.ValueKind != JsonValueKind.Array)
+            return [];
+
+        return document.RootElement.EnumerateArray()
+            .Select(ParseContainer)
+            .OfType<DockerContainer>()
+            .OrderBy(c => c.Name, StringComparer.Ordinal)
+            .ToList();
+    }
+
+    private static DockerContainer? ParseContainer(JsonElement element) {
+        var name = ParseName(element);
+
+        if (string.IsNullOrWhiteSpace(name))
+            return null;
+
+        return new DockerContainer {
+            Name = name,
+            Image = GetString(element, "Image") ?? "unknown",
+            ComposeProject = GetLabel(element, _composeProjectLabel),
+            PublishedPorts = ParsePorts(element)
+        };
+    }
+
+    private static string? ParseName(JsonElement element) {
+        if (!element.TryGetProperty("Names", out JsonElement names) || names.ValueKind != JsonValueKind.Array)
+            return GetString(element, "Name")?.TrimStart('/');
+
+        return names.EnumerateArray()
+            .Select(n => n.GetString()?.TrimStart('/'))
+            .FirstOrDefault(n => !string.IsNullOrWhiteSpace(n));
+    }
+
+    private static List<DockerPortBinding> ParsePorts(JsonElement element) {
+        if (!element.TryGetProperty("Ports", out JsonElement ports) || ports.ValueKind != JsonValueKind.Array)
+            return [];
+
+        // Dual-stack publishes show up twice, once per family ("0.0.0.0" and "::");
+        // folding wildcards to one spelling keeps that one binding, not two.
+        return ports.EnumerateArray()
+            .Where(p => p.TryGetProperty("PublicPort", out JsonElement port) && port.ValueKind == JsonValueKind.Number)
+            .Select(p => new DockerPortBinding(
+                p.GetProperty("PublicPort").GetInt32(),
+                (GetString(p, "Type") ?? "tcp").ToUpperInvariant(),
+                GetString(p, "IP") ?? ""))
+            .Select(p => p.IsWildcard ? p with { HostIp = "" } : p)
+            .DistinctBy(p => (p.HostPort, p.Protocol, p.HostIp))
+            .OrderBy(p => p.HostPort)
+            .ToList();
+    }
+
+    private static string? GetLabel(JsonElement element, string label) {
+        if (!element.TryGetProperty("Labels", out JsonElement labels) || labels.ValueKind != JsonValueKind.Object)
+            return null;
+
+        return labels.TryGetProperty(label, out JsonElement value) ? value.GetString() : null;
+    }
+
+    private static string? GetString(JsonElement element, string property) =>
+        element.TryGetProperty(property, out JsonElement value) && value.ValueKind == JsonValueKind.String
+            ? value.GetString()
+            : null;
+}

+ 48 - 0
RackPeek.Domain/Discovery/DockerEngineInfo.cs

@@ -0,0 +1,48 @@
+using System.Text.Json;
+
+namespace RackPeek.Domain.Discovery;
+
+/// <summary>
+///     The engine host's own account of itself, from <c>GET /info</c>. This is what makes
+///     a remote engine discoverable without user intervention: the daemon id gives the
+///     services an identity seed that does not depend on which machine ran the command,
+///     and the name is the hostname <c>rpk discover system</c> would report on that box.
+/// </summary>
+public sealed record DockerEngineInfo {
+    /// <summary>
+    ///     The daemon's persisted unique id (kept under <c>/var/lib/docker</c>). Survives
+    ///     reboots; changes only on an engine reinstall — the same failure mode
+    ///     <c>/etc/machine-id</c> has for local discovery.
+    /// </summary>
+    public string? Id { get; init; }
+
+    /// <summary>The engine host's hostname.</summary>
+    public string? Hostname { get; init; }
+}
+
+/// <summary>Parses the Docker Engine <c>GET /info</c> response. Pure, so it tests from a fixture.</summary>
+public static class DockerEngineInfoParser {
+    public static DockerEngineInfo? Parse(string json) {
+        try {
+            using var document = JsonDocument.Parse(json);
+
+            if (document.RootElement.ValueKind != JsonValueKind.Object)
+                return null;
+
+            return new DockerEngineInfo {
+                Id = GetString(document.RootElement, "ID"),
+                Hostname = GetString(document.RootElement, "Name")
+            };
+        }
+        catch (JsonException) {
+            return null;
+        }
+    }
+
+    private static string? GetString(JsonElement element, string property) =>
+        element.TryGetProperty(property, out JsonElement value)
+        && value.ValueKind == JsonValueKind.String
+        && !string.IsNullOrWhiteSpace(value.GetString())
+            ? value.GetString()
+            : null;
+}

+ 86 - 0
RackPeek.Domain/Discovery/DockerServiceMapper.cs

@@ -0,0 +1,86 @@
+using RackPeek.Domain.Resources.Services;
+
+namespace RackPeek.Domain.Discovery;
+
+/// <summary>Maps containers onto the Service resources RackPeek stores. Pure.</summary>
+public static class DockerServiceMapper {
+    /// <summary>
+    ///     Containers nothing outside the host can reach are skipped — no published port,
+    ///     or every binding on a loopback address. They are not services anyone would put
+    ///     on an inventory, and a Service is required to carry an address.
+    /// </summary>
+    /// <param name="hostSeed">
+    ///     Identity of the machine the containers run on — the host's machine-id where
+    ///     there is one. Part of the container's own id, so the same container name on
+    ///     two different hosts stays two different resources.
+    /// </param>
+    public static List<Service> ToResources(
+        IReadOnlyList<DockerContainer> containers,
+        string hostSeed,
+        string hostName,
+        string? hostIp) {
+        var taken = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
+
+        return containers
+            .Where(c => c.PublishedPorts.Any(p => !p.IsLoopback))
+            .Select(c => ToResource(c, hostSeed, hostName, hostIp, taken))
+            .ToList();
+    }
+
+    private static Service ToResource(
+        DockerContainer container,
+        string hostSeed,
+        string hostName,
+        string? hostIp,
+        ISet<string> taken) {
+        var discoveryId = DiscoveryId.Create(
+            DiscoveryId.DockerScheme,
+            $"{hostSeed}/{container.Name}");
+
+        // A wildcard binding is reachable on the host's own address; a binding pinned to
+        // one interface is only reachable there, so that address wins over the host's.
+        var ports = container.PublishedPorts
+            .Where(p => !p.IsLoopback)
+            .OrderBy(p => p.IsWildcard ? 0 : 1)
+            .ThenBy(p => p.HostPort)
+            .ThenBy(p => p.Protocol, StringComparer.Ordinal)
+            .ThenBy(p => p.HostIp, StringComparer.Ordinal)
+            .ToList();
+
+        DockerPortBinding port = ports[0];
+
+        return new Service {
+            Kind = Service.KindLabel,
+            Name = DiscoveryNaming.Unique(
+                DiscoveryNaming.Suggest(container.Name, "service", discoveryId),
+                discoveryId,
+                taken),
+            DiscoveryId = discoveryId,
+            Network = new Network {
+                Ip = ServiceIp(port, hostIp),
+                Port = port.HostPort,
+                Protocol = port.Protocol
+            },
+            Endpoints = ports.Select(p => new Network {
+                Ip = ServiceIp(p, hostIp),
+                Port = p.HostPort,
+                Protocol = p.Protocol
+            }).ToList(),
+            Notes = container.Image,
+            Tags = string.IsNullOrWhiteSpace(container.ComposeProject)
+                ? []
+                : [DiscoveryNaming.Slug(container.ComposeProject)],
+            RunsOn = [hostName]
+        };
+    }
+
+    /// <summary>
+    ///     The inventory schema holds IPv4 only, so a binding pinned to an IPv6 address
+    ///     falls back to the host's address — the right machine, if not the exact socket.
+    /// </summary>
+    private static string? ServiceIp(DockerPortBinding port, string? hostIp) =>
+        !port.IsWildcard && System.Net.IPAddress.TryParse(port.HostIp, out System.Net.IPAddress? ip)
+        && ip.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork
+            ? port.HostIp
+            : hostIp;
+}

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

@@ -0,0 +1,21 @@
+namespace RackPeek.Domain.Discovery;
+
+/// <summary>Reads containers off a Docker Engine API. The IO half of docker discovery.</summary>
+public interface IDockerClient {
+    /// <summary>Describes where this client is pointed, for error messages.</summary>
+    string Endpoint { get; }
+
+    /// <summary>
+    ///     Running containers only. A stopped container has no host port bindings — the
+    ///     daemon does not create them until it runs — so it can never be described as a
+    ///     Service, which makes listing stopped containers pointless here.
+    /// </summary>
+    Task<IReadOnlyList<DockerContainer>> ListContainersAsync(CancellationToken cancellationToken = default);
+
+    /// <summary>
+    ///     The engine's own identity, or null when it cannot be read. Null is an expected
+    ///     answer, not an error: the read-only socket proxies the docs recommend usually
+    ///     allow <c>/containers</c> but block <c>/info</c>.
+    /// </summary>
+    Task<DockerEngineInfo?> GetInfoAsync(CancellationToken cancellationToken = default);
+}

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

@@ -0,0 +1,61 @@
+using RackPeek.Domain.Resources.Services.Networking;
+
+namespace RackPeek.Domain.Discovery;
+
+/// <summary>
+///     Network IO for the sweep. The IO half of network discovery, mirroring
+///     <see cref="IDockerClient" /> / <see cref="IProxmoxClient" />: everything here is
+///     untestable-by-design plumbing, and every decision made about what comes back
+///     lives in <see cref="NetworkScanner" /> and the pure parsers.
+/// </summary>
+public interface INetworkProbe {
+    /// <summary>
+    ///     True when this platform can sweep at all. The browser (WASM viewer) cannot —
+    ///     its sockets are sandboxed — and without this guard a scan there would report
+    ///     an empty network instead of the truth. Mirrors <see cref="ISystemProbe.IsSupported" />.
+    /// </summary>
+    bool IsSupported { get; }
+
+    /// <summary>True when the host answers an ICMP echo within the timeout.</summary>
+    Task<bool> PingAsync(string ip, TimeSpan timeout, CancellationToken cancellationToken = default);
+
+    /// <summary>True when a TCP connect to the port completes within the timeout.</summary>
+    Task<bool> TryConnectAsync(string ip, int port, TimeSpan timeout, CancellationToken cancellationToken = default);
+
+    /// <summary>
+    ///     The host's ARP table, raw, in whichever format this platform produces — the
+    ///     sweep's pings populate it, and <see cref="ArpTableParser" /> reads either
+    ///     format. Null when it cannot be read; MACs are an enrichment, not a requirement.
+    /// </summary>
+    Task<string?> ReadArpAsync(CancellationToken cancellationToken = default);
+
+    /// <summary>The host's reverse-DNS name, or null when it has none worth keeping.</summary>
+    Task<string?> ReverseDnsAsync(string ip, 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.
+    /// </summary>
+    Cidr? LocalSubnet();
+}

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

@@ -0,0 +1,56 @@
+namespace RackPeek.Domain.Discovery;
+
+/// <summary>Reads a Proxmox VE API. The IO half of hypervisor discovery.</summary>
+public interface IProxmoxClient {
+    /// <summary>Where this client is pointed, for error messages.</summary>
+    string Endpoint { get; }
+
+    /// <summary>
+    ///     The scope a vmid is unique within — the cluster name where there is one, and
+    ///     the node otherwise. Part of every guest's identity, so a guest that migrates
+    ///     between nodes stays the same resource.
+    /// </summary>
+    Task<string> GetIdentityScopeAsync(CancellationToken cancellationToken = default);
+
+    Task<IReadOnlyList<ProxmoxNode>> GetNodesAsync(CancellationToken cancellationToken = default);
+
+    /// <summary>
+    ///     Adds what only the per-node status call knows — currently the Proxmox version.
+    ///     Returns the node unchanged if the token may not read it: a read-only token
+    ///     without Sys.Audit can still see the guests, and a partial answer beats none.
+    /// </summary>
+    Task<ProxmoxNode> EnrichAsync(ProxmoxNode node, CancellationToken cancellationToken = default);
+
+    /// <summary>Guests of one kind on one node. <paramref name="endpoint" /> is qemu or lxc.</summary>
+    Task<IReadOnlyList<ProxmoxGuest>> GetGuestsAsync(
+        string node,
+        string endpoint,
+        CancellationToken cancellationToken = default);
+
+    /// <summary>
+    ///     Physical disks on a node. Needs the same permission as the status call, so it
+    ///     is gathered as part of enrichment and simply absent without it.
+    /// </summary>
+    Task<IReadOnlyList<ProxmoxDisk>> GetDisksAsync(string node, CancellationToken cancellationToken = default);
+
+    /// <summary>Display adapters in a node, from its PCI device list.</summary>
+    Task<IReadOnlyList<ProxmoxGpu>> GetGpusAsync(string node, CancellationToken cancellationToken = default);
+
+    Task<ProxmoxGuestConfig> GetGuestConfigAsync(
+        string node,
+        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);
+}

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

@@ -0,0 +1,30 @@
+namespace RackPeek.Domain.Discovery;
+
+/// <summary>
+///     Reads raw facts off the host. The only platform-specific code in discovery, and
+///     deliberately the only part that is not unit tested — it does IO and nothing else.
+/// </summary>
+public interface ISystemProbe {
+    /// <summary>True when this probe can run on the current host.</summary>
+    bool IsSupported { get; }
+
+    Task<RawSystemSnapshot> ReadAsync(CancellationToken cancellationToken = default);
+}
+
+/// <summary>
+///     Probe selection, shared by every collector so they all describe the host — and
+///     seed discovery ids — identically.
+/// </summary>
+public static class SystemProbes {
+    /// <summary>Facts from the first supported probe, or null on an unsupported platform.</summary>
+    public static async Task<SystemFacts?> TryReadHostAsync(
+        IEnumerable<ISystemProbe> probes,
+        CancellationToken cancellationToken) {
+        ISystemProbe? probe = probes.FirstOrDefault(p => p.IsSupported);
+
+        if (probe == null)
+            return null;
+
+        return SystemFactsParser.Parse(await probe.ReadAsync(cancellationToken));
+    }
+}

+ 67 - 0
RackPeek.Domain/Discovery/LinuxSystemProbe.cs

@@ -0,0 +1,67 @@
+namespace RackPeek.Domain.Discovery;
+
+public sealed class LinuxSystemProbe : ISystemProbe {
+    private const string _blockDeviceRoot = "/sys/block";
+    private const long _sectorBytes = 512;
+
+    public bool IsSupported => OperatingSystem.IsLinux();
+
+    public async Task<RawSystemSnapshot> ReadAsync(CancellationToken cancellationToken = default) {
+        return new RawSystemSnapshot {
+            Hostname = SystemProbeCommon.Hostname(),
+            Cores = SystemProbeCommon.Cores(),
+            Nics = SystemProbeCommon.Nics(),
+            FallbackMemoryBytes = SystemProbeCommon.FallbackMemoryBytes(),
+            BlockDevices = ReadBlockDevices(),
+            OsReleaseFile = await SystemProbeCommon.TryReadFileAsync("/etc/os-release", cancellationToken),
+            MemInfoFile = await SystemProbeCommon.TryReadFileAsync("/proc/meminfo", cancellationToken),
+            MachineIdFile = await ReadMachineIdAsync(cancellationToken),
+            CgroupFile = await SystemProbeCommon.TryReadFileAsync("/proc/1/cgroup", cancellationToken),
+            DockerEnvPresent = File.Exists("/.dockerenv"),
+            DmiVendor = await SystemProbeCommon.TryReadFileAsync("/sys/class/dmi/id/sys_vendor", cancellationToken),
+            DmiProduct = await SystemProbeCommon.TryReadFileAsync("/sys/class/dmi/id/product_name", cancellationToken)
+        };
+    }
+
+    private static async Task<string?> ReadMachineIdAsync(CancellationToken cancellationToken) =>
+        await SystemProbeCommon.TryReadFileAsync("/etc/machine-id", cancellationToken)
+        ?? await SystemProbeCommon.TryReadFileAsync("/var/lib/dbus/machine-id", cancellationToken);
+
+    /// <summary>
+    ///     Whole disks as the kernel sees them, which is closer to what goes on an
+    ///     inventory card than the mounted filesystems would be.
+    /// </summary>
+    private static List<BlockDeviceFact> ReadBlockDevices() {
+        try {
+            if (!Directory.Exists(_blockDeviceRoot))
+                return [];
+
+            return Directory.EnumerateDirectories(_blockDeviceRoot)
+                .Select(ReadBlockDevice)
+                .OfType<BlockDeviceFact>()
+                .OrderBy(d => d.Name, StringComparer.Ordinal)
+                .ToList();
+        }
+        catch {
+            return [];
+        }
+    }
+
+    private static BlockDeviceFact? ReadBlockDevice(string path) {
+        try {
+            var name = Path.GetFileName(path);
+
+            if (!long.TryParse(File.ReadAllText(Path.Combine(path, "size")).Trim(), out var sectors))
+                return null;
+
+            var rotationalPath = Path.Combine(path, "queue", "rotational");
+            var rotational = File.Exists(rotationalPath)
+                             && File.ReadAllText(rotationalPath).Trim() == "1";
+
+            return new BlockDeviceFact(name, sectors * _sectorBytes, rotational);
+        }
+        catch {
+            return null;
+        }
+    }
+}

+ 76 - 0
RackPeek.Domain/Discovery/MacSystemProbe.cs

@@ -0,0 +1,76 @@
+namespace RackPeek.Domain.Discovery;
+
+/// <summary>
+///     macOS host probe. Disks are deliberately left out: everything that reports them
+///     (diskutil, system_profiler) needs a plist parser for a field that is optional
+///     anyway, and omitting is better than guessing — null means "don't touch" on merge.
+/// </summary>
+public sealed class MacSystemProbe : ISystemProbe {
+    public bool IsSupported => OperatingSystem.IsMacOS();
+
+    public async Task<RawSystemSnapshot> ReadAsync(CancellationToken cancellationToken = default) {
+        // The four probes spawn independent processes, so they run side by side.
+        Task<string?> osName = ReadOsNameAsync(cancellationToken);
+        Task<long?> memoryBytes = ReadMemoryBytesAsync(cancellationToken);
+        Task<string?> platformUuid = ReadPlatformUuidAsync(cancellationToken);
+        Task<bool> hypervisorPresent = ReadHypervisorPresentAsync(cancellationToken);
+
+        return new RawSystemSnapshot {
+            Hostname = SystemProbeCommon.Hostname(),
+            Cores = SystemProbeCommon.Cores(),
+            Nics = SystemProbeCommon.Nics(),
+            FallbackMemoryBytes = SystemProbeCommon.FallbackMemoryBytes(),
+            OsName = await osName,
+            MemoryBytes = await memoryBytes,
+            PlatformUuid = await platformUuid,
+            HypervisorPresent = await hypervisorPresent
+        };
+    }
+
+    private static async Task<string?> ReadOsNameAsync(CancellationToken cancellationToken) {
+        var product = await SystemProbeCommon.TryRunAsync("sw_vers", "-productName", cancellationToken);
+        var version = await SystemProbeCommon.TryRunAsync("sw_vers", "-productVersion", cancellationToken);
+
+        if (string.IsNullOrWhiteSpace(product))
+            return null;
+
+        return string.IsNullOrWhiteSpace(version) ? product : $"{product} {version}";
+    }
+
+    private static async Task<long?> ReadMemoryBytesAsync(CancellationToken cancellationToken) {
+        var value = await SystemProbeCommon.TryRunAsync("sysctl", "-n hw.memsize", cancellationToken);
+
+        return long.TryParse(value, out var bytes) ? bytes : null;
+    }
+
+    private static async Task<bool> ReadHypervisorPresentAsync(CancellationToken cancellationToken) {
+        var value = await SystemProbeCommon.TryRunAsync("sysctl", "-n kern.hv_vmm_present", cancellationToken);
+
+        return value?.Trim() == "1";
+    }
+
+    private static async Task<string?> ReadPlatformUuidAsync(CancellationToken cancellationToken) {
+        var output = await SystemProbeCommon.TryRunAsync(
+            "ioreg", "-rd1 -c IOPlatformExpertDevice", cancellationToken);
+
+        return ParsePlatformUuid(output);
+    }
+
+    /// <summary>Pulls IOPlatformUUID out of an ioreg dump. Public so it can be tested off a macOS host.</summary>
+    public static string? ParsePlatformUuid(string? ioregOutput) {
+        if (string.IsNullOrWhiteSpace(ioregOutput))
+            return null;
+
+        foreach (var line in ioregOutput.Split('\n')) {
+            if (!line.Contains("IOPlatformUUID", StringComparison.Ordinal))
+                continue;
+
+            var parts = line.Split('=', 2);
+
+            if (parts.Length == 2)
+                return parts[1].Trim().Trim('"').Trim();
+        }
+
+        return null;
+    }
+}

+ 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&";
+}

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

@@ -0,0 +1,259 @@
+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;
+
+/// <summary>The real network IO. Deliberately dumb; see <see cref="INetworkProbe" />.</summary>
+public sealed class NetworkProbe : INetworkProbe {
+    public bool IsSupported => !OperatingSystem.IsBrowser();
+
+    public async Task<bool> PingAsync(string ip, TimeSpan timeout, CancellationToken cancellationToken = default) {
+        try {
+            using var ping = new Ping();
+            PingReply reply = await ping.SendPingAsync(ip, timeout, cancellationToken: cancellationToken);
+
+            return reply.Status == IPStatus.Success;
+        }
+        catch {
+            // No ICMP privilege, unreachable network, bad address — all mean "no answer".
+            return false;
+        }
+    }
+
+    public async Task<bool> TryConnectAsync(
+        string ip,
+        int port,
+        TimeSpan timeout,
+        CancellationToken cancellationToken = default) {
+        try {
+            using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
+            using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+            cts.CancelAfter(timeout);
+
+            await socket.ConnectAsync(IPAddress.Parse(ip), port, cts.Token);
+
+            return true;
+        }
+        catch {
+            // Refused, timed out, filtered — for liveness they are all the same "no".
+            return false;
+        }
+    }
+
+    public async Task<string?> ReadArpAsync(CancellationToken cancellationToken = default) {
+        // Linux reads the kernel's file; BSD/macOS answer `arp -an`; Windows' arp.exe
+        // only knows `-a`. A source only wins if it yields entries the parser can use —
+        // an exit code alone is not proof (arp.exe printing usage text could exit 0),
+        // and trusting one would silently cost every host its MAC identity.
+        foreach (Func<Task<string?>> read in new Func<Task<string?>>[] {
+                     () => SystemProbeCommon.TryReadFileAsync("/proc/net/arp", cancellationToken),
+                     () => SystemProbeCommon.TryRunAsync("arp", "-an", cancellationToken),
+                     () => SystemProbeCommon.TryRunAsync("arp", "-a", cancellationToken)
+                 }) {
+            var text = await read();
+
+            if (text != null && ArpTableParser.Parse(text).Count > 0)
+                return text;
+        }
+
+        return null;
+    }
+
+    public async Task<string?> ReverseDnsAsync(
+        string ip,
+        TimeSpan timeout,
+        CancellationToken cancellationToken = default) {
+        try {
+            // A resolver with a dead PTR zone can sit on the query far longer than the
+            // whole sweep took; the cap keeps a pile of dead lookups from stalling it.
+            using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+            cts.CancelAfter(timeout);
+
+            IPHostEntry entry = await Dns.GetHostEntryAsync(ip, cts.Token);
+
+            // Some resolvers answer a PTR miss by echoing the address back.
+            return string.IsNullOrWhiteSpace(entry.HostName) || entry.HostName == ip
+                ? null
+                : entry.HostName;
+        }
+        catch {
+            return null;
+        }
+    }
+
+    public 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()) {
+                if (nic.OperationalStatus != OperationalStatus.Up
+                    || nic.NetworkInterfaceType == NetworkInterfaceType.Loopback)
+                    continue;
+
+                IPInterfaceProperties properties = nic.GetIPProperties();
+
+                var hasGateway = properties.GatewayAddresses.Any(g =>
+                    g.Address.AddressFamily == AddressFamily.InterNetwork
+                    && !g.Address.Equals(IPAddress.Any));
+
+                if (!hasGateway)
+                    continue;
+
+                // Skip 169.254/16 self-assigned addresses: a NIC mid-DHCP-renewal can
+                // carry one alongside its real address, and sweeping that block finds
+                // nothing by definition.
+                UnicastIPAddressInformation? address = properties.UnicastAddresses.FirstOrDefault(a =>
+                    a.Address.AddressFamily == AddressFamily.InterNetwork
+                    && !IsLinkLocal(a.Address));
+
+                if (address == null)
+                    continue;
+
+                return Cidr.Parse($"{address.Address}/{address.PrefixLength}");
+            }
+        }
+        catch {
+            // Fall through: the caller asks the user for --cidr instead.
+        }
+
+        return null;
+    }
+
+    private static bool IsLinkLocal(IPAddress address) {
+        var bytes = address.GetAddressBytes();
+
+        return bytes.Length == 4 && bytes[0] == 169 && bytes[1] == 254;
+    }
+}

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

@@ -0,0 +1,71 @@
+using RackPeek.Domain.Resources.Services.Networking;
+
+namespace RackPeek.Domain.Discovery;
+
+/// <summary>
+///     One responding host, as the sweep saw it. <see cref="OpenPorts" /> records only
+///     what liveness probing happened to touch — port probing stops at the first answer,
+///     so this is evidence the host is alive, never a port inventory.
+/// </summary>
+public sealed record NetworkHostFact(
+    string Ip,
+    string? Mac,
+    string? Hostname,
+    bool AnsweredPing,
+    IReadOnlyList<int> OpenPorts) {
+    /// <summary>
+    ///     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 {
+    public required Cidr Cidr { get; init; }
+
+    /// <summary>TCP ports probed to catch hosts that do not answer ping.</summary>
+    public IReadOnlyList<int> Ports { get; init; } = WellKnownPorts.Defaults;
+
+    public TimeSpan PingTimeout { get; init; } = TimeSpan.FromMilliseconds(300);
+
+    public TimeSpan PortTimeout { get; init; } = TimeSpan.FromMilliseconds(500);
+
+    /// <summary>Cap on each alive host's reverse-DNS lookup — resolvers that silently
+    /// drop PTR queries would otherwise stall the whole result on the OS default.</summary>
+    public TimeSpan DnsTimeout { get; init; } = TimeSpan.FromSeconds(2);
+
+    /// <summary>How many hosts are probed at once.</summary>
+    public int Concurrency { get; init; } = 128;
+
+    /// <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;
+}

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

@@ -0,0 +1,183 @@
+using RackPeek.Domain.Resources;
+using RackPeek.Domain.Resources.Services;
+using RackPeek.Domain.Resources.Services.Networking;
+using RackPeek.Domain.Resources.SystemResources;
+
+namespace RackPeek.Domain.Discovery;
+
+/// <summary>Maps swept hosts onto the System resources RackPeek stores. Pure.</summary>
+public static class NetworkScanMapper {
+    public static List<Resource> ToResources(IReadOnlyList<NetworkHostFact> hosts) {
+        var taken = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
+        var resources = new List<Resource>();
+
+        foreach ((NetworkHostFact host, IReadOnlyList<string> allIps) in Collapse(hosts)) {
+            // The MAC is the only identity a scan can see that survives a DHCP re-lease;
+            // when ARP could not provide one (a routed subnet, say) the IP has to do,
+            // and the id changes if the address does — documented in the guide.
+            var discoveryId = DiscoveryId.Create(
+                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(
+                        label,
+                        "host",
+                        discoveryId),
+                    discoveryId,
+                    taken),
+                DiscoveryId = discoveryId,
+                // Deliberately sparse: a scan sees an address, not an OS or a type, and
+                // whatever it wrote here would overwrite the real values on every rescan
+                // of a card the user (or an agent collector) has since filled in.
+                Ip = host.Ip
+            };
+
+            if (host.Mac != null)
+                system.Labels["mac"] = host.Mac;
+
+            // 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
+    ///     ip (deterministic), every address in an "ips" label. Anything else would make
+    ///     the machine's identity depend on how many of its addresses happened to answer
+    ///     a particular scan, and identity must never move between scans.
+    /// </summary>
+    private static IEnumerable<(NetworkHostFact Host, IReadOnlyList<string> AllIps)> Collapse(
+        IReadOnlyList<NetworkHostFact> hosts) {
+        var byMac = new Dictionary<string, List<NetworkHostFact>>(StringComparer.OrdinalIgnoreCase);
+
+        foreach (NetworkHostFact host in hosts)
+            if (host.Mac != null) {
+                if (!byMac.TryGetValue(host.Mac, out List<NetworkHostFact>? group))
+                    byMac[host.Mac] = group = [];
+
+                group.Add(host);
+            }
+
+        var emitted = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
+
+        foreach (NetworkHostFact host in hosts) {
+            if (host.Mac == null) {
+                yield return (host, [host.Ip]);
+
+                continue;
+            }
+
+            if (!emitted.Add(host.Mac))
+                continue;
+
+            var group = byMac[host.Mac]
+                .OrderBy(h => IpHelper.ToUInt32(h.Ip))
+                .ToList();
+
+            NetworkHostFact primary = group[0];
+
+            // Any name in the group beats none: a VIP rarely has its own PTR record.
+            var hostname = group.Select(h => h.Hostname).FirstOrDefault(n => n != null);
+
+            yield return (
+                primary with { Hostname = hostname },
+                group.Select(h => h.Ip).ToList());
+        }
+    }
+}

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

@@ -0,0 +1,287 @@
+using RackPeek.Domain.Resources.Services.Networking;
+
+namespace RackPeek.Domain.Discovery;
+
+/// <summary>
+///     Sweeps a subnet and reports the hosts that answered. A host counts as alive when
+///     it answers ping OR accepts a TCP connect on any probed port — plenty of gear
+///     drops ICMP, and plenty of gear with ICMP open runs no interesting service, so
+///     neither signal alone is enough. All IO goes through <see cref="INetworkProbe" />.
+/// </summary>
+public static class NetworkScanner {
+    /// <summary>
+    ///     The widest block a sweep accepts, wherever the block came from — typed by the
+    ///     user or auto-detected off a NIC. Wider than this is 65k+ hosts: a typo or a
+    ///     CGNAT/VPN prefix, not a homelab.
+    /// </summary>
+    public const int MinPrefix = 16;
+
+    /// <summary>
+    ///     Every address worth probing in the block: hosts only, so the network and
+    ///     broadcast addresses are skipped — except in /31 (RFC 3021 point-to-point)
+    ///     and /32, where every address is a host.
+    /// </summary>
+    public static IEnumerable<string> EnumerateTargets(Cidr cidr) {
+        // 64-bit throughout: 1u << 32 wraps under C#'s masked shift, and a block that
+        // touches 255.255.255.255 would overflow the loop bound in 32 bits.
+        var size = 1UL << (32 - cidr.Prefix);
+
+        var first = cidr.Prefix >= 31 ? cidr.Network : (ulong)cidr.Network + 1;
+        var last = cidr.Prefix >= 31
+            ? cidr.Network + size - 1
+            : cidr.Network + size - 2;
+
+        for (var ip = first; ip <= last; ip++)
+            yield return IpHelper.ToIp((uint)ip);
+    }
+
+    public static async Task<IReadOnlyList<NetworkHostFact>> ScanAsync(
+        INetworkProbe probe,
+        NetworkScanOptions options,
+        CancellationToken cancellationToken = default) {
+        // Enforced here rather than only at a front end, so every caller — CLI flag,
+        // auto-detected subnet, future MCP tool — hits the same wall.
+        if (options.Cidr.Prefix < MinPrefix)
+            throw new ArgumentOutOfRangeException(
+                nameof(options),
+                $"/{options.Cidr.Prefix} is more than 65,534 hosts. Narrow the sweep to /{MinPrefix} or smaller.");
+
+        var targets = EnumerateTargets(options.Cidr).ToList();
+
+        using var gate = new SemaphoreSlim(options.Concurrency);
+
+        (string Ip, bool Ping, List<int> Open)?[] swept = await Task.WhenAll(
+            targets.Select(ip => SweepHostAsync(probe, options, ip, gate, cancellationToken)));
+
+        var alive = swept.Where(h => h != null).Select(h => h!.Value).ToList();
+
+        // Read the ARP table only after the sweep: it is the sweep's own pings and
+        // connects that put the neighbours into it.
+        IReadOnlyDictionary<string, string> macByIp =
+            ArpTableParser.Parse(await probe.ReadArpAsync(cancellationToken));
+
+        // Names resolve in parallel too — a resolver that drops PTR queries burns the
+        // full timeout per lookup, and paying that once beats paying it per host.
+        var names = await Task.WhenAll(alive.Select(async h => {
+            await gate.WaitAsync(cancellationToken);
+
+            try {
+                return await probe.ReverseDnsAsync(h.Ip, options.DnsTimeout, cancellationToken);
+            }
+            finally {
+                gate.Release();
+            }
+        }));
+
+        // 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, _) = 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
+            .OrderBy(f => IpHelper.ToUInt32(f.Ip))
+            .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,
+        NetworkScanOptions options,
+        string ip,
+        SemaphoreSlim gate,
+        CancellationToken cancellationToken) {
+        await gate.WaitAsync(cancellationToken);
+
+        try {
+            var ping = await probe.PingAsync(ip, options.PingTimeout, cancellationToken);
+            var open = new List<int>();
+
+            // Liveness needs one answer, not a port inventory: a ping reply skips the
+            // port probes entirely, and probing stops at the first open port.
+            if (!ping)
+                foreach (var port in options.Ports) {
+                    if (!await probe.TryConnectAsync(ip, port, options.PortTimeout, cancellationToken))
+                        continue;
+
+                    open.Add(port);
+                    break;
+                }
+
+            return ping || open.Count > 0 ? (ip, ping, open) : null;
+        }
+        finally {
+            gate.Release();
+        }
+    }
+}

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

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

@@ -0,0 +1,223 @@
+using System.Text.Json;
+using System.Net.Security;
+
+namespace RackPeek.Domain.Discovery;
+
+/// <summary>
+///     Talks to the Proxmox VE API with an API token. A token is used rather than a
+///     password because it can be given a read-only role and revoked on its own.
+/// </summary>
+public sealed class ProxmoxApiClient : IProxmoxClient, IDisposable {
+    public const string QemuEndpoint = "qemu";
+    public const string LxcEndpoint = "lxc";
+    public const string TokenIdEnvironmentVariable = "RPK_PVE_TOKEN_ID";
+    public const string TokenSecretEnvironmentVariable = "RPK_PVE_TOKEN_SECRET";
+
+    private readonly HttpClient _httpClient;
+
+    /// <param name="allowUntrustedCertificate">
+    ///     Proxmox ships with a self-signed certificate and most installations keep it,
+    ///     so this is needed more often than not. It is opt-in all the same.
+    /// </param>
+    public ProxmoxApiClient(
+        string host,
+        string tokenId,
+        string tokenSecret,
+        bool allowUntrustedCertificate = false,
+        HttpClient? httpClient = null) {
+        Endpoint = Normalise(host);
+
+        _httpClient = httpClient ?? new HttpClient(Handler(allowUntrustedCertificate));
+        _httpClient.BaseAddress = new Uri(Endpoint + "/api2/json/");
+        _httpClient.Timeout = TimeSpan.FromSeconds(30);
+
+        // Proxmox expects the whole token as one opaque Authorization value.
+        _httpClient.DefaultRequestHeaders.TryAddWithoutValidation(
+            "Authorization",
+            $"PVEAPIToken={tokenId}={tokenSecret}");
+    }
+
+    public string Endpoint { get; }
+
+    public void Dispose() => _httpClient.Dispose();
+
+    public async Task<string> GetIdentityScopeAsync(CancellationToken cancellationToken = default) {
+        // A standalone host has no cluster, and Proxmox answers 5xx rather than an empty
+        // list, so a failure here is expected and means "not clustered".
+        try {
+            var json = await GetAsync("cluster/status", cancellationToken);
+            var clusterName = ProxmoxResponseParser.ParseIdentityScope(json, string.Empty);
+
+            // Only fall back to the node list when there is no cluster name — the
+            // fallback costs a second call, and on a cluster it would be thrown away.
+            return clusterName.Length > 0 ? clusterName : await FirstNodeAsync(cancellationToken);
+        }
+        catch (HttpRequestException) {
+            // Either there is no cluster, or the token may not read it. Either way the
+            // node is a sound scope: a vmid is unique within it.
+            return await FirstNodeAsync(cancellationToken);
+        }
+    }
+
+    public async Task<IReadOnlyList<ProxmoxNode>> GetNodesAsync(CancellationToken cancellationToken = default) =>
+        ProxmoxResponseParser.ParseNodes(await GetAsync("nodes", cancellationToken));
+
+    public async Task<ProxmoxNode> EnrichAsync(
+        ProxmoxNode node,
+        CancellationToken cancellationToken = default) {
+        try {
+            // The three endpoints are independent, so the round trips overlap.
+            Task<string> status = GetAsync($"nodes/{Uri.EscapeDataString(node.Name)}/status", cancellationToken);
+            Task<IReadOnlyList<ProxmoxDisk>> disks = GetDisksAsync(node.Name, cancellationToken);
+            Task<IReadOnlyList<ProxmoxGpu>> gpus = GetGpusAsync(node.Name, cancellationToken);
+
+            ProxmoxNode detail = ProxmoxResponseParser.ParseNodeStatus(await status, node.Name);
+
+            return node with {
+                Cores = detail.Cores > 0 ? detail.Cores : node.Cores,
+                MemoryBytes = detail.MemoryBytes > 0 ? detail.MemoryBytes : node.MemoryBytes,
+                Version = detail.Version,
+                CpuModel = detail.CpuModel,
+                Sockets = detail.Sockets,
+                PhysicalCores = detail.PhysicalCores,
+                Disks = await disks,
+                Gpus = await gpus
+            };
+        }
+        catch (HttpRequestException) {
+            return node;
+        }
+    }
+
+    public async Task<IReadOnlyList<ProxmoxGuest>> GetGuestsAsync(
+        string node,
+        string endpoint,
+        CancellationToken cancellationToken = default) {
+        var json = await GetAsync($"nodes/{Uri.EscapeDataString(node)}/{endpoint}", cancellationToken);
+
+        return ProxmoxResponseParser.ParseGuests(
+            json,
+            node,
+            endpoint == LxcEndpoint ? ProxmoxResponseParser.ContainerType : ProxmoxResponseParser.VmType);
+    }
+
+    public async Task<IReadOnlyList<ProxmoxDisk>> GetDisksAsync(
+        string node,
+        CancellationToken cancellationToken = default) {
+        try {
+            return ProxmoxResponseParser.ParseDisks(
+                await GetAsync($"nodes/{Uri.EscapeDataString(node)}/disks/list", cancellationToken));
+        }
+        catch (HttpRequestException) {
+            return [];
+        }
+    }
+
+    public async Task<IReadOnlyList<ProxmoxGpu>> GetGpusAsync(
+        string node,
+        CancellationToken cancellationToken = default) {
+        try {
+            return ProxmoxResponseParser.ParseGpus(
+                await GetAsync($"nodes/{Uri.EscapeDataString(node)}/hardware/pci", cancellationToken));
+        }
+        catch (HttpRequestException) {
+            return [];
+        }
+    }
+
+    public async Task<ProxmoxGuestConfig> GetGuestConfigAsync(
+        string node,
+        string endpoint,
+        int vmId,
+        CancellationToken cancellationToken = default) {
+        // A guest can disappear between listing and reading it; that is not worth failing
+        // the whole run over, so it simply contributes nothing.
+        try {
+            var json = await GetAsync(
+                $"nodes/{Uri.EscapeDataString(node)}/{endpoint}/{vmId}/config",
+                cancellationToken);
+
+            return ProxmoxResponseParser.ParseGuestConfig(json);
+        }
+        catch (HttpRequestException) {
+            return new ProxmoxGuestConfig(null, null, [], []);
+        }
+    }
+
+    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);
+
+        return nodes.FirstOrDefault()?.Name ?? "proxmox";
+    }
+
+    private async Task<string> GetAsync(string path, CancellationToken cancellationToken) {
+        using HttpResponseMessage response = await _httpClient.GetAsync(path, cancellationToken);
+
+        if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized)
+            throw new HttpRequestException(
+                "Proxmox rejected the API token (401). Check the token id is of the form " +
+                "user@realm!tokenname and that the secret matches.");
+
+        if (response.StatusCode == System.Net.HttpStatusCode.Forbidden)
+            throw new HttpRequestException(
+                $"The API token is not permitted to read {path} (403). In the Proxmox UI: " +
+                "Datacenter -> Permissions -> Add -> API Token Permission, path '/', " +
+                "role PVEAuditor, with Propagate ticked.");
+
+        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;
+    }
+
+    private static string Normalise(string host) {
+        var trimmed = host.Trim().TrimEnd('/');
+
+        if (trimmed.Contains("://", StringComparison.Ordinal))
+            return trimmed;
+
+        // A bare name gets the default scheme and port, but "pve.lan:8006" already
+        // carries a port — appending another would make the URL unparseable.
+        return trimmed.Contains(':', StringComparison.Ordinal)
+            ? $"https://{trimmed}"
+            : $"https://{trimmed}:8006";
+    }
+}

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

+ 602 - 0
RackPeek.Domain/Discovery/ProxmoxModels.cs

@@ -0,0 +1,602 @@
+using System.Text.Json;
+
+namespace RackPeek.Domain.Discovery;
+
+/// <summary>
+///     A Proxmox node: the physical machine and the hypervisor installed on it. RackPeek
+///     models those as two resources, so both sets of facts are gathered here.
+/// </summary>
+public sealed record ProxmoxNode {
+    public required string Name { get; init; }
+
+    /// <summary>Logical processors, which is what the hypervisor OS sees.</summary>
+    public int Cores { get; init; }
+
+    public long MemoryBytes { get; init; }
+
+    /// <summary>e.g. <c>pve-manager/8.2.2/9355359cd7afbae4</c>, only from the status call.</summary>
+    public string? Version { get; init; }
+
+    // Hardware, all from the status call and all optional — a token without Sys.Audit
+    // still gets a usable node, just without these.
+    public string? CpuModel { get; init; }
+    public int Sockets { get; init; }
+    public int PhysicalCores { get; init; }
+    public IReadOnlyList<ProxmoxDisk> Disks { get; init; } = [];
+
+    /// <summary>
+    ///     Display adapters in the machine. A GPU passed through to a guest is still
+    ///     physically in the host, so this is where it belongs — the PCI address is kept
+    ///     so a guest holding it can say which card it has.
+    /// </summary>
+    public IReadOnlyList<ProxmoxGpu> Gpus { get; init; } = [];
+}
+
+/// <summary>A physical disk as Proxmox reports it, already classified by type.</summary>
+public sealed record ProxmoxDisk(string Type, long SizeBytes, string? Model);
+
+/// <summary>A display adapter and where it sits on the bus.</summary>
+public sealed record ProxmoxGpu(string Address, string Model);
+
+/// <summary>A guest on a node. QEMU and LXC differ only in the kind of system they are.</summary>
+public sealed record ProxmoxGuest {
+    public required int VmId { get; init; }
+    public required string Node { get; init; }
+
+    /// <summary>Empty for a guest that has never been named; the mapper falls back to the vmid.</summary>
+    public string Name { get; init; } = string.Empty;
+
+    /// <summary><c>vm</c> or <c>container</c>, matching SystemResource.ValidSystemTypes.</summary>
+    public required string Type { get; init; }
+
+    public int Cores { get; init; }
+    public long MemoryBytes { get; init; }
+    /// <summary>The boot disk, from the guest list. A fallback for when the config is unreadable.</summary>
+    public long DiskBytes { get; init; }
+
+    /// <summary>Every attached disk, from the guest's config.</summary>
+    public IReadOnlyList<long> Disks { get; init; } = [];
+
+    /// <summary>PCI addresses handed exclusively to this guest, from its config.</summary>
+    public IReadOnlyList<string> PassthroughAddresses { get; init; } = [];
+
+    public IReadOnlyList<string> Tags { get; init; } = [];
+
+    /// <summary>
+    ///     <c>running</c>, <c>stopped</c> and friends, from the guest list. Only a running
+    ///     guest can be asked where it is, and a stopped one has no address to report.
+    /// </summary>
+    public string? Status { get; init; }
+
+    /// <summary>Filled in from the guest's config, which is the only place it is known.</summary>
+    public string? Os { get; init; }
+
+    public string? Ip { get; init; }
+
+    /// <summary>
+    ///     The guest's NIC MACs, from its config's netN lines — the bridge that lets a
+    ///     network scan and this collector agree they are looking at the same guest.
+    /// </summary>
+    public IReadOnlyList<string> Macs { get; init; } = [];
+}
+
+/// <summary>
+///     Parses the Proxmox API. Every response wraps its payload in a <c>data</c> member.
+///     Pure, so it tests from captured responses without a Proxmox to talk to.
+/// </summary>
+public static class ProxmoxResponseParser {
+    public const string VmType = "vm";
+    public const string ContainerType = "container";
+
+    /// <summary>
+    ///     Nodes with whatever detail the token is allowed to see. Proxmox strips
+    ///     <c>maxcpu</c> and <c>maxmem</c> from this response for a token without the
+    ///     rights to read them, rather than refusing the call, so both shapes are normal.
+    /// </summary>
+    public static List<ProxmoxNode> ParseNodes(string json) {
+        return Data(json)
+            .Where(n => !string.IsNullOrWhiteSpace(GetString(n, "node")))
+            .Select(n => new ProxmoxNode {
+                Name = GetString(n, "node")!,
+                Cores = GetInt(n, "maxcpu") ?? 0,
+                MemoryBytes = GetLong(n, "maxmem") ?? 0
+            })
+            .OrderBy(n => n.Name, StringComparer.Ordinal)
+            .ToList();
+    }
+
+    public static ProxmoxNode ParseNodeStatus(string json, string nodeName) {
+        using var document = JsonDocument.Parse(json);
+
+        if (!document.RootElement.TryGetProperty("data", out JsonElement data))
+            return new ProxmoxNode { Name = nodeName };
+
+        var cores = data.TryGetProperty("cpuinfo", out JsonElement cpu)
+            ? GetInt(cpu, "cpus") ?? GetInt(cpu, "cores") ?? 0
+            : 0;
+
+        var memory = data.TryGetProperty("memory", out JsonElement mem)
+            ? GetLong(mem, "total") ?? 0
+            : 0;
+
+        return new ProxmoxNode {
+            Name = nodeName,
+            Cores = cores,
+            MemoryBytes = memory,
+            Version = GetString(data, "pveversion"),
+            CpuModel = cpu.ValueKind == JsonValueKind.Object ? GetString(cpu, "model") : null,
+            Sockets = cpu.ValueKind == JsonValueKind.Object ? GetInt(cpu, "sockets") ?? 0 : 0,
+            PhysicalCores = cpu.ValueKind == JsonValueKind.Object ? GetInt(cpu, "cores") ?? 0 : 0
+        };
+    }
+
+    /// <summary>
+    ///     Physical disks. Proxmox has already worked out nvme/ssd/hdd, which is better
+    ///     than the guess <c>discover system</c> has to make from a rotational flag.
+    /// </summary>
+    public static List<ProxmoxDisk> ParseDisks(string json) {
+        return Data(json)
+            .Select(d => new ProxmoxDisk(
+                NormaliseDiskType(GetString(d, "type")),
+                GetLong(d, "size") ?? 0,
+                GetString(d, "model")))
+            .Where(d => d.SizeBytes > 0)
+            .ToList();
+    }
+
+    /// <summary>Proxmox says "unknown" for a disk it cannot classify; RackPeek omits the type.</summary>
+    private static string NormaliseDiskType(string? type) {
+        return type?.ToLowerInvariant() switch {
+            "nvme" => "nvme",
+            "ssd" => "ssd",
+            "hdd" => "hdd",
+            _ => string.Empty
+        };
+    }
+
+    /// <summary>
+    ///     Display adapters from the node's PCI device list. PCI class 0x03 is the
+    ///     display-controller class, which is how a GPU is told apart from the other
+    ///     couple of dozen devices on a modern board.
+    /// </summary>
+    public static List<ProxmoxGpu> ParseGpus(string json) {
+        return Data(json)
+            .Where(d => (GetString(d, "class") ?? string.Empty).StartsWith("0x03", StringComparison.Ordinal))
+            .Select(d => new { Address = GetString(d, "id"), Model = MarketingName(GetString(d, "device_name")) })
+            .Where(g => !string.IsNullOrWhiteSpace(g.Address) && !string.IsNullOrWhiteSpace(g.Model))
+            .Select(g => new ProxmoxGpu(g.Address!, g.Model!))
+            .ToList();
+    }
+
+    /// <summary>
+    ///     PCI addresses a guest has been given exclusive use of. The config writes them
+    ///     as <c>hostpci0: 0000:01:00</c>, optionally with trailing options and sometimes
+    ///     without the function suffix the device list carries.
+    /// </summary>
+    public static List<string> ParsePassthrough(JsonElement config) {
+        return config.EnumerateObject()
+            .Where(p => p.Name.StartsWith("hostpci", StringComparison.OrdinalIgnoreCase))
+            .Select(p => p.Value.ValueKind == JsonValueKind.String ? p.Value.GetString() : null)
+            .Where(v => !string.IsNullOrWhiteSpace(v))
+            .Select(v => v!.Split(',')[0].Trim())
+            .Where(v => v.Length > 0)
+            .ToList();
+    }
+
+    /// <summary>
+    ///     PCI names the part and then the product: <c>GA102 [GeForce RTX 3090]</c>. The
+    ///     bracketed half is the one people would have typed, so it wins where it exists.
+    /// </summary>
+    public static string? MarketingName(string? deviceName) {
+        if (string.IsNullOrWhiteSpace(deviceName))
+            return null;
+
+        var open = deviceName.IndexOf('[');
+        var close = deviceName.IndexOf(']');
+
+        return close > open && open >= 0
+            ? deviceName[(open + 1)..close].Trim()
+            : deviceName.Trim();
+    }
+
+    public static List<ProxmoxGuest> ParseGuests(string json, string node, string type) {
+        return Data(json)
+            .Select(g => ParseGuest(g, node, type))
+            .OfType<ProxmoxGuest>()
+            .OrderBy(g => g.VmId)
+            .ToList();
+    }
+
+    /// <summary>
+    ///     The scope a vmid is unique within. A clustered guest can migrate between nodes,
+    ///     so the cluster name is what keeps its identity stable; a standalone host has no
+    ///     cluster entry and falls back to the node.
+    /// </summary>
+    public static string ParseIdentityScope(string clusterStatusJson, string fallbackNode) {
+        JsonElement cluster = Data(clusterStatusJson)
+            .FirstOrDefault(e => GetString(e, "type") == "cluster");
+
+        var name = cluster.ValueKind == JsonValueKind.Object ? GetString(cluster, "name") : null;
+
+        return string.IsNullOrWhiteSpace(name) ? fallbackNode : name;
+    }
+
+    /// <summary>
+    ///     Reads the guest's own config. This is the only place the OS is knowable, and
+    ///     for a container it carries the address too — which is why the extra call per
+    ///     guest earns its place.
+    /// </summary>
+    public static ProxmoxGuestConfig ParseGuestConfig(string json) {
+        using var document = JsonDocument.Parse(json);
+
+        if (!document.RootElement.TryGetProperty("data", out JsonElement data))
+            return new ProxmoxGuestConfig(null, null, [], [], []);
+
+        return new ProxmoxGuestConfig(
+            DescribeOs(GetString(data, "ostype")),
+            ParseStaticIp(GetString(data, "net0")),
+            ParseDiskSizes(data),
+            ParsePassthrough(data),
+            ParseMacs(data));
+    }
+
+    /// <summary>
+    ///     The MACs in a guest's netN lines. QEMU spells them as the NIC model's value
+    ///     (<c>virtio=BC:24:11:…</c>), containers as <c>hwaddr=BC:24:11:…</c> — so any
+    ///     part whose value normalises to a MAC counts, and nothing else can (bridge
+    ///     names, ip=, tags never survive normalisation). Normalised by the same code
+    ///     that reads ARP tables, so a scan and this collector always agree.
+    /// </summary>
+    public static List<string> ParseMacs(JsonElement config) {
+        var macs = new List<string>();
+
+        foreach (JsonProperty property in config.EnumerateObject()) {
+            if (!IsNetSlot(property.Name))
+                continue;
+
+            var value = property.Value.ValueKind == JsonValueKind.String ? property.Value.GetString() : null;
+
+            if (value == null)
+                continue;
+
+            foreach (var part in value.Split(',', StringSplitOptions.TrimEntries)) {
+                var separator = part.IndexOf('=');
+
+                if (separator <= 0)
+                    continue;
+
+                var mac = ArpTableParser.NormaliseMac(part[(separator + 1)..]);
+
+                if (mac != null)
+                    macs.Add(mac);
+            }
+        }
+
+        return macs.Distinct().ToList();
+    }
+
+    private static bool IsNetSlot(string key) =>
+        key.StartsWith("net", StringComparison.OrdinalIgnoreCase)
+        && key.Length > 3
+        && key[3..].All(char.IsAsciiDigit);
+
+    /// <summary>
+    ///     Every disk attached to a guest. The guest list only carries <c>maxdisk</c>,
+    ///     which is the boot disk alone — a VM with a small root and a large data volume
+    ///     would otherwise be recorded at a fraction of its real size.
+    /// </summary>
+    public static List<long> ParseDiskSizes(JsonElement config) {
+        var sizes = new List<long>();
+
+        foreach (JsonProperty property in config.EnumerateObject()) {
+            if (!IsDiskSlot(property.Name))
+                continue;
+
+            var value = property.Value.ValueKind == JsonValueKind.String ? property.Value.GetString() : null;
+
+            if (value == null || value.Contains("media=cdrom", StringComparison.OrdinalIgnoreCase))
+                continue;
+
+            var size = ParseSize(value);
+
+            if (size > 0)
+                sizes.Add(size);
+        }
+
+        return sizes;
+    }
+
+    /// <summary>
+    ///     Disk-bearing config keys. <c>unusedN</c> is excluded because it is a detached
+    ///     volume with no size, and the EFI and TPM state volumes because they are
+    ///     firmware scratch space of a few megabytes rather than storage anyone inventories.
+    /// </summary>
+    private static bool IsDiskSlot(string key) {
+        string[] prefixes = ["scsi", "virtio", "sata", "ide", "mp"];
+
+        if (key.Equals("rootfs", StringComparison.OrdinalIgnoreCase))
+            return true;
+
+        return prefixes.Any(p =>
+            key.StartsWith(p, StringComparison.OrdinalIgnoreCase)
+            && key.Length > p.Length
+            && key[p.Length..].All(char.IsAsciiDigit));
+    }
+
+    /// <summary>
+    ///     Reads <c>size=64G</c> out of a volume definition, in bytes. Proxmox permits a
+    ///     fractional number (<c>size=4.5G</c>, after an odd resize) and a bare number,
+    ///     which is bytes.
+    /// </summary>
+    public static long ParseSize(string volume) {
+        foreach (var part in volume.Split(',', StringSplitOptions.TrimEntries)) {
+            if (!part.StartsWith("size=", StringComparison.OrdinalIgnoreCase))
+                continue;
+
+            var raw = part[5..].Trim();
+
+            if (raw.Length == 0)
+                return 0;
+
+            var multiplier = char.ToUpperInvariant(raw[^1]) switch {
+                'K' => 1024L,
+                'M' => 1024L * 1024,
+                'G' => 1024L * 1024 * 1024,
+                'T' => 1024L * 1024 * 1024 * 1024,
+                _ => 0L
+            };
+
+            if (multiplier == 0)
+                return long.TryParse(raw, out var bytes) && bytes > 0 ? bytes : 0;
+
+            return double.TryParse(
+                       raw[..^1],
+                       System.Globalization.NumberStyles.Float,
+                       System.Globalization.CultureInfo.InvariantCulture,
+                       out var value)
+                   && value > 0
+                ? (long)Math.Round(value * multiplier)
+                : 0;
+        }
+
+        return 0;
+    }
+
+    /// <summary>
+    ///     Proxmox stores an ostype code. The container ones name a real distribution and
+    ///     are worth having; the QEMU ones are coarse by nature — <c>l26</c> means any
+    ///     Linux since 2.6 — so they stay vague rather than pretending to precision.
+    /// </summary>
+    internal static string? DescribeOs(string? ostype) {
+        if (string.IsNullOrWhiteSpace(ostype))
+            return null;
+
+        return ostype.ToLowerInvariant() switch {
+            "l24" => "Linux",
+            "l26" => "Linux",
+            "solaris" => "Solaris",
+            "wxp" => "Windows XP",
+            "w2k" => "Windows 2000",
+            "w2k3" => "Windows Server 2003",
+            "w2k8" => "Windows Server 2008",
+            "wvista" => "Windows Vista",
+            "win7" => "Windows 7",
+            "win8" => "Windows 8",
+            "win10" => "Windows 10",
+            "win11" => "Windows 11",
+            "other" => null,
+            "unmanaged" => null,
+            // Container templates are named after the distribution itself.
+            var distribution => char.ToUpperInvariant(distribution[0]) + distribution[1..]
+        };
+    }
+
+    /// <summary>
+    ///     Pulls the address out of a net interface line such as
+    ///     <c>name=eth0,bridge=vmbr0,ip=192.168.1.53/24</c>. Returns null for
+    ///     <c>ip=dhcp</c> and <c>ip=manual</c>, where the config knows no more than we do.
+    /// </summary>
+    internal static string? ParseStaticIp(string? net) {
+        if (string.IsNullOrWhiteSpace(net))
+            return null;
+
+        foreach (var part in net.Split(',', StringSplitOptions.TrimEntries)) {
+            if (!part.StartsWith("ip=", StringComparison.OrdinalIgnoreCase))
+                continue;
+
+            var value = part[3..].Split('/')[0].Trim();
+
+            return value.Equals("dhcp", StringComparison.OrdinalIgnoreCase)
+                   || value.Equals("manual", StringComparison.OrdinalIgnoreCase)
+                   || value.Length == 0
+                ? null
+                : value;
+        }
+
+        return null;
+    }
+
+    private static ProxmoxGuest? ParseGuest(JsonElement element, string node, string type) {
+        var vmid = GetInt(element, "vmid");
+
+        if (vmid == null)
+            return null;
+
+        return new ProxmoxGuest {
+            VmId = vmid.Value,
+            Node = node,
+            Name = GetString(element, "name") ?? string.Empty,
+            Type = type,
+            Cores = GetInt(element, "cpus") ?? 0,
+            MemoryBytes = GetLong(element, "maxmem") ?? 0,
+            DiskBytes = GetLong(element, "maxdisk") ?? 0,
+            Status = GetString(element, "status"),
+            Tags = ParseTags(GetString(element, "tags"))
+        };
+    }
+
+    /// <summary>Proxmox joins guest tags with semicolons.</summary>
+    private static List<string> ParseTags(string? tags) {
+        if (string.IsNullOrWhiteSpace(tags))
+            return [];
+
+        return tags.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
+            .Select(DiscoveryNaming.Slug)
+            .Where(t => t.Length > 0)
+            .Distinct(StringComparer.OrdinalIgnoreCase)
+            .ToList();
+    }
+
+    private static IEnumerable<JsonElement> Data(string json) {
+        using var document = JsonDocument.Parse(json);
+
+        if (!document.RootElement.TryGetProperty("data", out JsonElement data)
+            || data.ValueKind != JsonValueKind.Array)
+            return [];
+
+        return data.EnumerateArray().Select(e => e.Clone()).ToList();
+    }
+
+    private static string? GetString(JsonElement element, string name) =>
+        element.TryGetProperty(name, out JsonElement value) && value.ValueKind == JsonValueKind.String
+            ? value.GetString()
+            : 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;
+
+        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,
+    IReadOnlyList<long> DiskBytes,
+    IReadOnlyList<string> PassthroughAddresses,
+    IReadOnlyList<string>? Macs = null);

+ 253 - 0
RackPeek.Domain/Discovery/ProxmoxResourceMapper.cs

@@ -0,0 +1,253 @@
+using RackPeek.Domain.Resources;
+using RackPeek.Domain.Resources.Servers;
+using RackPeek.Domain.Resources.SubResources;
+using RackPeek.Domain.Resources.SystemResources;
+
+namespace RackPeek.Domain.Discovery;
+
+/// <summary>
+///     Maps a Proxmox estate onto RackPeek's three levels. Pure.
+///     <para>
+///         A node becomes two resources, because it is two things: the machine
+///         (<c>kepler</c>, a Server carrying the CPU, memory and disks) and the
+///         hypervisor installed on it (<c>kepler-pve</c>, a System). Guests then run on
+///         the hypervisor, giving Hardware -> System -> System all the way down.
+///     </para>
+/// </summary>
+public static class ProxmoxResourceMapper {
+    public const string Scheme = "pve";
+
+    /// <summary>Label recording which cards a guest has exclusive use of.</summary>
+    public const string GpuLabel = "gpu";
+
+    /// <summary>The cap RackPeek's own validation puts on a label value.</summary>
+    private const int _maxLabelLength = Helpers.ThrowIfInvalid.MaxLabelValueLength;
+
+    public static List<Resource> ToResources(
+        string scope,
+        IReadOnlyList<ProxmoxNode> nodes,
+        IReadOnlyList<ProxmoxGuest> guests) {
+        var taken = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
+        var resources = new List<Resource>();
+
+        // Nodes first, so a guest named after its node does not take the node's name.
+        var hypervisorNames = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
+
+        // PCI addresses repeat on every machine, so a guest can only be matched against
+        // the cards in the node it actually runs on.
+        var gpusByNode = nodes.ToDictionary(
+            n => n.Name,
+            n => n.Gpus,
+            StringComparer.OrdinalIgnoreCase);
+
+        foreach (ProxmoxNode node in nodes) {
+            Server server = ToServer(node, scope, taken);
+            SystemResource hypervisor = ToHypervisor(node, scope, server.Name, taken);
+
+            hypervisorNames[node.Name] = hypervisor.Name;
+            resources.Add(server);
+            resources.Add(hypervisor);
+        }
+
+        // A vmid is unique within the scope, so two entries carrying the same one are
+        // the same guest — which is what a migration in flight looks like, reported by
+        // both the node it is leaving and the node it is joining. Without this they
+        // would come out as two resources sharing an identity.
+        resources.AddRange(guests
+            .DistinctBy(g => g.VmId)
+            .Select(g => ToResource(g, scope, hypervisorNames, gpusByNode, taken)));
+
+        return resources;
+    }
+
+    /// <summary>The machine itself. Takes the node's own name, being the thing people point at.</summary>
+    private static Server ToServer(ProxmoxNode node, string scope, ISet<string> taken) {
+        var discoveryId = DiscoveryId.Create(Scheme, $"{scope}/node/{node.Name}");
+
+        return new Server {
+            Kind = Server.KindLabel,
+            Name = DiscoveryNaming.Unique(
+                DiscoveryNaming.Suggest(node.Name, "server", discoveryId),
+                discoveryId,
+                taken),
+            DiscoveryId = discoveryId,
+            Ram = ToGb(node.MemoryBytes) is { } ram ? new Ram { Size = ram } : null,
+            Cpus = ToCpus(node),
+            Drives = ToDrives(node.Disks),
+
+            // A GPU passed through to a guest is still bolted into this machine, so it
+            // is recorded here rather than on whatever borrows it. VRAM is not something
+            // the PCI list knows, so it is left off.
+            Gpus = node.Gpus.Count == 0 ? null : node.Gpus.Select(g => new Gpu { Model = g.Model }).ToList()
+        };
+    }
+
+    /// <summary>The Proxmox install running on the machine, which is what guests run on.</summary>
+    private static SystemResource ToHypervisor(
+        ProxmoxNode node,
+        string scope,
+        string serverName,
+        ISet<string> taken) {
+        var discoveryId = DiscoveryId.Create(Scheme, $"{scope}/node/{node.Name}/pve");
+
+        return new SystemResource {
+            Kind = SystemResource.KindLabel,
+            Name = DiscoveryNaming.Unique(
+                DiscoveryNaming.Suggest($"{node.Name}-pve", "hypervisor", discoveryId),
+                discoveryId,
+                taken),
+            DiscoveryId = discoveryId,
+            Type = "hypervisor",
+            Os = DescribeVersion(node.Version),
+            Cores = node.Cores > 0 ? node.Cores : null,
+            Ram = ToGb(node.MemoryBytes),
+            RunsOn = [serverName]
+        };
+    }
+
+    /// <summary>
+    ///     One entry per socket. Proxmox reports totals across the machine, so they are
+    ///     divided down — two sockets of an 8-core part read as 2 x 8, not 1 x 16.
+    /// </summary>
+    private static List<Cpu>? ToCpus(ProxmoxNode node) {
+        if (string.IsNullOrWhiteSpace(node.CpuModel))
+            return null;
+
+        var sockets = Math.Max(1, node.Sockets);
+
+        var cpu = new Cpu {
+            Model = node.CpuModel,
+            Cores = node.PhysicalCores > 0 ? node.PhysicalCores / sockets : null,
+            Threads = node.Cores > 0 ? node.Cores / sockets : null
+        };
+
+        return Enumerable.Range(0, sockets).Select(_ => cpu).ToList();
+    }
+
+    private static List<Drive>? ToDrives(IReadOnlyList<ProxmoxDisk> disks) {
+        if (disks.Count == 0)
+            return null;
+
+        return disks
+            .Select(d => new Drive {
+                Type = string.IsNullOrEmpty(d.Type) ? null : d.Type,
+                Size = (int)DiscoveryUnits.BytesToWholeGb(d.SizeBytes)
+            })
+            .ToList();
+    }
+
+    private static SystemResource ToResource(
+        ProxmoxGuest guest,
+        string scope,
+        IReadOnlyDictionary<string, string> hypervisorNames,
+        IReadOnlyDictionary<string, IReadOnlyList<ProxmoxGpu>> gpusByNode,
+        ISet<string> taken) {
+        // The vmid is unique within the cluster and survives a rename or a migration
+        // between nodes, which is exactly what an identity needs to do.
+        var discoveryId = DiscoveryId.Create(Scheme, $"{scope}/{guest.VmId}");
+
+        Dictionary<string, string> labels = PassthroughLabels(guest, gpusByNode);
+
+        // The bridge to network discovery: a scan identifies this guest by one of
+        // these, so carrying them lets the resolver land both collectors on one card.
+        if (guest.Macs.Count > 0)
+            labels["macs"] = string.Join(",", guest.Macs);
+
+        return new SystemResource {
+            Kind = SystemResource.KindLabel,
+            Name = DiscoveryNaming.Unique(
+                DiscoveryNaming.Suggest(FallbackName(guest), "system", discoveryId),
+                discoveryId,
+                taken),
+            DiscoveryId = discoveryId,
+            Type = guest.Type,
+            Os = guest.Os,
+            Cores = guest.Cores > 0 ? guest.Cores : null,
+            Ram = ToGb(guest.MemoryBytes),
+            Ip = guest.Ip,
+            Drives = ToGuestDrives(guest),
+            Tags = guest.Tags.ToArray(),
+            Labels = labels,
+            RunsOn = hypervisorNames.TryGetValue(guest.Node, out var hypervisor) ? [hypervisor] : []
+        };
+    }
+
+    /// <summary>
+    ///     Every disk the config lists, falling back to the boot disk from the guest list
+    ///     when the config could not be read. The storage backend says nothing about the
+    ///     underlying medium, so the type is left off rather than guessed.
+    /// </summary>
+    private static List<Drive>? ToGuestDrives(ProxmoxGuest guest) {
+        IReadOnlyList<long> sizes = guest.Disks.Count > 0
+            ? guest.Disks
+            : guest.DiskBytes > 0
+                ? [guest.DiskBytes]
+                : [];
+
+        if (sizes.Count == 0)
+            return null;
+
+        return sizes
+            .Select(bytes => new Drive { Size = (int)DiscoveryUnits.BytesToWholeGb(bytes) })
+            .ToList();
+    }
+
+    /// <summary>
+    ///     Records the cards a guest holds. The GPU itself stays on the Server, because
+    ///     that is where it is physically installed; this is the assignment, which
+    ///     RackPeek has no first-class way to express.
+    /// </summary>
+    private static Dictionary<string, string> PassthroughLabels(
+        ProxmoxGuest guest,
+        IReadOnlyDictionary<string, IReadOnlyList<ProxmoxGpu>> gpusByNode) {
+        var labels = new Dictionary<string, string>();
+
+        if (guest.PassthroughAddresses.Count == 0
+            || !gpusByNode.TryGetValue(guest.Node, out IReadOnlyList<ProxmoxGpu>? gpus))
+            return labels;
+
+        var held = guest.PassthroughAddresses
+            .Select(address => gpus.FirstOrDefault(g => AddressesMatch(g.Address, address)))
+            .OfType<ProxmoxGpu>()
+            .Select(g => g.Model)
+            .ToList();
+
+        if (held.Count == 0)
+            return labels;
+
+        var value = string.Join(", ", held);
+
+        labels[GpuLabel] = value.Length <= _maxLabelLength
+            ? value
+            : value[.._maxLabelLength].TrimEnd(',', ' ');
+
+        return labels;
+    }
+
+    /// <summary>
+    ///     The device list gives a function suffix (<c>0000:01:00.0</c>) that a guest
+    ///     config usually leaves off (<c>0000:01:00</c>), so either may be the longer.
+    /// </summary>
+    private static bool AddressesMatch(string deviceAddress, string configured) =>
+        deviceAddress.StartsWith(configured, StringComparison.OrdinalIgnoreCase)
+        || configured.StartsWith(deviceAddress, StringComparison.OrdinalIgnoreCase);
+
+    /// <summary>A guest that was never named still needs one; the vmid is what people call it.</summary>
+    private static string FallbackName(ProxmoxGuest guest) =>
+        string.IsNullOrWhiteSpace(guest.Name)
+            ? $"{(guest.Type == ProxmoxResponseParser.ContainerType ? "ct" : "vm")}-{guest.VmId}"
+            : guest.Name;
+
+    /// <summary><c>pve-manager/8.2.2/9355359c</c> becomes <c>Proxmox VE 8.2.2</c>.</summary>
+    internal static string? DescribeVersion(string? pveVersion) {
+        if (string.IsNullOrWhiteSpace(pveVersion))
+            return null;
+
+        var parts = pveVersion.Split('/');
+
+        return parts.Length >= 2 ? $"Proxmox VE {parts[1]}" : pveVersion;
+    }
+
+    private static double? ToGb(long bytes) =>
+        bytes > 0 ? DiscoveryUnits.BytesToWholeGb(bytes) : null;
+}

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

+ 81 - 0
RackPeek.Domain/Discovery/SystemFacts.cs

@@ -0,0 +1,81 @@
+namespace RackPeek.Domain.Discovery;
+
+/// <summary>
+///     The one place discovery turns bytes into the whole gigabytes RackPeek stores,
+///     so every collector reports the same size for the same hardware.
+/// </summary>
+public static class DiscoveryUnits {
+    private const double _bytesPerGb = 1024d * 1024 * 1024;
+
+    /// <summary>Rounded, floored at 1 — a real device is never zero gigabytes.</summary>
+    public static double BytesToWholeGb(long bytes) => Math.Max(1, Math.Round(bytes / _bytesPerGb));
+}
+
+/// <summary>A physical or virtual disk as reported by the host.</summary>
+public sealed record BlockDeviceFact(string Name, long SizeBytes, bool Rotational);
+
+/// <summary>A network interface, reduced to the parts that pick a primary address.</summary>
+public sealed record NicFact(
+    string Name,
+    bool IsUp,
+    bool IsLoopback,
+    bool HasGateway,
+    string? Ipv4,
+    string? Mac = null);
+
+/// <summary>
+///     Everything a probe managed to read off the host, still in its raw form.
+///     Kept deliberately dumb: probes do IO and nothing else, so that every decision
+///     made about this data lives in <see cref="SystemFactsParser" /> and is testable
+///     on any platform from a captured fixture.
+/// </summary>
+public sealed record RawSystemSnapshot {
+    public string Hostname { get; init; } = string.Empty;
+    public int Cores { get; init; }
+    public IReadOnlyList<NicFact> Nics { get; init; } = [];
+    public IReadOnlyList<BlockDeviceFact> BlockDevices { get; init; } = [];
+
+    /// <summary>Fallback when the platform-specific read fails; always populated.</summary>
+    public long FallbackMemoryBytes { get; init; }
+
+    // Linux
+    public string? OsReleaseFile { get; init; }
+    public string? MemInfoFile { get; init; }
+    public string? MachineIdFile { get; init; }
+    public string? CgroupFile { get; init; }
+    public bool DockerEnvPresent { get; init; }
+    public string? DmiVendor { get; init; }
+    public string? DmiProduct { get; init; }
+
+    // macOS
+    public string? OsName { get; init; }
+    public long? MemoryBytes { get; init; }
+    public string? PlatformUuid { get; init; }
+    public bool HypervisorPresent { get; init; }
+}
+
+/// <summary>The host, once the raw snapshot has been interpreted.</summary>
+public sealed record SystemFacts {
+    public required string Hostname { get; init; }
+
+    /// <summary>Seed for the discovery id. Null when the host offers nothing stable.</summary>
+    public string? MachineId { get; init; }
+
+    public required string Os { get; init; }
+    public required int Cores { get; init; }
+    public required double RamGb { get; init; }
+
+    /// <summary>One of <see cref="Resources.SystemResources.SystemResource.ValidSystemTypes" />.</summary>
+    public required string Type { get; init; }
+
+    public string? Ip { get; init; }
+    public IReadOnlyList<DriveFact> Drives { get; init; } = [];
+
+    /// <summary>
+    ///     The machine's physical-NIC MACs, normalised. What lets an agent-discovered
+    ///     card and a network-scanned card of the same box find each other.
+    /// </summary>
+    public IReadOnlyList<string> Macs { get; init; } = [];
+}
+
+public sealed record DriveFact(string Type, int SizeGb);

+ 182 - 0
RackPeek.Domain/Discovery/SystemFactsParser.cs

@@ -0,0 +1,182 @@
+
+namespace RackPeek.Domain.Discovery;
+
+/// <summary>
+///     Turns a <see cref="RawSystemSnapshot" /> into <see cref="SystemFacts" />.
+///     Pure: no IO, no platform checks, so it runs and is tested identically everywhere.
+/// </summary>
+public static class SystemFactsParser {
+    /// <summary>Strings that appear in DMI when the host is a guest rather than real hardware.</summary>
+    private static readonly string[] _virtualMachineMarkers =
+    [
+        "qemu", "kvm", "vmware", "virtualbox", "innotek", "xen", "bochs",
+        "bhyve", "parallels", "hyper-v", "virtual machine", "openstack"
+    ];
+
+    /// <summary>Kernel-managed devices that are not disks anyone wants in an inventory.</summary>
+    private static readonly string[] _ignoredBlockDevicePrefixes =
+        ["loop", "ram", "zram", "sr", "dm-", "fd", "md"];
+
+    public static SystemFacts Parse(RawSystemSnapshot raw) {
+        var type = ParseType(raw);
+
+        return new SystemFacts {
+            Hostname = raw.Hostname,
+            MachineId = ParseMachineId(raw),
+            Os = ParseOs(raw),
+            Cores = raw.Cores > 0 ? raw.Cores : 1,
+            RamGb = ParseRamGb(raw),
+            Type = type,
+            Ip = SelectPrimaryIp(raw.Nics),
+            Macs = SelectMacs(raw.Nics),
+
+            // A container sees the host's block devices through /sys/block. They belong
+            // to the machine underneath it, so reporting them here would attribute
+            // someone else's disks to this resource.
+            Drives = type == "container" ? [] : ParseDrives(raw.BlockDevices)
+        };
+    }
+
+    internal static string? ParseMachineId(RawSystemSnapshot raw) {
+        var id = Clean(raw.PlatformUuid) ?? Clean(raw.MachineIdFile);
+
+        return string.IsNullOrEmpty(id) ? null : id;
+    }
+
+    internal static string ParseOs(RawSystemSnapshot raw) {
+        var name = Clean(raw.OsName);
+        if (!string.IsNullOrEmpty(name))
+            return name;
+
+        var pretty = ReadKeyValue(raw.OsReleaseFile, "PRETTY_NAME");
+        if (!string.IsNullOrEmpty(pretty))
+            return pretty;
+
+        var id = ReadKeyValue(raw.OsReleaseFile, "NAME");
+        var version = ReadKeyValue(raw.OsReleaseFile, "VERSION");
+
+        if (!string.IsNullOrEmpty(id))
+            return string.IsNullOrEmpty(version) ? id : $"{id} {version}";
+
+        return "Unknown";
+    }
+
+    internal static double ParseRamGb(RawSystemSnapshot raw) {
+        if (raw.MemoryBytes is > 0)
+            return DiscoveryUnits.BytesToWholeGb(raw.MemoryBytes.Value);
+
+        // MemTotal is in kB, and is a little under the physical total because the
+        // kernel reserves some. Reported as-is rather than rounded up to a DIMM size.
+        var memTotal = ReadKeyValue(raw.MemInfoFile, "MemTotal", ':');
+
+        if (!string.IsNullOrEmpty(memTotal)) {
+            var digits = new string(memTotal.TakeWhile(char.IsAsciiDigit).ToArray());
+
+            if (long.TryParse(digits, out var kb) && kb > 0)
+                return DiscoveryUnits.BytesToWholeGb(kb * 1024L);
+        }
+
+        return DiscoveryUnits.BytesToWholeGb(raw.FallbackMemoryBytes);
+    }
+
+    internal static string ParseType(RawSystemSnapshot raw) {
+        if (raw.DockerEnvPresent || ContainsContainerMarker(raw.CgroupFile))
+            return "container";
+
+        if (raw.HypervisorPresent)
+            return "vm";
+
+        var dmi = $"{raw.DmiVendor} {raw.DmiProduct}".ToLowerInvariant();
+
+        if (_virtualMachineMarkers.Any(marker => dmi.Contains(marker, StringComparison.Ordinal)))
+            return "vm";
+
+        return "baremetal";
+    }
+
+    internal static string? SelectPrimaryIp(IReadOnlyList<NicFact> nics) {
+        var usable = nics
+            .Where(n => n is { IsUp: true, IsLoopback: false } && !string.IsNullOrWhiteSpace(n.Ipv4))
+            .ToList();
+
+        // An interface holding the default route is the address other machines reach
+        // this host on. Otherwise prefer anything that is not an obvious virtual bridge.
+        return usable.FirstOrDefault(n => n.HasGateway)?.Ipv4
+               ?? usable.FirstOrDefault(n => !IsVirtual(n.Name))?.Ipv4
+               ?? usable.FirstOrDefault()?.Ipv4;
+    }
+
+    /// <summary>
+    ///     The MACs a network scan could see this machine by: real interfaces only — a
+    ///     docker bridge's MAC never crosses the wire, so recording it could only cause
+    ///     a false unification. Normalised by the same code that reads ARP tables, so
+    ///     both sides of the bridge always agree on the spelling.
+    /// </summary>
+    internal static List<string> SelectMacs(IReadOnlyList<NicFact> nics) {
+        return nics
+            .Where(n => n is { IsUp: true, IsLoopback: false } && !IsVirtual(n.Name))
+            .Select(n => ArpTableParser.NormaliseMac(n.Mac))
+            .Where(mac => mac != null)
+            .Select(mac => mac!)
+            .Distinct()
+            .ToList();
+    }
+
+    internal static bool IsVirtual(string name) {
+        string[] prefixes = ["docker", "br-", "veth", "virbr", "tailscale", "utun", "tun", "tap", "cni", "flannel"];
+
+        return prefixes.Any(p => name.StartsWith(p, StringComparison.OrdinalIgnoreCase));
+    }
+
+    internal static List<DriveFact> ParseDrives(IReadOnlyList<BlockDeviceFact> devices) {
+        return devices
+            .Where(d => !_ignoredBlockDevicePrefixes.Any(p =>
+                d.Name.StartsWith(p, StringComparison.OrdinalIgnoreCase)))
+            .Where(d => d.SizeBytes > 0)
+            .Select(d => new DriveFact(DriveType(d), (int)DiscoveryUnits.BytesToWholeGb(d.SizeBytes)))
+            .ToList();
+    }
+
+    private static string DriveType(BlockDeviceFact device) {
+        if (device.Name.StartsWith("nvme", StringComparison.OrdinalIgnoreCase))
+            return "nvme";
+
+        if (device.Name.StartsWith("mmcblk", StringComparison.OrdinalIgnoreCase))
+            return "sdcard";
+
+        return device.Rotational ? "hdd" : "ssd";
+    }
+
+    private static bool ContainsContainerMarker(string? cgroup) {
+        if (string.IsNullOrWhiteSpace(cgroup))
+            return false;
+
+        string[] markers = ["docker", "lxc", "kubepods", "containerd", "podman"];
+
+        return markers.Any(m => cgroup.Contains(m, StringComparison.OrdinalIgnoreCase));
+    }
+
+    /// <summary>Reads one entry out of a key=value file such as /etc/os-release.</summary>
+    private static string? ReadKeyValue(string? contents, string key, char separator = '=') {
+        if (string.IsNullOrWhiteSpace(contents))
+            return null;
+
+        foreach (var rawLine in contents.Split('\n')) {
+            var line = rawLine.Trim();
+            var index = line.IndexOf(separator);
+
+            if (index <= 0)
+                continue;
+
+            if (!line[..index].Trim().Equals(key, StringComparison.OrdinalIgnoreCase))
+                continue;
+
+            return line[(index + 1)..].Trim().Trim('"').Trim();
+        }
+
+        return null;
+    }
+
+    private static string? Clean(string? value) =>
+        string.IsNullOrWhiteSpace(value) ? null : value.Trim();
+}

+ 133 - 0
RackPeek.Domain/Discovery/SystemProbeCommon.cs

@@ -0,0 +1,133 @@
+using System.Diagnostics;
+using System.Net;
+using System.Net.NetworkInformation;
+using System.Net.Sockets;
+
+namespace RackPeek.Domain.Discovery;
+
+/// <summary>Host reads that the BCL already does the same way on every platform.</summary>
+internal static class SystemProbeCommon {
+    public static string Hostname() {
+        try {
+            return Dns.GetHostName();
+        }
+        catch {
+            return Environment.MachineName;
+        }
+    }
+
+    public static int Cores() => Environment.ProcessorCount;
+
+    public static long FallbackMemoryBytes() => GC.GetGCMemoryInfo().TotalAvailableMemoryBytes;
+
+    public static IReadOnlyList<NicFact> Nics() {
+        try {
+            return NetworkInterface.GetAllNetworkInterfaces()
+                .Select(ToFact)
+                .Where(n => n.Ipv4 != null)
+                .ToList();
+        }
+        catch {
+            return [];
+        }
+    }
+
+    private static NicFact ToFact(NetworkInterface nic) {
+        IPInterfaceProperties properties = nic.GetIPProperties();
+
+        var ipv4 = properties.UnicastAddresses
+            .FirstOrDefault(a => a.Address.AddressFamily == AddressFamily.InterNetwork)
+            ?.Address.ToString();
+
+        var hasGateway = properties.GatewayAddresses
+            .Any(g => g.Address.AddressFamily == AddressFamily.InterNetwork
+                      && !g.Address.Equals(IPAddress.Any));
+
+        return new NicFact(
+            nic.Name,
+            nic.OperationalStatus == OperationalStatus.Up,
+            nic.NetworkInterfaceType == NetworkInterfaceType.Loopback,
+            hasGateway,
+            ipv4,
+            FormatMac(nic));
+    }
+
+    /// <summary>Lowercase colon-separated, matching what ARP tables report — or null.</summary>
+    private static string? FormatMac(NetworkInterface nic) {
+        try {
+            var bytes = nic.GetPhysicalAddress().GetAddressBytes();
+
+            return bytes.Length == 6
+                ? string.Join(':', bytes.Select(b => b.ToString("x2")))
+                : null;
+        }
+        catch {
+            return null;
+        }
+    }
+
+    /// <summary>Reads a file, returning null for anything unreadable rather than throwing.</summary>
+    public static async Task<string?> TryReadFileAsync(string path, CancellationToken cancellationToken) {
+        try {
+            return File.Exists(path)
+                ? await File.ReadAllTextAsync(path, cancellationToken)
+                : null;
+        }
+        catch {
+            return null;
+        }
+    }
+
+    /// <summary>
+    ///     Runs a command and returns stdout, or null if it cannot be run, fails, or
+    ///     takes longer than a few seconds — a hung probe must not hang a timer-driven
+    ///     <c>rpk discover</c> forever. Stderr is drained concurrently so a chatty child
+    ///     cannot deadlock on a full pipe.
+    /// </summary>
+    public static async Task<string?> TryRunAsync(
+        string fileName,
+        string arguments,
+        CancellationToken cancellationToken) {
+        try {
+            using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+            timeout.CancelAfter(TimeSpan.FromSeconds(10));
+
+            using var process = new Process {
+                StartInfo = new ProcessStartInfo {
+                    FileName = fileName,
+                    Arguments = arguments,
+                    RedirectStandardOutput = true,
+                    RedirectStandardError = true,
+                    UseShellExecute = false,
+                    CreateNoWindow = true
+                }
+            };
+
+            if (!process.Start())
+                return null;
+
+            try {
+                Task<string> stdout = process.StandardOutput.ReadToEndAsync(timeout.Token);
+                Task<string> stderr = process.StandardError.ReadToEndAsync(timeout.Token);
+
+                await process.WaitForExitAsync(timeout.Token);
+                await stderr;
+
+                return process.ExitCode == 0 ? (await stdout).Trim() : null;
+            }
+            catch (OperationCanceledException) {
+                try {
+                    process.Kill(true);
+                }
+                catch {
+                    // It may have exited in the meantime; nothing left to do.
+                }
+
+                return null;
+            }
+        }
+        catch {
+            return null;
+        }
+    }
+}

+ 42 - 0
RackPeek.Domain/Discovery/SystemResourceMapper.cs

@@ -0,0 +1,42 @@
+using RackPeek.Domain.Resources.SubResources;
+using RackPeek.Domain.Resources.SystemResources;
+
+namespace RackPeek.Domain.Discovery;
+
+/// <summary>Maps host facts onto the System resource RackPeek stores. Pure.</summary>
+public static class SystemResourceMapper {
+    /// <summary>
+    ///     <paramref name="nameOverride" /> is what makes repeated runs on a box stable
+    ///     regardless of hostname changes, and is the recommended way to run this from a
+    ///     timer. Without it the hostname is used.
+    /// </summary>
+    public static SystemResource ToResource(SystemFacts facts, string? nameOverride = null) {
+        var discoveryId = DiscoveryId.Create(
+            DiscoveryId.SystemScheme,
+            facts.MachineId ?? facts.Hostname);
+
+        var resource = new SystemResource {
+            Kind = SystemResource.KindLabel,
+            Name = DiscoveryNaming.Suggest(
+                nameOverride ?? DiscoveryNaming.HostLabel(facts.Hostname),
+                "system",
+                discoveryId),
+            DiscoveryId = discoveryId,
+            Type = facts.Type,
+            Os = facts.Os,
+            Cores = facts.Cores,
+            Ram = facts.RamGb,
+            Ip = facts.Ip,
+            Drives = facts.Drives.Count == 0
+                ? null
+                : facts.Drives.Select(d => new Drive { Type = d.Type, Size = d.SizeGb }).ToList()
+        };
+
+        // The bridge to network discovery: a scan identifies this machine by one of
+        // these, so carrying them lets the resolver land both collectors on one card.
+        if (facts.Macs.Count > 0)
+            resource.Labels["macs"] = string.Join(",", facts.Macs);
+
+        return resource;
+    }
+}

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

@@ -0,0 +1,97 @@
+namespace RackPeek.Domain.Discovery;
+
+/// <summary>
+///     The TCP ports the sweep knocks on when a host ignores ping. Chosen for what a
+///     homelab actually runs — one open port anywhere in this list is enough to call
+///     the host alive, so breadth matters more than depth.
+/// </summary>
+public static class WellKnownPorts {
+    public static readonly IReadOnlyList<int> Defaults = [
+        22, // ssh — almost everything
+        80, // http
+        443, // https
+        53, // dns — pi-hole, routers
+        445, // smb — nas boxes
+        3389, // rdp — windows
+        631, // ipp — printers
+        8006, // proxmox
+        5000, // synology / registries
+        8080, // alt http
+        8443, // alt https
+        9100 // node-exporter / jetdirect
+    ];
+
+    /// <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}";
+}

+ 18 - 0
RackPeek.Domain/Helpers/ConfigLoadException.cs

@@ -0,0 +1,18 @@
+namespace RackPeek.Domain.Helpers;
+
+/// <summary>
+///     The config file exists but cannot be read as a RackPeek document — damaged,
+///     truncated, or not YAML. Distinct from an unreadable store (IO errors), which is
+///     tolerated at boot: a damaged file must fail loudly on every read and write so a
+///     partial or empty inventory is never served, and never persisted over the
+///     user's file (#337).
+/// </summary>
+public sealed class ConfigLoadException : Exception {
+    public ConfigLoadException(string message)
+        : base(message) {
+    }
+
+    public ConfigLoadException(string message, Exception innerException)
+        : base(message, innerException) {
+    }
+}

+ 16 - 0
RackPeek.Domain/Helpers/PortSummaries.cs

@@ -0,0 +1,16 @@
+using RackPeek.Domain.Resources.SubResources;
+
+namespace RackPeek.Domain.Helpers;
+
+public static class PortSummaries {
+    public static string Describe(List<Port>? ports) {
+        if (ports == null || ports.Count == 0)
+            return "None";
+
+        IEnumerable<string> groups = ports
+            .GroupBy(p => p.Type ?? "Unknown")
+            .Select(g => $"{g.Key}: {g.Sum(p => p.Count ?? 0)}");
+
+        return string.Join(", ", groups);
+    }
+}

+ 5 - 2
RackPeek.Domain/Helpers/ThrowIfInvalid.cs

@@ -5,10 +5,13 @@ using RackPeek.Domain.Resources.SystemResources;
 namespace RackPeek.Domain.Helpers;
 
 public static class ThrowIfInvalid {
+    public const int MaxResourceNameLength = 50;
+    public const int MaxLabelValueLength = 200;
+
     public static void ResourceName(string name) {
         if (string.IsNullOrWhiteSpace(name)) throw new ValidationException("Name is required.");
 
-        if (name.Length > 50) throw new ValidationException("Name is too long.");
+        if (name.Length > MaxResourceNameLength) throw new ValidationException("Name is too long.");
     }
 
     public static void LabelKey(string key) {
@@ -18,7 +21,7 @@ public static class ThrowIfInvalid {
 
     public static void LabelValue(string value) {
         if (string.IsNullOrWhiteSpace(value)) throw new ValidationException("Label value is required.");
-        if (value.Length > 200) throw new ValidationException("Label value is too long.");
+        if (value.Length > MaxLabelValueLength) throw new ValidationException("Label value is too long.");
     }
 
     public static void AccessPointModelName(string name) {

+ 1 - 0
RackPeek.Domain/Persistence/IResourceCollection.cs

@@ -33,6 +33,7 @@ public interface IResourceCollection {
     Task<IReadOnlyList<(Resource, string)>> GetResourceIpsAsync();
 
     Task<IReadOnlyList<T>> GetAllOfTypeAsync<T>();
+    Task<(IReadOnlyList<Resource> Resources, IReadOnlyList<Connection> Connections)> GetInventorySnapshotAsync();
     Task<IReadOnlyList<Resource>> GetDependantsAsync(string name);
 
     Task Merge(string incomingYaml, MergeMode mode);

+ 3 - 3
RackPeek.Domain/Persistence/ResourceCollectionMerger.cs

@@ -36,8 +36,8 @@ public static class ResourceCollectionMerger {
         IEnumerable<Resource> original,
         IEnumerable<Resource> incoming,
         MergeMode mode) {
-        List<Resource> originalClone = DeepCloneList(original);
-        List<Resource> incomingClone = DeepCloneList(incoming);
+        List<Resource> originalClone = DeepCloneResources(original);
+        List<Resource> incomingClone = DeepCloneResources(incoming);
 
         var result = originalClone.ToDictionary(r => r.Name, r => r, StringComparer.OrdinalIgnoreCase);
 
@@ -59,7 +59,7 @@ public static class ResourceCollectionMerger {
         return result.Values.ToList();
     }
 
-    private static List<Resource> DeepCloneList(IEnumerable<Resource> resources) {
+    public static List<Resource> DeepCloneResources(IEnumerable<Resource> resources) {
         var json = JsonSerializer.Serialize(resources, _cloneJsonOptions);
         return JsonSerializer.Deserialize<List<Resource>>(json, _cloneJsonOptions) ?? new List<Resource>();
     }

+ 49 - 1
RackPeek.Domain/Persistence/Yaml/ITextFileStore.cs

@@ -1,3 +1,5 @@
+using System.Text;
+
 namespace RackPeek.Domain.Persistence.Yaml;
 
 public interface ITextFileStore {
@@ -11,5 +13,51 @@ public sealed class PhysicalTextFileStore : ITextFileStore {
 
     public Task<string> ReadAllTextAsync(string path) => File.ReadAllTextAsync(path);
 
-    public Task WriteAllTextAsync(string path, string contents) => File.WriteAllTextAsync(path, contents);
+    /// <summary>
+    ///     Atomic and durable replacement for File.WriteAllTextAsync, which truncates
+    ///     the destination before writing and never flushes to disk — so a crash or
+    ///     power loss mid-save could leave a truncated config, and a save that had
+    ///     "succeeded" could still be lost (#337). The content is written to a
+    ///     temporary file in the same directory, flushed to disk, then moved over the
+    ///     destination — a rename, so readers only ever see the old or the new file,
+    ///     never a partial one.
+    /// </summary>
+    public async Task WriteAllTextAsync(string path, string contents) {
+        var fullPath = Path.GetFullPath(path);
+        var directory = Path.GetDirectoryName(fullPath)
+                        ?? throw new IOException($"'{path}' has no parent directory.");
+
+        var tempPath = Path.Combine(
+            directory,
+            $"{Path.GetFileName(fullPath)}.tmp-{Guid.NewGuid():N}");
+
+        try {
+            await using (var stream = new FileStream(
+                             tempPath,
+                             FileMode.CreateNew,
+                             FileAccess.Write,
+                             FileShare.None)) {
+                var bytes = Encoding.UTF8.GetBytes(contents);
+                await stream.WriteAsync(bytes);
+
+                // Flush through the OS cache to the disk itself, so the rename
+                // below never publishes a file whose bytes could still vanish.
+                stream.Flush(true);
+            }
+
+            File.Move(tempPath, fullPath, true);
+        }
+        catch {
+            // Never leave temp files behind on a failed write; the destination
+            // is untouched by construction.
+            try {
+                File.Delete(tempPath);
+            }
+            catch (IOException) {
+                // Best effort — the stray temp file is harmless.
+            }
+
+            throw;
+        }
+    }
 }

+ 55 - 5
RackPeek.Domain/Persistence/Yaml/RackPeekConfigMigrationDeserializer.cs

@@ -24,7 +24,9 @@ public class RackPeekConfigMigrationDeserializer : YamlMigrationDeserializer<Yam
         {
             EnsureSchemaVersionExists,
             ConvertScalarRunsOnToList,
-            ConvertNicsToPortsV3
+            ConvertNicsToPortsV3,
+            AllowDiscoveryIdsV4,
+            AddServiceEndpointsV5
         };
 
     public RackPeekConfigMigrationDeserializer(IServiceProvider serviceProvider,
@@ -95,9 +97,12 @@ public class RackPeekConfigMigrationDeserializer : YamlMigrationDeserializer<Yam
                     break;
 
                 case List<object> list:
-                    resourceDict[key] = list
-                        .OfType<string>()
-                        .ToList();
+                    if (list.Any(item => item is not string))
+                        throw new InvalidCastException(
+                            $"Cannot convert every item in '{key}' to a string for resource " +
+                            $"'{resourceDict.GetValueOrDefault("name", "unknown")}'.");
+
+                    resourceDict[key] = list.Cast<string>().ToList();
                     break;
 
                 case List<string>:
@@ -136,7 +141,17 @@ public class RackPeekConfigMigrationDeserializer : YamlMigrationDeserializer<Yam
 
             foreach (var nicObj in nics) {
                 if (nicObj is not Dictionary<object, object> nicDict)
-                    continue;
+                    throw new InvalidCastException(
+                        $"Cannot convert a non-object NIC to a port for resource " +
+                        $"'{resourceDict.GetValueOrDefault("name", "unknown")}'.");
+
+                var unsupportedKeys = nicDict.Keys
+                    .Where(key => key is not string text || text is not ("type" or "speed" or "ports"))
+                    .ToList();
+                if (unsupportedKeys.Count > 0)
+                    throw new InvalidCastException(
+                        $"Cannot migrate NIC with unsupported fields for resource " +
+                        $"'{resourceDict.GetValueOrDefault("name", "unknown")}'.");
 
                 var port = new Dictionary<object, object>();
 
@@ -167,5 +182,40 @@ public class RackPeekConfigMigrationDeserializer : YamlMigrationDeserializer<Yam
         return ValueTask.CompletedTask;
     }
 
+    /// <summary>
+    ///     v4 adds the optional <c>discoveryId</c> field for <c>rpk discover</c>. Purely
+    ///     additive, so a v3 document only needs its version stamped — but it still gets
+    ///     a version of its own so that a v3-era binary refuses a discovery-written file
+    ///     cleanly ("version 4 is newer than this application supports") instead of
+    ///     failing schema validation on a field it has never heard of.
+    /// </summary>
+    public static ValueTask AllowDiscoveryIdsV4(IServiceProvider serviceProvider, Dictionary<object, object> obj) {
+        obj["version"] = 4;
+
+        return ValueTask.CompletedTask;
+    }
+
+    public static ValueTask AddServiceEndpointsV5(IServiceProvider serviceProvider, Dictionary<object, object> obj) {
+        if (obj.TryGetValue("resources", out var resourcesObj) && resourcesObj is List<object> resources) {
+            foreach (var resourceObj in resources) {
+                if (resourceObj is not Dictionary<object, object> resource
+                    || !resource.TryGetValue("kind", out var kind)
+                    || !Equals(kind, Service.KindLabel)
+                    || !resource.TryGetValue("network", out var network)
+                    || network is not Dictionary<object, object> networkDict
+                    || resource.ContainsKey("endpoints"))
+                    continue;
+
+                resource["endpoints"] = new List<object> {
+                    new Dictionary<object, object>(networkDict)
+                };
+            }
+        }
+
+        obj["version"] = 5;
+
+        return ValueTask.CompletedTask;
+    }
+
     #endregion
 }

+ 188 - 32
RackPeek.Domain/Persistence/Yaml/YamlResourceCollection.cs

@@ -1,6 +1,8 @@
 using System.Collections.ObjectModel;
 using System.Collections.Specialized;
 using System.Diagnostics;
+using RackPeek.Domain.Discovery;
+using RackPeek.Domain.Helpers;
 using RackPeek.Domain.Resources;
 using RackPeek.Domain.Resources.AccessPoints;
 using RackPeek.Domain.Resources.Connections;
@@ -22,8 +24,43 @@ namespace RackPeek.Domain.Persistence.Yaml;
 
 public class ResourceCollection {
     public readonly SemaphoreSlim FileLock = new(1, 1);
-    public List<Resource> Resources { get; } = new();
-    public List<Connection> Connections { get; } = new();
+    private InventoryState _inventory = new(new List<Resource>(), new List<Connection>());
+
+    public List<Resource> Resources {
+        get => _inventory.Resources;
+        set => _inventory = _inventory with { Resources = value };
+    }
+
+    public List<Connection> Connections {
+        get => _inventory.Connections;
+        set => _inventory = _inventory with { Connections = value };
+    }
+
+    public (List<Resource> Resources, List<Connection> Connections) GetInventorySnapshot() {
+        InventoryState inventory = _inventory;
+        return (inventory.Resources, inventory.Connections);
+    }
+
+    public void ReplaceInventory(List<Resource> resources, List<Connection> connections) =>
+        _inventory = new InventoryState(resources, connections);
+
+    /// <summary>
+    ///     Whether the store has ever been read successfully. Guarded by
+    ///     <see cref="FileLock" />. Write paths check it so a boot that survived an
+    ///     unreadable config cannot later persist the empty in-memory collection over
+    ///     the user's file.
+    /// </summary>
+    public bool Loaded { get; set; }
+
+    /// <summary>
+    ///     Set when the config exists but could not be understood — damaged, truncated,
+    ///     or not YAML. The process is still allowed to boot (the web UI is how someone
+    ///     fixes the file), but every read and write must fail loudly rather than serve
+    ///     or persist an empty inventory (#337).
+    /// </summary>
+    public ConfigLoadException? LoadFailure { get; set; }
+
+    private sealed record InventoryState(List<Resource> Resources, List<Connection> Connections);
 }
 
 public sealed class YamlResourceCollection(
@@ -36,16 +73,19 @@ public sealed class YamlResourceCollection(
     private static readonly int _currentSchemaVersion = RackPeekConfigMigrationDeserializer.ListOfMigrations.Count;
 
     public Task<bool> Exists(string name) {
+        ThrowIfLoadFailed();
         return Task.FromResult(resourceCollection.Resources.Exists(r =>
             r.Name.Equals(name, StringComparison.OrdinalIgnoreCase)));
     }
 
     public Task<string?> GetKind(string? name) {
+        ThrowIfLoadFailed();
         return Task.FromResult(resourceCollection.Resources.FirstOrDefault(r =>
             r.Name.Equals(name, StringComparison.OrdinalIgnoreCase))?.Kind);
     }
 
     public Task<IReadOnlyList<(Resource, string)>> GetByLabelAsync(string name) {
+        ThrowIfLoadFailed();
         ReadOnlyCollection<(Resource r, string)> result = resourceCollection.Resources
             .Where(r => r.Labels != null && r.Labels.TryGetValue(name, out _))
             .Select(r => (r, r.Labels![name]))
@@ -56,6 +96,7 @@ public sealed class YamlResourceCollection(
     }
 
     public Task<Dictionary<string, int>> GetLabelsAsync() {
+        ThrowIfLoadFailed();
         var result = resourceCollection.Resources
             .SelectMany(r => r.Labels ?? Enumerable.Empty<KeyValuePair<string, string>>())
             .Where(kvp => !string.IsNullOrWhiteSpace(kvp.Key))
@@ -66,6 +107,7 @@ public sealed class YamlResourceCollection(
     }
 
     public Task<IReadOnlyList<(Resource, string)>> GetResourceIpsAsync() {
+        ThrowIfLoadFailed();
         var result = new List<(Resource, string)>();
 
         List<Resource> allResources = resourceCollection.Resources;
@@ -99,6 +141,7 @@ public sealed class YamlResourceCollection(
     }
 
     public Task<Dictionary<string, int>> GetTagsAsync() {
+        ThrowIfLoadFailed();
         var result = resourceCollection.Resources
             .SelectMany(r => r.Tags) // flatten all tag arrays
             .Where(t => !string.IsNullOrWhiteSpace(t))
@@ -108,10 +151,20 @@ public sealed class YamlResourceCollection(
         return Task.FromResult(result);
     }
 
-    public Task<IReadOnlyList<T>> GetAllOfTypeAsync<T>() =>
-        Task.FromResult<IReadOnlyList<T>>(resourceCollection.Resources.OfType<T>().ToList());
+    public Task<IReadOnlyList<T>> GetAllOfTypeAsync<T>() {
+        ThrowIfLoadFailed();
+        return Task.FromResult<IReadOnlyList<T>>(resourceCollection.Resources.OfType<T>().ToList());
+    }
+
+    public Task<(IReadOnlyList<Resource> Resources, IReadOnlyList<Connection> Connections)>
+        GetInventorySnapshotAsync() {
+        ThrowIfLoadFailed();
+        (List<Resource> resources, List<Connection> connections) = resourceCollection.GetInventorySnapshot();
+        return Task.FromResult<(IReadOnlyList<Resource>, IReadOnlyList<Connection>)>((resources, connections));
+    }
 
     public Task<IReadOnlyList<Resource>> GetDependantsAsync(string name) {
+        ThrowIfLoadFailed();
         var result = resourceCollection.Resources
             .Where(r => r.RunsOn.Any(p => p.Equals(name, StringComparison.OrdinalIgnoreCase)))
             .ToList();
@@ -125,26 +178,35 @@ public sealed class YamlResourceCollection(
 
         await resourceCollection.FileLock.WaitAsync();
         try {
+            await EnsureLoadedAsync();
+
             YamlRoot incomingRoot = await migrationService.DeserializeAsync(incomingYaml);
 
             List<Resource> incomingResources = incomingRoot.Resources ?? new List<Resource>();
+            List<Resource> existingResources = ResourceCollectionMerger.DeepCloneResources(
+                resourceCollection.Resources);
+            var existingConnections = resourceCollection.Connections
+                .Select(CloneConnection)
+                .ToList();
+
+            DiscoveryIdResolver.ResolveNames(
+                existingResources,
+                incomingResources,
+                incomingRoot.Connections,
+                existingConnections,
+                true);
+
             List<Resource> merged = ResourceCollectionMerger.Merge(
-                resourceCollection.Resources,
+                existingResources,
                 incomingResources,
                 mode);
 
-            resourceCollection.Resources.Clear();
-            resourceCollection.Resources.AddRange(merged);
-
             List<Connection>? mergedConnections = ConnectionMerger.Merge(
-                resourceCollection.Connections,
+                existingConnections,
                 incomingRoot.Connections,
                 mode);
 
-            if (mergedConnections != null) {
-                resourceCollection.Connections.Clear();
-                resourceCollection.Connections.AddRange(mergedConnections);
-            }
+            resourceCollection.ReplaceInventory(merged, mergedConnections ?? existingConnections);
 
             var rootToSave = new YamlRoot {
                 Version = RackPeekConfigMigrationDeserializer.ListOfMigrations.Count,
@@ -159,7 +221,23 @@ public sealed class YamlResourceCollection(
         }
     }
 
+    private static Connection CloneConnection(Connection connection) => new() {
+        A = new PortReference {
+            Resource = connection.A.Resource,
+            PortGroup = connection.A.PortGroup,
+            PortIndex = connection.A.PortIndex
+        },
+        B = new PortReference {
+            Resource = connection.B.Resource,
+            PortGroup = connection.B.PortGroup,
+            PortIndex = connection.B.PortIndex
+        },
+        Label = connection.Label,
+        Notes = connection.Notes
+    };
+
     public Task<IReadOnlyList<Resource>> GetByTagAsync(string name) {
+        ThrowIfLoadFailed();
         return Task.FromResult<IReadOnlyList<Resource>>(
             resourceCollection.Resources
                 .Where(r => r.Tags.Contains(name))
@@ -167,27 +245,42 @@ public sealed class YamlResourceCollection(
         );
     }
 
-    public IReadOnlyList<Hardware> HardwareResources =>
-        resourceCollection.Resources.OfType<Hardware>().ToList();
+    public IReadOnlyList<Hardware> HardwareResources {
+        get {
+            ThrowIfLoadFailed();
+            return resourceCollection.Resources.OfType<Hardware>().ToList();
+        }
+    }
 
-    public IReadOnlyList<SystemResource> SystemResources =>
-        resourceCollection.Resources.OfType<SystemResource>().ToList();
+    public IReadOnlyList<SystemResource> SystemResources {
+        get {
+            ThrowIfLoadFailed();
+            return resourceCollection.Resources.OfType<SystemResource>().ToList();
+        }
+    }
 
-    public IReadOnlyList<Service> ServiceResources =>
-        resourceCollection.Resources.OfType<Service>().ToList();
+    public IReadOnlyList<Service> ServiceResources {
+        get {
+            ThrowIfLoadFailed();
+            return resourceCollection.Resources.OfType<Service>().ToList();
+        }
+    }
 
     public Task<Resource?> GetByNameAsync(string name) {
+        ThrowIfLoadFailed();
         return Task.FromResult(resourceCollection.Resources.FirstOrDefault(r =>
             r.Name.Equals(name, StringComparison.OrdinalIgnoreCase)));
     }
 
     public Task<T?> GetByNameAsync<T>(string name) where T : Resource {
+        ThrowIfLoadFailed();
         Resource? resource =
             resourceCollection.Resources.FirstOrDefault(r => r.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
         return Task.FromResult(resource as T);
     }
 
     public Resource? GetByName(string name) {
+        ThrowIfLoadFailed();
         return resourceCollection.Resources.FirstOrDefault(r =>
             r.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
     }
@@ -200,27 +293,83 @@ public sealed class YamlResourceCollection(
         // "Index was outside the bounds of the array" out of List.Clear.
         await resourceCollection.FileLock.WaitAsync();
         try {
-            var yaml = await fileStore.ReadAllTextAsync(filePath);
+            await LoadUnderLockAsync();
+        }
+        finally {
+            resourceCollection.FileLock.Release();
+        }
+    }
 
-            YamlRoot root = await migrationService.DeserializeAsync(
+    private async Task LoadUnderLockAsync() {
+        var yaml = await fileStore.ReadAllTextAsync(filePath);
+
+        YamlRoot root;
+
+        try {
+            root = await migrationService.DeserializeAsync(
                 yaml,
                 async originalYaml => await BackupOriginalAsync(originalYaml),
-                async migratedRoot => await SaveRootAsync(migratedRoot)
+                async migratedRoot => {
+                    if (migratedRoot.Version > 0)
+                        await SaveRootAsync(migratedRoot);
+                }
             );
+        }
+        catch (Exception ex) when (ex is not ConfigLoadException
+                                       and not IOException
+                                       and not UnauthorizedAccessException) {
+            // A file that exists but cannot be understood. Record it so that every
+            // later read and write refuses, rather than quietly serving — and then
+            // persisting — an empty inventory over a recoverable file (#337).
+            resourceCollection.LoadFailure = new ConfigLoadException(
+                $"The config at {filePath} could not be read: {ex.Message} " +
+                "Fix or restore the file (recent schema migrations leave .bak copies " +
+                "beside it); nothing has been changed.",
+                ex);
+
+            throw resourceCollection.LoadFailure;
+        }
 
-            resourceCollection.Resources.Clear();
+        // A RackPeek document always carries a schema version. Its absence means the
+        // file was cut before the version line was written, or is not a RackPeek
+        // config at all — either way the parse "succeeding" with an empty document is
+        // not evidence of an empty inventory.
+        if (!string.IsNullOrWhiteSpace(yaml) && root.Version <= 0) {
+            resourceCollection.LoadFailure = new ConfigLoadException(
+                $"The config at {filePath} is missing its schema version, so it is " +
+                "incomplete or not a RackPeek config. Fix or restore the file; " +
+                "nothing has been changed.");
+
+            throw resourceCollection.LoadFailure;
+        }
 
-            if (root.Resources != null)
-                resourceCollection.Resources.AddRange(root.Resources);
+        resourceCollection.ReplaceInventory(root.Resources ?? [], root.Connections ?? []);
 
-            resourceCollection.Connections.Clear();
+        resourceCollection.LoadFailure = null;
+        resourceCollection.Loaded = true;
+    }
 
-            if (root.Connections != null)
-                resourceCollection.Connections.AddRange(root.Connections);
-        }
-        finally {
-            resourceCollection.FileLock.Release();
-        }
+    /// <summary>
+    ///     Called at the top of every read path. When the config exists but could not be
+    ///     understood, the in-memory collection is empty for a reason that has nothing to
+    ///     do with the user's inventory — serving it would report "0 resources" for a
+    ///     recoverable file, and scripted consumers would treat that as the truth (#337).
+    /// </summary>
+    private void ThrowIfLoadFailed() {
+        if (resourceCollection.LoadFailure != null)
+            throw resourceCollection.LoadFailure;
+    }
+
+    /// <summary>
+    ///     Called at the top of every write path, under the lock. Normally a no-op:
+    ///     both the CLI and the web host load at startup. When that startup load failed
+    ///     (unreadable or malformed file, tolerated so the process can boot), this
+    ///     retries — and if the store still cannot be read, the write fails HERE, before
+    ///     the empty in-memory collection can be persisted over the user's config.
+    /// </summary>
+    private async Task EnsureLoadedAsync() {
+        if (!resourceCollection.Loaded)
+            await LoadUnderLockAsync();
     }
 
     public Task AddAsync(Resource resource) {
@@ -267,6 +416,7 @@ public sealed class YamlResourceCollection(
     }
 
     public Task<IReadOnlyList<Connection>> GetConnectionsAsync() {
+        ThrowIfLoadFailed();
         IReadOnlyList<Connection> result =
             resourceCollection.Connections
                 .ToList()
@@ -276,6 +426,7 @@ public sealed class YamlResourceCollection(
     }
 
     public Task<IReadOnlyList<Connection>> GetConnectionsForResourceAsync(string resource) {
+        ThrowIfLoadFailed();
         IReadOnlyList<Connection> result =
             resourceCollection.Connections
                 .Where(c =>
@@ -288,6 +439,7 @@ public sealed class YamlResourceCollection(
     }
 
     public Task<Connection?> GetConnectionForPortAsync(PortReference port) {
+        ThrowIfLoadFailed();
         Connection? connection =
             resourceCollection.Connections
                 .FirstOrDefault(c =>
@@ -353,6 +505,8 @@ public sealed class YamlResourceCollection(
     private async Task UpdateWithLockAsync(Action<List<Resource>> action) {
         await resourceCollection.FileLock.WaitAsync();
         try {
+            await EnsureLoadedAsync();
+
             action(resourceCollection.Resources);
 
             // Always write current schema version when app writes the file.
@@ -461,6 +615,8 @@ public sealed class YamlResourceCollection(
     private async Task UpdateConnectionsWithLockAsync(Action<List<Connection>> action) {
         await resourceCollection.FileLock.WaitAsync();
         try {
+            await EnsureLoadedAsync();
+
             action(resourceCollection.Connections);
 
             var root = new YamlRoot {

+ 10 - 3
RackPeek.Domain/RackPeek.Domain.csproj

@@ -6,12 +6,19 @@
         <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" />
-        <PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.12" />
-        <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.12" />
-        <PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.12" />
+        <PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.13" />
+        <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.13" />
+        <PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.13" />
         <PackageReference Include="YamlDotNet" Version="18.1.0" />
     </ItemGroup>
 

+ 2 - 0
RackPeek.Domain/Resources/Laptops/DescribeLaptopUseCase.cs

@@ -22,6 +22,7 @@ public class DescribeLaptopUseCase(IResourceCollection repository) : IUseCase {
             ramSummary,
             laptop.Drives?.Count ?? 0,
             laptop.Gpus?.Count ?? 0,
+            laptop.Ports?.Count ?? 0,
             laptop.Labels
         );
     }
@@ -33,5 +34,6 @@ public record LaptopDescription(
     string? RamSummary,
     int DriveCount,
     int GpuCount,
+    int NicCount,
     Dictionary<string, string> Labels
 );

+ 2 - 1
RackPeek.Domain/Resources/Laptops/Laptop.cs

@@ -3,11 +3,12 @@ using RackPeek.Domain.Resources.SubResources;
 
 namespace RackPeek.Domain.Resources.Laptops;
 
-public class Laptop : Hardware.Hardware, ICpuResource, IDriveResource, IGpuResource {
+public class Laptop : Hardware.Hardware, ICpuResource, IDriveResource, IGpuResource, IPortResource {
     public const string KindLabel = "Laptop";
     public Ram? Ram { get; set; }
     public string? Model { get; set; }
     public List<Cpu>? Cpus { get; set; }
     public List<Drive>? Drives { get; set; }
     public List<Gpu>? Gpus { get; set; }
+    public List<Port>? Ports { get; set; }
 }

+ 2 - 0
RackPeek.Domain/Resources/OtherHardware/DescribeOtherUseCase.cs

@@ -7,6 +7,7 @@ public record OtherDescription(
     string Name,
     string? Model,
     string? Description,
+    string PortSummary,
     Dictionary<string, string> Labels
 );
 
@@ -23,6 +24,7 @@ public class DescribeOtherUseCase(IResourceCollection repository) : IUseCase {
             other.Name,
             other.Model,
             other.Description,
+            PortSummaries.Describe(other.Ports),
             other.Labels
         );
     }

+ 5 - 1
RackPeek.Domain/Resources/OtherHardware/Other.cs

@@ -1,7 +1,11 @@
+using RackPeek.Domain.Resources.Servers;
+using RackPeek.Domain.Resources.SubResources;
+
 namespace RackPeek.Domain.Resources.OtherHardware;
 
-public class Other : Hardware.Hardware {
+public class Other : Hardware.Hardware, IPortResource {
     public const string KindLabel = "Other";
     public string? Model { get; set; }
     public string? Description { get; set; }
+    public List<Port>? Ports { get; set; }
 }

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

@@ -52,6 +52,41 @@ public abstract class Resource {
 
     public required string Name { get; set; }
 
+    /// <summary>
+    ///     Stable machine-generated identity, set by <c>rpk discover</c>. Optional, and
+    ///     absent on everything entered by hand. Lets a re-run find this resource again
+    ///     after the user has renamed it. See <c>RackPeek.Domain.Discovery.DiscoveryId</c>.
+    /// </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; }

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

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

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

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

+ 17 - 22
RackPeek.Domain/Resources/Services/Service.cs

@@ -1,30 +1,25 @@
-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();
-    }
+    public List<Network> Endpoints { get; set; } = [];
+
+    /// <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;
+    }
+}

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

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

+ 4 - 1
RackPeek.Domain/Resources/SubResources/Nic.cs

@@ -25,7 +25,10 @@ public class Nic {
         "xfp", "cx4",
 
         // Management / special-purpose
-        "mgmt" // Dedicated management NIC (IPMI/BMC)
+        "mgmt", // Dedicated management NIC (IPMI/BMC)
+
+        // Peripheral bus
+        "usb" // USB-attached hardware (dongles, external drives, accelerators)
     };
 
     public string? Type { get; set; }

+ 2 - 0
RackPeek.Domain/Resources/UpsUnits/DescribeUpsUseCase.cs

@@ -7,6 +7,7 @@ public record UpsDescription(
     string Name,
     string? Model,
     int? Va,
+    string PortSummary,
     Dictionary<string, string> Labels
 );
 
@@ -23,6 +24,7 @@ public class DescribeUpsUseCase(IResourceCollection repository) : IUseCase {
             ups.Name,
             ups.Model,
             ups.Va,
+            PortSummaries.Describe(ups.Ports),
             ups.Labels
         );
     }

+ 5 - 1
RackPeek.Domain/Resources/UpsUnits/Ups.cs

@@ -1,7 +1,11 @@
+using RackPeek.Domain.Resources.Servers;
+using RackPeek.Domain.Resources.SubResources;
+
 namespace RackPeek.Domain.Resources.UpsUnits;
 
-public class Ups : Hardware.Hardware {
+public class Ups : Hardware.Hardware, IPortResource {
     public const string KindLabel = "Ups";
     public string? Model { get; set; }
     public int? Va { get; set; }
+    public List<Port>? Ports { get; set; }
 }

+ 1 - 1
RackPeek.Domain/RpkConstants.cs

@@ -1,7 +1,7 @@
 namespace RackPeek.Domain;
 
 public static class RpkConstants {
-    public const string Version = "v2.1.0";
+    public const string Version = "v2.3.0";
 
     public static bool HasGitServices { get; set; }
 }

+ 1 - 1
Shared.Rcl/Services/GlobalSearchService.cs → RackPeek.Domain/Search/GlobalSearchService.cs

@@ -2,7 +2,7 @@ using RackPeek.Domain.Resources;
 using RackPeek.Domain.Resources.Services;
 using RackPeek.Domain.Resources.SystemResources;
 
-namespace Shared.Rcl.Services;
+namespace RackPeek.Domain.Search;
 
 public record SearchResult(
     string Name,

+ 8 - 0
RackPeek.Domain/ServiceCollectionExtensions.cs

@@ -1,6 +1,7 @@
 using System.Reflection;
 using Microsoft.Extensions.Configuration;
 using Microsoft.Extensions.DependencyInjection;
+using RackPeek.Domain.Discovery;
 using RackPeek.Domain.Git;
 using RackPeek.Domain.Persistence;
 using RackPeek.Domain.Resources;
@@ -72,6 +73,13 @@ public static class ServiceCollectionExtensions {
 
     public static IServiceCollection AddUseCases(
         this IServiceCollection services) {
+        // Discovery probes. Both are registered on every platform and every host (CLI,
+        // web console, viewer); the command picks whichever reports itself supported,
+        // so an unsupported host fails with a message rather than a missing registration.
+        services.AddSingleton<ISystemProbe, LinuxSystemProbe>();
+        services.AddSingleton<ISystemProbe, MacSystemProbe>();
+        services.AddSingleton<INetworkProbe, NetworkProbe>();
+
         services.AddScoped(typeof(IAddResourceUseCase<>), typeof(AddResourceUseCase<>));
         services.AddScoped(typeof(IAddLabelUseCase<>), typeof(AddLabelUseCase<>));
         services.AddScoped(typeof(IAddTagUseCase<>), typeof(AddTagUseCase<>));

+ 7 - 0
RackPeek.Domain/UseCases/Ansible/AnsibleInventoryGenerator.cs

@@ -1,5 +1,6 @@
 using System.Text;
 using RackPeek.Domain.Resources;
+using RackPeek.Domain.Resources.SystemResources;
 
 namespace RackPeek.Domain.UseCases.Ansible;
 
@@ -180,6 +181,12 @@ public static class AnsibleInventoryGenerator {
         if (r.Labels.TryGetValue("hostname", out var hn) && !string.IsNullOrWhiteSpace(hn))
             return hn;
 
+        // A System's own address, the way the ssh and hosts exporters already read it —
+        // this is what makes discovered hosts addressable without hand-adding a label.
+        // Labels stay first: an explicit ansible_host must always win.
+        if (r is SystemResource { Ip: not null } system && !string.IsNullOrWhiteSpace(system.Ip))
+            return system.Ip;
+
         return null;
     }
 

+ 5 - 0
RackPeek.Domain/UseCases/CloneAccessPointUseCase.cs

@@ -27,6 +27,11 @@ public class CloneResourceUseCase<T>(IResourceCollection repo) : ICloneResourceU
         T clone = Clone.DeepClone(original);
         clone.Name = cloneName;
 
+        // A discoveryId names one machine; a copy of its card is not that machine.
+        // Keeping it would also put two resources with the same id in the store,
+        // which DiscoveryIdResolver rejects on every subsequent discovery import.
+        clone.DiscoveryId = null;
+
         await repo.AddAsync(clone);
     }
 }

+ 12 - 2
RackPeek.Domain/UseCases/RenameResourceUseCase.cs

@@ -18,15 +18,25 @@ public class RenameResourceUseCase<T>(IResourceCollection repo) : IRenameResourc
         newName = Normalize.HardwareName(newName);
         ThrowIfInvalid.ResourceName(newName);
 
-        Resource? existingResource = await repo.GetByNameAsync(newName);
+        Resource? original = await repo.GetByNameAsync(originalName);
+
+        IReadOnlyList<Resource> resources = await repo.GetAllOfTypeAsync<Resource>();
+        Resource? existingResource = resources.FirstOrDefault(r =>
+            !ReferenceEquals(r, original) && r.Name.Equals(newName, StringComparison.OrdinalIgnoreCase));
         if (existingResource != null)
             throw new ConflictException($"{existingResource.Kind} resource '{newName}' already exists.");
 
-        Resource? original = await repo.GetByNameAsync(originalName);
         if (original == null)
             throw new NotFoundException($"Resource '{originalName}' not found.");
 
+        if (original.Name == newName)
+            return;
+
         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>();

+ 25 - 0
RackPeek.Mcp/McpSetup.cs

@@ -0,0 +1,25 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using Microsoft.Extensions.DependencyInjection;
+using ModelContextProtocol;
+using RackPeek.Mcp.Tools;
+
+namespace RackPeek.Mcp;
+
+public static class McpSetup {
+    /// <summary>The name MCP clients see in the initialize handshake.</summary>
+    public const string ServerName = "rackpeek";
+
+    public static IMcpServerBuilder WithRackPeekTools(this IMcpServerBuilder builder) {
+        // MCP tool serialization does not go through ASP.NET's ConfigureHttpJsonOptions,
+        // so enums-as-strings is opted into again here to match the REST API's JSON.
+        var json = new JsonSerializerOptions(McpJsonUtilities.DefaultOptions);
+        json.Converters.Add(new JsonStringEnumConverter());
+
+        return builder
+            .WithTools<QueryTools>(json)
+            .WithTools<MutationTools>(json)
+            .WithTools<ExportTools>(json)
+            .WithTools<GitTools>(json);
+    }
+}

+ 30 - 0
RackPeek.Mcp/RackPeek.Mcp.csproj

@@ -0,0 +1,30 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+    <PropertyGroup>
+        <TargetFramework>net10.0</TargetFramework>
+        <ImplicitUsings>enable</ImplicitUsings>
+        <Nullable>enable</Nullable>
+    </PropertyGroup>
+
+    <!-- MCP tool implementations over the domain use cases. This project is
+         referenced only by RackPeek.Web: the CLI (RackPeek -> Shared.Rcl ->
+         RackPeek.Domain) must stay free of the MCP SDK dependency so the
+         self-contained single-file binaries do not grow for a feature that
+         only exists on the server. -->
+
+    <ItemGroup>
+        <PackageReference Include="ModelContextProtocol" Version="2.3.0"/>
+    </ItemGroup>
+
+    <ItemGroup>
+        <ProjectReference Include="..\RackPeek.Domain\RackPeek.Domain.csproj"/>
+    </ItemGroup>
+
+    <ItemGroup>
+        <!-- get_schema serves the schema for the CURRENT config version, resolved at
+             runtime from ListOfMigrations.Count. When a v5 migration lands, add its
+             schema here — the tool throws (and Tests.Mcp fails) until it is present. -->
+        <EmbeddedResource Include="..\schemas\v5\schema.v5.json" LogicalName="schema.v5.json"/>
+    </ItemGroup>
+
+</Project>

+ 54 - 0
RackPeek.Mcp/ResourceKindDispatch.cs

@@ -0,0 +1,54 @@
+using Microsoft.Extensions.DependencyInjection;
+using RackPeek.Domain.Helpers;
+using RackPeek.Domain.Persistence;
+using RackPeek.Domain.Resources;
+using RackPeek.Domain.Resources.AccessPoints;
+using RackPeek.Domain.Resources.Desktops;
+using RackPeek.Domain.Resources.Firewalls;
+using RackPeek.Domain.Resources.Laptops;
+using RackPeek.Domain.Resources.OtherHardware;
+using RackPeek.Domain.Resources.Routers;
+using RackPeek.Domain.Resources.Servers;
+using RackPeek.Domain.Resources.Services;
+using RackPeek.Domain.Resources.Switches;
+using RackPeek.Domain.Resources.SystemResources;
+using RackPeek.Domain.Resources.UpsUnits;
+using RackPeek.Domain.UseCases;
+
+namespace RackPeek.Mcp;
+
+/// <summary>
+///     Closes the per-kind generic use cases over the concrete resource type at
+///     runtime. MCP tools receive a name, not a type — the kind is looked up and the
+///     call dispatched so type-sensitive code (the clone's deep copy serialises the
+///     real derived type, not the abstract base) runs against the right generic.
+/// </summary>
+internal static class ResourceKindDispatch {
+    public static async Task CloneAsync(
+        IServiceProvider services,
+        IResourceCollection repo,
+        string originalName,
+        string cloneName) {
+        var kind = await repo.GetKind(originalName)
+                   ?? throw new NotFoundException($"Resource '{originalName}' not found.");
+
+        await (kind.Trim().ToLowerInvariant() switch {
+            "server" => Run<Server>(),
+            "switch" => Run<Switch>(),
+            "firewall" => Run<Firewall>(),
+            "router" => Run<Router>(),
+            "accesspoint" => Run<AccessPoint>(),
+            "desktop" => Run<Desktop>(),
+            "laptop" => Run<Laptop>(),
+            "ups" => Run<Ups>(),
+            "other" => Run<Other>(),
+            "system" => Run<SystemResource>(),
+            "service" => Run<Service>(),
+            _ => throw new NotFoundException($"Resource kind '{kind}' cannot be cloned.")
+        });
+
+        Task Run<T>() where T : Resource =>
+            services.GetRequiredService<ICloneResourceUseCase<T>>()
+                .ExecuteAsync(originalName, cloneName);
+    }
+}

+ 38 - 0
RackPeek.Mcp/ToolErrors.cs

@@ -0,0 +1,38 @@
+using System.ComponentModel.DataAnnotations;
+using ModelContextProtocol;
+using RackPeek.Domain.Helpers;
+
+namespace RackPeek.Mcp;
+
+/// <summary>
+///     Runs a tool body and rethrows the domain's user-facing exceptions as
+///     <see cref="McpException" /> so their message reaches the calling agent as a
+///     tool error it can act on. Anything else falls through: the SDK reports those
+///     as a generic error, deliberately, so nothing internal leaks over the wire.
+/// </summary>
+internal static class ToolErrors {
+    public static async Task<T> RunAsync<T>(Func<Task<T>> action) {
+        try {
+            return await action();
+        }
+        catch (ValidationException ex) {
+            throw new McpException($"Invalid input: {ex.Message}");
+        }
+        catch (NotFoundException ex) {
+            throw new McpException(ex.Message);
+        }
+        catch (ConfigLoadException ex) {
+            // The config exists but cannot be read. Say so plainly rather than letting
+            // the agent see a generic failure and conclude the inventory is empty.
+            throw new McpException(ex.Message);
+        }
+        catch (ConflictException ex) {
+            throw new McpException(ex.Message);
+        }
+        catch (InvalidOperationException ex) {
+            // The connection use cases signal user-correctable mistakes with this
+            // ("cannot connect a port to itself", "resource has no ports").
+            throw new McpException(ex.Message);
+        }
+    }
+}

+ 83 - 0
RackPeek.Mcp/Tools/ExportTools.cs

@@ -0,0 +1,83 @@
+using System.ComponentModel;
+using Microsoft.Extensions.DependencyInjection;
+using ModelContextProtocol.Server;
+using RackPeek.Domain.Graph;
+using RackPeek.Domain.Graph.Serialisers;
+using RackPeek.Domain.Graph.UseCases;
+using RackPeek.Domain.UseCases.Ansible;
+using RackPeek.Domain.UseCases.Hosts;
+using RackPeek.Domain.UseCases.SSH;
+
+namespace RackPeek.Mcp.Tools;
+
+public enum TopologyView {
+    Physical,
+    Logical
+}
+
+[McpServerToolType]
+public sealed class ExportTools(IServiceProvider services) {
+    [McpServerTool(Name = "export_ansible_inventory", UseStructuredContent = true, ReadOnly = true, Idempotent = true, OpenWorld = false)]
+    [Description("Renders the inventory as an Ansible inventory file. A resource needs an 'ansible_host', 'ip' or " +
+                 "'hostname' label to be included, and hosts are emitted under the groups built from groupByTags / " +
+                 "groupByLabelKeys — pass at least one of those or the inventory comes back empty.")]
+    public Task<InventoryResult?> ExportAnsibleInventory(
+        [Description("Output format: Ini or Yaml.")] InventoryFormat format = InventoryFormat.Ini,
+        [Description("Create a group per listed tag.")] string[]? groupByTags = null,
+        [Description("Create groups from these label keys, e.g. 'env' groups hosts into env_prod, env_dev.")]
+        string[]? groupByLabelKeys = null) {
+        return ToolErrors.RunAsync(() =>
+            services.GetRequiredService<AnsibleInventoryGeneratorUseCase>().ExecuteAsync(new InventoryOptions {
+                Format = format,
+                GroupByTags = groupByTags ?? [],
+                GroupByLabelKeys = groupByLabelKeys ?? []
+            }));
+    }
+
+    [McpServerTool(Name = "export_ssh_config", UseStructuredContent = true, ReadOnly = true, Idempotent = true, OpenWorld = false)]
+    [Description("Renders the inventory as an OpenSSH client config (Host blocks).")]
+    public Task<SshExportResult?> ExportSshConfig(
+        [Description("Only include resources carrying at least one of these tags.")]
+        string[]? includeTags = null,
+        [Description("Default SSH user for every host.")] string? defaultUser = null,
+        [Description("Default SSH port for every host.")] int defaultPort = 22,
+        [Description("Default IdentityFile path for every host.")] string? defaultIdentityFile = null) {
+        return ToolErrors.RunAsync(() =>
+            services.GetRequiredService<SshConfigExportUseCase>().ExecuteAsync(new SshExportOptions {
+                IncludeTags = includeTags ?? [],
+                DefaultUser = defaultUser,
+                DefaultPort = defaultPort,
+                DefaultIdentityFile = defaultIdentityFile
+            }));
+    }
+
+    [McpServerTool(Name = "export_hosts_file", UseStructuredContent = true, ReadOnly = true, Idempotent = true, OpenWorld = false)]
+    [Description("Renders the inventory as /etc/hosts entries.")]
+    public Task<HostsExportResult?> ExportHostsFile(
+        [Description("Only include resources carrying at least one of these tags.")]
+        string[]? includeTags = null,
+        [Description("Domain suffix appended to every host name, e.g. 'home.local'.")]
+        string? domainSuffix = null,
+        [Description("Include the localhost entries at the top.")]
+        bool includeLocalhostDefaults = true) {
+        return ToolErrors.RunAsync(() =>
+            services.GetRequiredService<HostsFileExportUseCase>().ExecuteAsync(new HostsExportOptions {
+                IncludeTags = includeTags ?? [],
+                DomainSuffix = domainSuffix,
+                IncludeLocalhostDefaults = includeLocalhostDefaults
+            }));
+    }
+
+    [McpServerTool(Name = "export_topology_mermaid", ReadOnly = true, Idempotent = true, OpenWorld = false)]
+    [Description("Renders the infrastructure as a Mermaid diagram: Physical shows hardware and its port connections, Logical shows host cards with their services grouped by subnet.")]
+    public Task<string> ExportTopologyMermaid(
+        [Description("Physical or Logical.")] TopologyView view = TopologyView.Physical) {
+        return ToolErrors.RunAsync(async () => {
+            Graph graph = view == TopologyView.Physical
+                ? await services.GetRequiredService<BuildPhysicalTopologyUseCase>().ExecuteAsync()
+                : await services.GetRequiredService<BuildLogicalGraphUseCase>().ExecuteAsync();
+
+            return new MermaidSerialiser().Serialise(graph);
+        });
+    }
+}

+ 95 - 0
RackPeek.Mcp/Tools/GitTools.cs

@@ -0,0 +1,95 @@
+using System.ComponentModel;
+using Microsoft.Extensions.DependencyInjection;
+using ModelContextProtocol;
+using ModelContextProtocol.Server;
+using RackPeek.Domain.Git;
+using RackPeek.Domain.Git.UseCases;
+
+namespace RackPeek.Mcp.Tools;
+
+public sealed record GitStatusResult(
+    [property: Description("False when the server has no GIT_TOKEN configured — every other field is then empty.")]
+    bool Available,
+    string? Message,
+    string? Branch = null,
+    [property: Description("Clean or Dirty.")] string? Status = null,
+    string[]? ChangedFiles = null,
+    bool HasRemote = false,
+    [property: Description("Commits the remote is missing. Null when there is no remote.")]
+    int? Ahead = null,
+    [property: Description("Commits this repo is missing. Null when there is no remote.")]
+    int? Behind = null,
+    List<string>? RecentCommits = null);
+
+/// <summary>
+///     Version control over the config directory. Only active when the server is
+///     started with GIT_TOKEN — otherwise <see cref="NullGitRepository" /> is wired
+///     in and these tools explain how to enable it instead of failing obscurely.
+/// </summary>
+[McpServerToolType]
+public sealed class GitTools(IGitRepository repo, IServiceProvider services) {
+    private const string _notConfigured =
+        "Git integration is not configured. Start the server with GIT_TOKEN " +
+        "(and optionally GIT_USERNAME) set to enable it.";
+
+    [McpServerTool(Name = "git_status", UseStructuredContent = true, ReadOnly = true, OpenWorld = false)]
+    [Description("The config repository's branch, dirty/clean state, changed files, remote sync state and recent commits.")]
+    public Task<GitStatusResult> GitStatus() {
+        return ToolErrors.RunAsync(() => {
+            if (!repo.IsAvailable)
+                return Task.FromResult(new GitStatusResult(false, _notConfigured));
+
+            try {
+                var hasRemote = repo.HasRemote();
+                GitSyncStatus? sync = hasRemote ? repo.FetchAndGetSyncStatus() : null;
+
+                GitLogEntry[] log;
+                try {
+                    log = repo.GetLog(5);
+                }
+                catch {
+                    // An empty repository has no log yet; that is not an error.
+                    log = [];
+                }
+
+                return Task.FromResult(new GitStatusResult(
+                    true,
+                    sync?.Error,
+                    repo.GetCurrentBranch(),
+                    repo.GetStatus().ToString(),
+                    repo.GetChangedFiles(),
+                    hasRemote,
+                    sync?.Ahead,
+                    sync?.Behind,
+                    log.Select(e => $"{e.Hash} {e.Date} {e.Author}: {e.Message}").ToList()));
+            }
+            catch (Exception ex) when (ex is not McpException) {
+                throw new McpException($"Git error: {ex.Message}");
+            }
+        });
+    }
+
+    [McpServerTool(Name = "git_commit", Idempotent = true, OpenWorld = false)]
+    [Description("Stages everything in the config directory and commits it. A clean tree commits nothing and still succeeds.")]
+    public Task<string> GitCommit(
+        [Description("The commit message.")] string message,
+        [Description("Also push to the configured remote.")] bool push = false) {
+        return ToolErrors.RunAsync(async () => {
+            if (!repo.IsAvailable)
+                throw new McpException(_notConfigured);
+
+            var error = await services.GetRequiredService<CommitAllUseCase>().ExecuteAsync(message);
+            if (error != null)
+                throw new McpException(error);
+
+            if (!push)
+                return "Committed.";
+
+            var pushError = await services.GetRequiredService<PushUseCase>().ExecuteAsync();
+            if (pushError != null)
+                throw new McpException($"Committed, but the push failed: {pushError}");
+
+            return "Committed and pushed.";
+        });
+    }
+}

+ 170 - 0
RackPeek.Mcp/Tools/MutationTools.cs

@@ -0,0 +1,170 @@
+using System.ComponentModel;
+using System.ComponentModel.DataAnnotations;
+using Microsoft.Extensions.DependencyInjection;
+using ModelContextProtocol;
+using ModelContextProtocol.Server;
+using RackPeek.Domain.Api;
+using RackPeek.Domain.Helpers;
+using RackPeek.Domain.Persistence;
+using RackPeek.Domain.Resources;
+using RackPeek.Domain.Resources.Connections;
+using RackPeek.Domain.UseCases;
+using RackPeek.Domain.UseCases.Labels;
+using RackPeek.Domain.UseCases.Tags;
+
+namespace RackPeek.Mcp.Tools;
+
+public sealed record TagsResult(string Name, string[] Tags);
+
+public sealed record LabelsResult(string Name, Dictionary<string, string> Labels);
+
+[McpServerToolType]
+public sealed class MutationTools(IResourceCollection repo, IServiceProvider services) {
+    [McpServerTool(Name = "upsert_resources", UseStructuredContent = true, Idempotent = true, OpenWorld = false)]
+    [Description("Creates or updates resources (and connections) from a RackPeek YAML document — the main " +
+                 "way to build and edit the inventory. Call get_schema first to learn the format, and " +
+                 "preview with dryRun before writing. Merge mode only adds and updates; it never removes.")]
+    public Task<ImportYamlResponse> UpsertResources(
+        [Description("A RackPeek YAML document: 'version', 'resources' and optional 'connections'.")]
+        string yaml,
+        [Description("Merge folds each incoming resource into the stored one, field by field. " +
+                     "Replace swaps each incoming resource in wholesale (other resources are untouched).")]
+        MergeMode mode = MergeMode.Merge,
+        [Description("When true, nothing is written — the response shows what would change.")]
+        bool dryRun = false) =>
+        RunUpsertAsync(services, new ImportYamlRequest { Yaml = yaml, Mode = mode, DryRun = dryRun });
+
+    [McpServerTool(Name = "delete_resource", Destructive = true, Idempotent = true, OpenWorld = false)]
+    [Description("Deletes a resource, detaches everything that ran on it, and removes its connections.")]
+    public Task<string> DeleteResource(
+        [Description("The resource's name.")] string name) {
+        return ToolErrors.RunAsync(async () => {
+            await services.GetRequiredService<IDeleteResourceUseCase<Resource>>().ExecuteAsync(name);
+            return $"Deleted '{name}'.";
+        });
+    }
+
+    [McpServerTool(Name = "rename_resource", Idempotent = true, OpenWorld = false)]
+    [Description("Renames a resource and rewrites every runsOn reference and connection endpoint that pointed at it.")]
+    public Task<string> RenameResource(
+        [Description("The resource's current name.")] string name,
+        [Description("The new name.")] string newName) {
+        return ToolErrors.RunAsync(async () => {
+            await services.GetRequiredService<IRenameResourceUseCase<Resource>>().ExecuteAsync(name, newName);
+            return $"Renamed '{name}' to '{newName}'.";
+        });
+    }
+
+    [McpServerTool(Name = "clone_resource", OpenWorld = false)]
+    [Description("Copies a resource under a new name. The copy has no discovery id — it describes new gear, not the original machine.")]
+    public Task<string> CloneResource(
+        [Description("The resource to copy.")] string name,
+        [Description("The copy's name.")] string cloneName) {
+        return ToolErrors.RunAsync(async () => {
+            await ResourceKindDispatch.CloneAsync(services, repo, name, cloneName);
+            return $"Cloned '{name}' to '{cloneName}'.";
+        });
+    }
+
+    [McpServerTool(Name = "edit_tags", UseStructuredContent = true, Idempotent = true, OpenWorld = false)]
+    [Description("Adds and/or removes tags on a resource and returns the tags it ends up with.")]
+    public Task<TagsResult> EditTags(
+        [Description("The resource's name.")] string name,
+        [Description("Tags to add.")] string[]? add = null,
+        [Description("Tags to remove.")] string[]? remove = null) {
+        return ToolErrors.RunAsync(async () => {
+            if (add is not { Length: > 0 } && remove is not { Length: > 0 })
+                throw new ValidationException("Pass at least one tag to add or remove.");
+
+            foreach (var tag in add ?? [])
+                await services.GetRequiredService<IAddTagUseCase<Resource>>().ExecuteAsync(name, tag);
+
+            foreach (var tag in remove ?? [])
+                await services.GetRequiredService<IRemoveTagUseCase<Resource>>().ExecuteAsync(name, tag);
+
+            Resource resource = await repo.GetByNameAsync(name)
+                                ?? throw new NotFoundException($"Resource '{name}' not found.");
+
+            return new TagsResult(resource.Name, resource.Tags);
+        });
+    }
+
+    [McpServerTool(Name = "edit_labels", UseStructuredContent = true, Idempotent = true, OpenWorld = false)]
+    [Description("Sets and/or removes key-value labels on a resource and returns the labels it ends up with.")]
+    public Task<LabelsResult> EditLabels(
+        [Description("The resource's name.")] string name,
+        [Description("Labels to set — an existing key is overwritten.")]
+        Dictionary<string, string>? set = null,
+        [Description("Label keys to remove.")] string[]? remove = null) {
+        return ToolErrors.RunAsync(async () => {
+            if (set is not { Count: > 0 } && remove is not { Length: > 0 })
+                throw new ValidationException("Pass at least one label to set or remove.");
+
+            foreach ((var key, var value) in set ?? new Dictionary<string, string>())
+                await services.GetRequiredService<IAddLabelUseCase<Resource>>().ExecuteAsync(name, key, value);
+
+            foreach (var key in remove ?? [])
+                await services.GetRequiredService<IRemoveLabelUseCase<Resource>>().ExecuteAsync(name, key);
+
+            Resource resource = await repo.GetByNameAsync(name)
+                                ?? throw new NotFoundException($"Resource '{name}' not found.");
+
+            return new LabelsResult(resource.Name, resource.Labels);
+        });
+    }
+
+    [McpServerTool(Name = "add_connection", Idempotent = true, OpenWorld = false)]
+    [Description("Connects a port on one hardware resource to a port on another. A port holds one connection — " +
+                 "connecting an occupied port replaces what was plugged into it. Port groups and indexes are " +
+                 "zero-based against the resource's 'ports' list (a group entry with count 4 has indexes 0-3).")]
+    public Task<string> AddConnection(
+        [Description("First endpoint's resource name.")] string resourceA,
+        [Description("First endpoint's port group index.")] int portGroupA,
+        [Description("First endpoint's port index within the group.")] int portIndexA,
+        [Description("Second endpoint's resource name.")] string resourceB,
+        [Description("Second endpoint's port group index.")] int portGroupB,
+        [Description("Second endpoint's port index within the group.")] int portIndexB,
+        [Description("Optional label, e.g. 'uplink'.")] string? label = null,
+        [Description("Optional notes.")] string? notes = null) {
+        return ToolErrors.RunAsync(async () => {
+            var a = new PortReference { Resource = resourceA, PortGroup = portGroupA, PortIndex = portIndexA };
+            var b = new PortReference { Resource = resourceB, PortGroup = portGroupB, PortIndex = portIndexB };
+
+            await services.GetRequiredService<IAddConnectionUseCase>().ExecuteAsync(a, b, label, notes);
+
+            return $"Connected {ConnectionMerger.Describe(new Connection { A = a, B = b, Label = label })}.";
+        });
+    }
+
+    [McpServerTool(Name = "remove_connection", Destructive = true, Idempotent = true, OpenWorld = false)]
+    [Description("Removes whatever connection is plugged into the given port.")]
+    public Task<string> RemoveConnection(
+        [Description("The port's resource name.")] string resource,
+        [Description("The port's group index.")] int portGroup,
+        [Description("The port's index within the group.")] int portIndex) {
+        return ToolErrors.RunAsync(async () => {
+            var port = new PortReference { Resource = resource, PortGroup = portGroup, PortIndex = portIndex };
+            await services.GetRequiredService<IRemoveConnectionUseCase>().ExecuteAsync(port);
+            return $"Removed any connection on {resource} port {portGroup}/{portIndex}.";
+        });
+    }
+
+    /// <summary>
+    ///     Upserts get their own error mapping: the use case reports YAML/JSON problems
+    ///     through several exception types, and — matching the REST endpoint's contract —
+    ///     the message is always safe and useful to show the caller.
+    /// </summary>
+    internal static async Task<ImportYamlResponse> RunUpsertAsync(
+        IServiceProvider services,
+        ImportYamlRequest request) {
+        try {
+            return await services.GetRequiredService<UpsertInventoryUseCase>().ExecuteAsync(request);
+        }
+        catch (ValidationException ex) {
+            throw new McpException($"Invalid input: {ex.Message}");
+        }
+        catch (Exception ex) when (ex is not McpException) {
+            throw new McpException($"Import failed: {ex.Message}");
+        }
+    }
+}

+ 220 - 0
RackPeek.Mcp/Tools/QueryTools.cs

@@ -0,0 +1,220 @@
+using System.ComponentModel;
+using Microsoft.Extensions.DependencyInjection;
+using ModelContextProtocol;
+using ModelContextProtocol.Server;
+using RackPeek.Domain.Helpers;
+using RackPeek.Domain.Persistence;
+using RackPeek.Domain.Persistence.Yaml;
+using RackPeek.Domain.Resources;
+using RackPeek.Domain.Resources.Connections;
+using RackPeek.Domain.Resources.Hardware;
+using RackPeek.Domain.Resources.Services.UseCases;
+using RackPeek.Domain.Resources.SystemResources.UseCases;
+using RackPeek.Domain.Search;
+
+namespace RackPeek.Mcp.Tools;
+
+public sealed record ResourceList(int Count, List<ResourceRow> Resources);
+
+public sealed record SearchResults(IReadOnlyList<SearchResult> Matches);
+
+public sealed record TreeResult(List<HardwareTree> Hardware);
+
+public sealed record ConnectionList(int Count, IReadOnlyList<Connection> Connections);
+
+public sealed record ResourceRow(
+    string Name,
+    string Kind,
+    string? Ip,
+    List<string> RunsOn,
+    string[] Tags,
+    Dictionary<string, string> Labels);
+
+public sealed record ResourceDetail(
+    [property: Description("The resource and its connections as a RackPeek YAML document. " +
+                           "Edit it and pass it back to upsert_resources to change the resource.")]
+    string Yaml,
+    [property: Description("Names of resources that run on this one.")]
+    List<string> Dependants);
+
+public sealed record InfrastructureSummary(
+    HardwareSummary Hardware,
+    SystemSummary Systems,
+    AllServicesSummary Services,
+    [property: Description("Every tag in use and how many resources carry it.")]
+    Dictionary<string, int> Tags,
+    [property: Description("Every label key in use and how many resources carry it.")]
+    Dictionary<string, int> Labels);
+
+public sealed record SchemaInfo(
+    [property: Description("The current config schema version.")]
+    int Version,
+    [property: Description("The JSON schema every inventory YAML document must conform to.")]
+    string JsonSchema,
+    string Guidance);
+
+[McpServerToolType]
+public sealed class QueryTools(IResourceCollection repo, IServiceProvider services) {
+    internal const string UpsertGuidance =
+        "A RackPeek document is YAML with 'version', 'resources' and optional 'connections'. " +
+        "Every resource has 'kind' (Server, Switch, Firewall, Router, Accesspoint, Desktop, " +
+        "Laptop, Ups, Other, System, Service), a unique 'name' (max 50 chars), and optional " +
+        "'tags', 'labels', 'notes' and 'runsOn'. Containment rules: a Service runs on a " +
+        "System; a System runs on hardware or on another System. Pass documents to " +
+        "upsert_resources — merge mode only adds and updates, it never removes fields, " +
+        "so use the dedicated tools (delete_resource, edit_tags, edit_labels, " +
+        "remove_connection) to take things away.";
+
+    [McpServerTool(Name = "list_resources", UseStructuredContent = true, ReadOnly = true, Idempotent = true, OpenWorld = false)]
+    [Description("Lists inventory resources with their key facts. All filters are optional and combine.")]
+    public Task<ResourceList> ListResources(
+        [Description("Only this kind: Server, Switch, Firewall, Router, Accesspoint, Desktop, Laptop, Ups, Other, System or Service.")]
+        string? kind = null,
+        [Description("Only resources carrying this tag.")]
+        string? tag = null,
+        [Description("Only resources carrying this label key.")]
+        string? labelKey = null) {
+        return ToolErrors.RunAsync(async () => {
+            IReadOnlyList<Resource> all = await repo.GetAllOfTypeAsync<Resource>();
+            IReadOnlyList<(Resource, string)> ips = await repo.GetResourceIpsAsync();
+
+            var ipByName = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
+            foreach ((Resource resource, var ip) in ips) ipByName[resource.Name] = ip;
+
+            var rows = all
+                .Where(r => kind is null || r.Kind.Equals(kind.Trim(), StringComparison.OrdinalIgnoreCase))
+                .Where(r => tag is null || r.Tags.Contains(tag.Trim(), StringComparer.OrdinalIgnoreCase))
+                .Where(r => labelKey is null || r.Labels.Keys.Contains(labelKey.Trim(), StringComparer.OrdinalIgnoreCase))
+                .OrderBy(r => r.Kind, StringComparer.OrdinalIgnoreCase)
+                .ThenBy(r => r.Name, StringComparer.OrdinalIgnoreCase)
+                .Select(r => new ResourceRow(
+                    r.Name,
+                    r.Kind,
+                    ipByName.GetValueOrDefault(r.Name),
+                    r.RunsOn,
+                    r.Tags,
+                    r.Labels))
+                .ToList();
+
+            return new ResourceList(rows.Count, rows);
+        });
+    }
+
+    [McpServerTool(Name = "get_resource", UseStructuredContent = true, ReadOnly = true, Idempotent = true, OpenWorld = false)]
+    [Description("Gets one resource in full, as a YAML document that can be edited and passed back to upsert_resources.")]
+    public Task<ResourceDetail> GetResource(
+        [Description("The resource's name.")] string name) {
+        return ToolErrors.RunAsync(async () => {
+            Resource resource = await repo.GetByNameAsync(name)
+                                ?? throw new NotFoundException($"Resource '{name}' not found.");
+
+            IReadOnlyList<Connection> connections = await repo.GetConnectionsForResourceAsync(resource.Name);
+            IReadOnlyList<Resource> dependants = await repo.GetDependantsAsync(resource.Name);
+
+            var yaml = YamlResourceCollection.SerializeRootAsync(new YamlRoot {
+                Version = RackPeekConfigMigrationDeserializer.ListOfMigrations.Count,
+                Resources = [resource],
+                Connections = connections.ToList()
+            });
+
+            return new ResourceDetail(yaml, dependants.Select(d => d.Name).ToList());
+        });
+    }
+
+    [McpServerTool(Name = "search_resources", UseStructuredContent = true, ReadOnly = true, Idempotent = true, OpenWorld = false)]
+    [Description("Free-text search over resource names, IPs, tags and labels; returns the best matches first.")]
+    public Task<SearchResults> SearchResources(
+        [Description("The search text.")] string query,
+        [Description("Maximum number of matches to return.")] int max = 8) {
+        return ToolErrors.RunAsync(async () => {
+            IReadOnlyList<Resource> all = await repo.GetAllOfTypeAsync<Resource>();
+            return new SearchResults(GlobalSearchService.Search(all, query, max));
+        });
+    }
+
+    [McpServerTool(Name = "get_summary", UseStructuredContent = true, ReadOnly = true, Idempotent = true, OpenWorld = false)]
+    [Description("Counts of everything in the inventory: hardware by kind, systems by type and OS, services, tags and labels.")]
+    public Task<InfrastructureSummary> GetSummary() {
+        return ToolErrors.RunAsync(async () => {
+            HardwareSummary hardware = await services.GetRequiredService<GetHardwareUseCaseSummary>().ExecuteAsync();
+            SystemSummary systems = await services.GetRequiredService<GetSystemSummaryUseCase>().ExecuteAsync();
+            AllServicesSummary allServices = await services.GetRequiredService<GetServiceSummaryUseCase>().ExecuteAsync();
+            Dictionary<string, int> tags = await repo.GetTagsAsync();
+            Dictionary<string, int> labels = await repo.GetLabelsAsync();
+
+            return new InfrastructureSummary(hardware, systems, allServices, tags, labels);
+        });
+    }
+
+    [McpServerTool(Name = "get_tree", UseStructuredContent = true, ReadOnly = true, Idempotent = true, OpenWorld = false)]
+    [Description("The containment forest: each hardware resource, the systems running on it, and the services running on those.")]
+    public Task<TreeResult> GetTree(
+        [Description("Only the tree under this hardware resource. Omit for the whole forest.")]
+        string? hardwareName = null) {
+        return ToolErrors.RunAsync(async () => {
+            List<HardwareTree> forest = await services.GetRequiredService<IHardwareRepository>().GetTreeAsync();
+
+            if (hardwareName is null) return new TreeResult(forest);
+
+            var match = forest
+                .Where(t => t.HardwareName.Equals(hardwareName.Trim(), StringComparison.OrdinalIgnoreCase))
+                .ToList();
+
+            if (match.Count == 0)
+                throw new NotFoundException($"Hardware '{hardwareName}' not found.");
+
+            return new TreeResult(match);
+        });
+    }
+
+    [McpServerTool(Name = "list_connections", UseStructuredContent = true, ReadOnly = true, Idempotent = true, OpenWorld = false)]
+    [Description("Physical port-to-port connections between hardware resources.")]
+    public Task<ConnectionList> ListConnections(
+        [Description("Only connections touching this resource. Omit for all connections.")]
+        string? resource = null) {
+        return ToolErrors.RunAsync(async () => {
+            IReadOnlyList<Connection> connections = resource is null
+                ? await repo.GetConnectionsAsync()
+                : await repo.GetConnectionsForResourceAsync(resource);
+
+            return new ConnectionList(connections.Count, connections);
+        });
+    }
+
+    [McpServerTool(Name = "get_subnets", UseStructuredContent = true, ReadOnly = true, Idempotent = true, OpenWorld = false)]
+    [Description("Groups service IPs into subnets, or lists the services inside one CIDR block.")]
+    public Task<ServiceSubnetsResult> GetSubnets(
+        [Description("A CIDR block like 192.168.1.0/24 to list the services inside it. Omit to group all service IPs into subnets instead.")]
+        string? cidr = null,
+        [Description("Prefix length used to group when no CIDR is given (default 24).")]
+        int? prefix = null,
+        CancellationToken cancellationToken = default) {
+        return ToolErrors.RunAsync(async () => {
+            ServiceSubnetsResult result = await services.GetRequiredService<ServiceSubnetsUseCase>()
+                .ExecuteAsync(cidr, prefix, cancellationToken);
+
+            if (result.IsInvalidCidr)
+                throw new McpException($"'{result.InvalidCidrValue}' is not a valid CIDR block. Use e.g. 192.168.1.0/24.");
+
+            return result;
+        });
+    }
+
+    [McpServerTool(Name = "get_schema", UseStructuredContent = true, ReadOnly = true, Idempotent = true, OpenWorld = false)]
+    [Description("The JSON schema and authoring rules for RackPeek inventory YAML — read this before writing documents for upsert_resources.")]
+    public Task<SchemaInfo> GetSchema() {
+        return ToolErrors.RunAsync(() => {
+            var version = RackPeekConfigMigrationDeserializer.ListOfMigrations.Count;
+            var resourceName = $"schema.v{version}.json";
+
+            using Stream? stream = typeof(QueryTools).Assembly.GetManifestResourceStream(resourceName);
+            if (stream is null)
+                throw new InvalidOperationException($"Embedded schema '{resourceName}' is missing from the build.");
+
+            using var reader = new StreamReader(stream);
+            var schema = reader.ReadToEnd();
+
+            return Task.FromResult(new SchemaInfo(version, schema, UpsertGuidance));
+        });
+    }
+}

+ 13 - 2
RackPeek.Web.Viewer/App.razor

@@ -1,4 +1,5 @@
-@using RackPeek.Domain.Persistence
+@using RackPeek.Domain.Helpers
+@using RackPeek.Domain.Persistence
 @using RackPeek.Web.Viewer.Pages
 @using Shared.Rcl.Servers
 @inject IResourceCollection Resources
@@ -23,7 +24,17 @@ else
 
     protected override async Task OnInitializedAsync()
     {
-        await Resources.LoadAsync();
+        try
+        {
+            await Resources.LoadAsync();
+        }
+        catch (ConfigLoadException)
+        {
+            // Same contract as the server host: a config that cannot be read must not
+            // leave the app stuck on "Loading…" — the YAML editor is how it is fixed.
+            // Here the store is browser storage, so this is a bad import rather than
+            // an interrupted write (#337).
+        }
 
         _ready = true;
     }

+ 2 - 2
RackPeek.Web.Viewer/RackPeek.Web.Viewer.csproj

@@ -8,8 +8,8 @@
     </PropertyGroup>
 
     <ItemGroup>
-        <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.12" />
-        <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="10.0.12" PrivateAssets="all" />
+        <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.13" />
+        <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="10.0.13" PrivateAssets="all" />
         <!-- Microsoft.DotNet.HotReload.WebAssembly.Browser is provided by the
              .NET 10 BlazorWebAssembly SDK — adding an explicit PackageReference
              causes NU1504 "duplicate PackageReference" during restore. -->

+ 28 - 2
RackPeek.Web.Viewer/wwwroot/schemas/v3/schema.v3.json

@@ -102,6 +102,9 @@
         {
           "$ref": "#/$defs/ups"
         },
+        {
+          "$ref": "#/$defs/other"
+        },
         {
           "$ref": "#/$defs/desktop"
         },
@@ -582,6 +585,28 @@
       ],
       "unevaluatedProperties": false
     },
+    "other": {
+      "allOf": [
+        {
+          "$ref": "#/$defs/resourceBase"
+        },
+        {
+          "type": "object",
+          "properties": {
+            "kind": {
+              "const": "Other"
+            },
+            "model": {
+              "type": "string"
+            },
+            "description": {
+              "type": "string"
+            }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
     "service": {
       "allOf": [
         {
@@ -639,7 +664,8 @@
               ]
             },
             "ip": {
-              "type": "string"
+              "type": "string",
+              "pattern": "^(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}$"
             },
             "os": {
               "type": "string"
@@ -664,4 +690,4 @@
       "unevaluatedProperties": false
     }
   }
-}
+}

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

@@ -0,0 +1,718 @@
+{
+  "$schema": "https://json-schema.org/draft/2020-12/schema",
+  "$id": "https://timmoth.github.io/RackPeek/schemas/v4/schema.v4.json",
+  "title": "RackPeek Infrastructure Specification",
+  "type": "object",
+  "additionalProperties": false,
+  "required": [
+    "version",
+    "resources"
+  ],
+  "properties": {
+    "version": {
+      "type": "integer",
+      "const": 4
+    },
+    "resources": {
+      "type": "array",
+      "items": {
+        "$ref": "#/$defs/resource"
+      }
+    },
+    "connections": {
+      "type": [
+        "array",
+        "null"
+      ],
+      "items": {
+        "$ref": "#/$defs/connection"
+      }
+    }
+  },
+  "$defs": {
+    "labels": {
+      "type": "object",
+      "additionalProperties": {
+        "type": "string"
+      }
+    },
+    "runsOn": {
+      "type": [
+        "array",
+        "null"
+      ],
+      "items": {
+        "type": "string",
+        "minLength": 1
+      }
+    },
+    "resourceBase": {
+      "type": "object",
+      "required": [
+        "kind",
+        "name"
+      ],
+      "properties": {
+        "kind": {
+          "type": "string"
+        },
+        "name": {
+          "type": "string",
+          "minLength": 1
+        },
+        "discoveryId": {
+          "type": [
+            "string",
+            "null"
+          ],
+          "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": {
+            "type": "string"
+          },
+          "default": []
+        },
+        "labels": {
+          "$ref": "#/$defs/labels",
+          "default": {}
+        },
+        "notes": {
+          "type": [
+            "string",
+            "null"
+          ]
+        },
+        "runsOn": {
+          "$ref": "#/$defs/runsOn"
+        }
+      }
+    },
+    "resource": {
+      "oneOf": [
+        {
+          "$ref": "#/$defs/server"
+        },
+        {
+          "$ref": "#/$defs/firewall"
+        },
+        {
+          "$ref": "#/$defs/router"
+        },
+        {
+          "$ref": "#/$defs/switch"
+        },
+        {
+          "$ref": "#/$defs/accessPoint"
+        },
+        {
+          "$ref": "#/$defs/ups"
+        },
+        {
+          "$ref": "#/$defs/other"
+        },
+        {
+          "$ref": "#/$defs/desktop"
+        },
+        {
+          "$ref": "#/$defs/laptop"
+        },
+        {
+          "$ref": "#/$defs/service"
+        },
+        {
+          "$ref": "#/$defs/system"
+        }
+      ]
+    },
+    "portReference": {
+      "type": "object",
+      "required": [
+        "resource",
+        "portGroup",
+        "portIndex"
+      ],
+      "additionalProperties": false,
+      "properties": {
+        "resource": {
+          "type": "string",
+          "minLength": 1
+        },
+        "portGroup": {
+          "type": "integer",
+          "minimum": 0
+        },
+        "portIndex": {
+          "type": "integer",
+          "minimum": 0
+        }
+      }
+    },
+    "connection": {
+      "type": "object",
+      "required": [
+        "a",
+        "b"
+      ],
+      "additionalProperties": false,
+      "properties": {
+        "a": {
+          "$ref": "#/$defs/portReference"
+        },
+        "b": {
+          "$ref": "#/$defs/portReference"
+        },
+        "label": {
+          "type": [
+            "string",
+            "null"
+          ]
+        },
+        "notes": {
+          "type": [
+            "string",
+            "null"
+          ]
+        }
+      }
+    },
+    "ram": {
+      "type": "object",
+      "required": [
+        "size"
+      ],
+      "additionalProperties": false,
+      "properties": {
+        "size": {
+          "type": "number",
+          "minimum": 0
+        },
+        "mts": {
+          "type": "integer",
+          "minimum": 0
+        }
+      }
+    },
+    "cpu": {
+      "type": "object",
+      "additionalProperties": false,
+      "properties": {
+        "model": {
+          "type": "string"
+        },
+        "cores": {
+          "type": "integer",
+          "minimum": 1
+        },
+        "threads": {
+          "type": "integer",
+          "minimum": 1
+        }
+      }
+    },
+    "drive": {
+      "type": "object",
+      "required": [
+        "size"
+      ],
+      "additionalProperties": false,
+      "properties": {
+        "type": {
+          "type": "string",
+          "enum": [
+            "nvme",
+            "ssd",
+            "hdd",
+            "sas",
+            "sata",
+            "usb",
+            "sdcard",
+            "micro-sd"
+          ]
+        },
+        "size": {
+          "type": "number",
+          "minimum": 1
+        }
+      }
+    },
+    "gpu": {
+      "type": "object",
+      "additionalProperties": false,
+      "properties": {
+        "model": {
+          "type": "string"
+        },
+        "vram": {
+          "type": "number",
+          "minimum": 0
+        }
+      }
+    },
+    "port": {
+      "type": "object",
+      "required": [
+        "type",
+        "speed",
+        "count"
+      ],
+      "additionalProperties": false,
+      "properties": {
+        "type": {
+          "type": "string",
+          "enum": [
+            "rj45",
+            "sfp",
+            "sfp+",
+            "sfp28",
+            "sfp56",
+            "qsfp+",
+            "qsfp28",
+            "qsfp56",
+            "qsfp-dd",
+            "osfp",
+            "xfp",
+            "cx4",
+            "mgmt",
+            "usb"
+          ]
+        },
+        "speed": {
+          "type": "number",
+          "minimum": 0
+        },
+        "count": {
+          "type": "integer",
+          "minimum": 1
+        }
+      }
+    },
+    "network": {
+      "type": "object",
+      "required": [
+        "ip",
+        "port",
+        "protocol"
+      ],
+      "additionalProperties": false,
+      "properties": {
+        "ip": {
+          "type": "string",
+          "pattern": "^(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}$"
+        },
+        "port": {
+          "type": "integer",
+          "minimum": 1,
+          "maximum": 65535
+        },
+        "protocol": {
+          "type": "string",
+          "enum": [
+            "TCP",
+            "UDP"
+          ]
+        },
+        "url": {
+          "type": "string",
+          "format": "uri"
+        }
+      }
+    },
+    "server": {
+      "allOf": [
+        {
+          "$ref": "#/$defs/resourceBase"
+        },
+        {
+          "type": "object",
+          "properties": {
+            "kind": {
+              "const": "Server"
+            },
+            "ram": {
+              "$ref": "#/$defs/ram"
+            },
+            "ipmi": {
+              "type": "boolean"
+            },
+            "cpus": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/cpu"
+              }
+            },
+            "drives": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/drive"
+              }
+            },
+            "gpus": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/gpu"
+              }
+            },
+            "ports": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/port"
+              }
+            }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+    "desktop": {
+      "allOf": [
+        {
+          "$ref": "#/$defs/resourceBase"
+        },
+        {
+          "type": "object",
+          "properties": {
+            "kind": {
+              "const": "Desktop"
+            },
+            "ram": {
+              "$ref": "#/$defs/ram"
+            },
+            "cpus": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/cpu"
+              }
+            },
+            "drives": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/drive"
+              }
+            },
+            "gpus": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/gpu"
+              }
+            },
+            "ports": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/port"
+              }
+            }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+    "laptop": {
+      "allOf": [
+        {
+          "$ref": "#/$defs/resourceBase"
+        },
+        {
+          "type": "object",
+          "properties": {
+            "kind": {
+              "const": "Laptop"
+            },
+            "ram": {
+              "$ref": "#/$defs/ram"
+            },
+            "cpus": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/cpu"
+              }
+            },
+            "drives": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/drive"
+              }
+            },
+            "ports": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/port"
+              }
+            }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+    "firewall": {
+      "allOf": [
+        {
+          "$ref": "#/$defs/resourceBase"
+        },
+        {
+          "type": "object",
+          "required": [
+            "ports"
+          ],
+          "properties": {
+            "kind": {
+              "const": "Firewall"
+            },
+            "model": {
+              "type": "string"
+            },
+            "managed": {
+              "type": "boolean"
+            },
+            "poe": {
+              "type": "boolean"
+            },
+            "ports": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/port"
+              }
+            }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+    "router": {
+      "allOf": [
+        {
+          "$ref": "#/$defs/resourceBase"
+        },
+        {
+          "type": "object",
+          "required": [
+            "ports"
+          ],
+          "properties": {
+            "kind": {
+              "const": "Router"
+            },
+            "model": {
+              "type": "string"
+            },
+            "managed": {
+              "type": "boolean"
+            },
+            "poe": {
+              "type": "boolean"
+            },
+            "ports": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/port"
+              }
+            }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+    "switch": {
+      "allOf": [
+        {
+          "$ref": "#/$defs/resourceBase"
+        },
+        {
+          "type": "object",
+          "required": [
+            "ports"
+          ],
+          "properties": {
+            "kind": {
+              "const": "Switch"
+            },
+            "model": {
+              "type": "string"
+            },
+            "managed": {
+              "type": "boolean"
+            },
+            "poe": {
+              "type": "boolean"
+            },
+            "ports": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/port"
+              }
+            }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+    "accessPoint": {
+      "allOf": [
+        {
+          "$ref": "#/$defs/resourceBase"
+        },
+        {
+          "type": "object",
+          "properties": {
+            "kind": {
+              "const": "AccessPoint"
+            },
+            "model": {
+              "type": "string"
+            },
+            "speed": {
+              "type": "number",
+              "minimum": 0
+            },
+            "ports": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/port"
+              }
+            }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+    "ups": {
+      "allOf": [
+        {
+          "$ref": "#/$defs/resourceBase"
+        },
+        {
+          "type": "object",
+          "properties": {
+            "kind": {
+              "const": "Ups"
+            },
+            "model": {
+              "type": "string"
+            },
+            "va": {
+              "type": "integer",
+              "minimum": 1
+            },
+            "ports": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/port"
+              }
+            }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+    "other": {
+      "allOf": [
+        {
+          "$ref": "#/$defs/resourceBase"
+        },
+        {
+          "type": "object",
+          "properties": {
+            "kind": {
+              "const": "Other"
+            },
+            "model": {
+              "type": "string"
+            },
+            "description": {
+              "type": "string"
+            },
+            "ports": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/port"
+              }
+            }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+    "service": {
+      "allOf": [
+        {
+          "$ref": "#/$defs/resourceBase"
+        },
+        {
+          "type": "object",
+          "required": [
+            "network"
+          ],
+          "properties": {
+            "kind": {
+              "const": "Service"
+            },
+            "network": {
+              "$ref": "#/$defs/network"
+            }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+    "system": {
+      "allOf": [
+        {
+          "$ref": "#/$defs/resourceBase"
+        },
+        {
+          "type": "object",
+          "properties": {
+            "kind": {
+              "const": "System"
+            },
+            "type": {
+              "type": "string",
+              "enum": [
+                "baremetal",
+                "Baremetal",
+                "cluster",
+                "Cluster",
+                "hypervisor",
+                "Hypervisor",
+                "vm",
+                "VM",
+                "container",
+                "embedded",
+                "cloud",
+                "other"
+              ]
+            },
+            "ip": {
+              "type": "string",
+              "pattern": "^(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}$"
+            },
+            "os": {
+              "type": "string"
+            },
+            "cores": {
+              "type": "integer",
+              "minimum": 1
+            },
+            "ram": {
+              "type": "number",
+              "minimum": 0
+            },
+            "drives": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/drive"
+              }
+            }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    }
+  }
+}

+ 724 - 0
RackPeek.Web.Viewer/wwwroot/schemas/v5/schema.v5.json

@@ -0,0 +1,724 @@
+{
+  "$schema": "https://json-schema.org/draft/2020-12/schema",
+  "$id": "https://timmoth.github.io/RackPeek/schemas/v5/schema.v5.json",
+  "title": "RackPeek Infrastructure Specification",
+  "type": "object",
+  "additionalProperties": false,
+  "required": [
+    "version",
+    "resources"
+  ],
+  "properties": {
+    "version": {
+      "type": "integer",
+      "const": 5
+    },
+    "resources": {
+      "type": "array",
+      "items": {
+        "$ref": "#/$defs/resource"
+      }
+    },
+    "connections": {
+      "type": [
+        "array",
+        "null"
+      ],
+      "items": {
+        "$ref": "#/$defs/connection"
+      }
+    }
+  },
+  "$defs": {
+    "labels": {
+      "type": "object",
+      "additionalProperties": {
+        "type": "string"
+      }
+    },
+    "runsOn": {
+      "type": [
+        "array",
+        "null"
+      ],
+      "items": {
+        "type": "string",
+        "minLength": 1
+      }
+    },
+    "resourceBase": {
+      "type": "object",
+      "required": [
+        "kind",
+        "name"
+      ],
+      "properties": {
+        "kind": {
+          "type": "string"
+        },
+        "name": {
+          "type": "string",
+          "minLength": 1
+        },
+        "discoveryId": {
+          "type": [
+            "string",
+            "null"
+          ],
+          "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": {
+            "type": "string"
+          },
+          "default": []
+        },
+        "labels": {
+          "$ref": "#/$defs/labels",
+          "default": {}
+        },
+        "notes": {
+          "type": [
+            "string",
+            "null"
+          ]
+        },
+        "runsOn": {
+          "$ref": "#/$defs/runsOn"
+        }
+      }
+    },
+    "resource": {
+      "oneOf": [
+        {
+          "$ref": "#/$defs/server"
+        },
+        {
+          "$ref": "#/$defs/firewall"
+        },
+        {
+          "$ref": "#/$defs/router"
+        },
+        {
+          "$ref": "#/$defs/switch"
+        },
+        {
+          "$ref": "#/$defs/accessPoint"
+        },
+        {
+          "$ref": "#/$defs/ups"
+        },
+        {
+          "$ref": "#/$defs/other"
+        },
+        {
+          "$ref": "#/$defs/desktop"
+        },
+        {
+          "$ref": "#/$defs/laptop"
+        },
+        {
+          "$ref": "#/$defs/service"
+        },
+        {
+          "$ref": "#/$defs/system"
+        }
+      ]
+    },
+    "portReference": {
+      "type": "object",
+      "required": [
+        "resource",
+        "portGroup",
+        "portIndex"
+      ],
+      "additionalProperties": false,
+      "properties": {
+        "resource": {
+          "type": "string",
+          "minLength": 1
+        },
+        "portGroup": {
+          "type": "integer",
+          "minimum": 0
+        },
+        "portIndex": {
+          "type": "integer",
+          "minimum": 0
+        }
+      }
+    },
+    "connection": {
+      "type": "object",
+      "required": [
+        "a",
+        "b"
+      ],
+      "additionalProperties": false,
+      "properties": {
+        "a": {
+          "$ref": "#/$defs/portReference"
+        },
+        "b": {
+          "$ref": "#/$defs/portReference"
+        },
+        "label": {
+          "type": [
+            "string",
+            "null"
+          ]
+        },
+        "notes": {
+          "type": [
+            "string",
+            "null"
+          ]
+        }
+      }
+    },
+    "ram": {
+      "type": "object",
+      "required": [
+        "size"
+      ],
+      "additionalProperties": false,
+      "properties": {
+        "size": {
+          "type": "number",
+          "minimum": 0
+        },
+        "mts": {
+          "type": "integer",
+          "minimum": 0
+        }
+      }
+    },
+    "cpu": {
+      "type": "object",
+      "additionalProperties": false,
+      "properties": {
+        "model": {
+          "type": "string"
+        },
+        "cores": {
+          "type": "integer",
+          "minimum": 1
+        },
+        "threads": {
+          "type": "integer",
+          "minimum": 1
+        }
+      }
+    },
+    "drive": {
+      "type": "object",
+      "required": [
+        "size"
+      ],
+      "additionalProperties": false,
+      "properties": {
+        "type": {
+          "type": "string",
+          "enum": [
+            "nvme",
+            "ssd",
+            "hdd",
+            "sas",
+            "sata",
+            "usb",
+            "sdcard",
+            "micro-sd"
+          ]
+        },
+        "size": {
+          "type": "number",
+          "minimum": 1
+        }
+      }
+    },
+    "gpu": {
+      "type": "object",
+      "additionalProperties": false,
+      "properties": {
+        "model": {
+          "type": "string"
+        },
+        "vram": {
+          "type": "number",
+          "minimum": 0
+        }
+      }
+    },
+    "port": {
+      "type": "object",
+      "required": [
+        "type",
+        "speed",
+        "count"
+      ],
+      "additionalProperties": false,
+      "properties": {
+        "type": {
+          "type": "string",
+          "enum": [
+            "rj45",
+            "sfp",
+            "sfp+",
+            "sfp28",
+            "sfp56",
+            "qsfp+",
+            "qsfp28",
+            "qsfp56",
+            "qsfp-dd",
+            "osfp",
+            "xfp",
+            "cx4",
+            "mgmt",
+            "usb"
+          ]
+        },
+        "speed": {
+          "type": "number",
+          "minimum": 0
+        },
+        "count": {
+          "type": "integer",
+          "minimum": 1
+        }
+      }
+    },
+    "network": {
+      "type": "object",
+      "required": [
+        "ip",
+        "port",
+        "protocol"
+      ],
+      "additionalProperties": false,
+      "properties": {
+        "ip": {
+          "type": "string",
+          "pattern": "^(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}$"
+        },
+        "port": {
+          "type": "integer",
+          "minimum": 1,
+          "maximum": 65535
+        },
+        "protocol": {
+          "type": "string",
+          "enum": [
+            "TCP",
+            "UDP"
+          ]
+        },
+        "url": {
+          "type": "string",
+          "format": "uri"
+        }
+      }
+    },
+    "server": {
+      "allOf": [
+        {
+          "$ref": "#/$defs/resourceBase"
+        },
+        {
+          "type": "object",
+          "properties": {
+            "kind": {
+              "const": "Server"
+            },
+            "ram": {
+              "$ref": "#/$defs/ram"
+            },
+            "ipmi": {
+              "type": "boolean"
+            },
+            "cpus": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/cpu"
+              }
+            },
+            "drives": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/drive"
+              }
+            },
+            "gpus": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/gpu"
+              }
+            },
+            "ports": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/port"
+              }
+            }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+    "desktop": {
+      "allOf": [
+        {
+          "$ref": "#/$defs/resourceBase"
+        },
+        {
+          "type": "object",
+          "properties": {
+            "kind": {
+              "const": "Desktop"
+            },
+            "ram": {
+              "$ref": "#/$defs/ram"
+            },
+            "cpus": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/cpu"
+              }
+            },
+            "drives": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/drive"
+              }
+            },
+            "gpus": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/gpu"
+              }
+            },
+            "ports": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/port"
+              }
+            }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+    "laptop": {
+      "allOf": [
+        {
+          "$ref": "#/$defs/resourceBase"
+        },
+        {
+          "type": "object",
+          "properties": {
+            "kind": {
+              "const": "Laptop"
+            },
+            "ram": {
+              "$ref": "#/$defs/ram"
+            },
+            "cpus": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/cpu"
+              }
+            },
+            "drives": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/drive"
+              }
+            },
+            "ports": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/port"
+              }
+            }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+    "firewall": {
+      "allOf": [
+        {
+          "$ref": "#/$defs/resourceBase"
+        },
+        {
+          "type": "object",
+          "required": [
+            "ports"
+          ],
+          "properties": {
+            "kind": {
+              "const": "Firewall"
+            },
+            "model": {
+              "type": "string"
+            },
+            "managed": {
+              "type": "boolean"
+            },
+            "poe": {
+              "type": "boolean"
+            },
+            "ports": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/port"
+              }
+            }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+    "router": {
+      "allOf": [
+        {
+          "$ref": "#/$defs/resourceBase"
+        },
+        {
+          "type": "object",
+          "required": [
+            "ports"
+          ],
+          "properties": {
+            "kind": {
+              "const": "Router"
+            },
+            "model": {
+              "type": "string"
+            },
+            "managed": {
+              "type": "boolean"
+            },
+            "poe": {
+              "type": "boolean"
+            },
+            "ports": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/port"
+              }
+            }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+    "switch": {
+      "allOf": [
+        {
+          "$ref": "#/$defs/resourceBase"
+        },
+        {
+          "type": "object",
+          "required": [
+            "ports"
+          ],
+          "properties": {
+            "kind": {
+              "const": "Switch"
+            },
+            "model": {
+              "type": "string"
+            },
+            "managed": {
+              "type": "boolean"
+            },
+            "poe": {
+              "type": "boolean"
+            },
+            "ports": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/port"
+              }
+            }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+    "accessPoint": {
+      "allOf": [
+        {
+          "$ref": "#/$defs/resourceBase"
+        },
+        {
+          "type": "object",
+          "properties": {
+            "kind": {
+              "const": "AccessPoint"
+            },
+            "model": {
+              "type": "string"
+            },
+            "speed": {
+              "type": "number",
+              "minimum": 0
+            },
+            "ports": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/port"
+              }
+            }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+    "ups": {
+      "allOf": [
+        {
+          "$ref": "#/$defs/resourceBase"
+        },
+        {
+          "type": "object",
+          "properties": {
+            "kind": {
+              "const": "Ups"
+            },
+            "model": {
+              "type": "string"
+            },
+            "va": {
+              "type": "integer",
+              "minimum": 1
+            },
+            "ports": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/port"
+              }
+            }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+    "other": {
+      "allOf": [
+        {
+          "$ref": "#/$defs/resourceBase"
+        },
+        {
+          "type": "object",
+          "properties": {
+            "kind": {
+              "const": "Other"
+            },
+            "model": {
+              "type": "string"
+            },
+            "description": {
+              "type": "string"
+            },
+            "ports": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/port"
+              }
+            }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+    "service": {
+      "allOf": [
+        {
+          "$ref": "#/$defs/resourceBase"
+        },
+        {
+          "type": "object",
+          "required": [
+            "network"
+          ],
+          "properties": {
+            "kind": {
+              "const": "Service"
+            },
+            "network": {
+              "$ref": "#/$defs/network"
+            },
+            "endpoints": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/network"
+              }
+            }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+    "system": {
+      "allOf": [
+        {
+          "$ref": "#/$defs/resourceBase"
+        },
+        {
+          "type": "object",
+          "properties": {
+            "kind": {
+              "const": "System"
+            },
+            "type": {
+              "type": "string",
+              "enum": [
+                "baremetal",
+                "Baremetal",
+                "cluster",
+                "Cluster",
+                "hypervisor",
+                "Hypervisor",
+                "vm",
+                "VM",
+                "container",
+                "embedded",
+                "cloud",
+                "other"
+              ]
+            },
+            "ip": {
+              "type": "string",
+              "pattern": "^(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}$"
+            },
+            "os": {
+              "type": "string"
+            },
+            "cores": {
+              "type": "integer",
+              "minimum": 1
+            },
+            "ram": {
+              "type": "number",
+              "minimum": 0
+            },
+            "drives": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/drive"
+              }
+            }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    }
+  }
+}

+ 14 - 0
RackPeek.Web/Api/ApiStatusEndpoints.cs

@@ -0,0 +1,14 @@
+using RackPeek.Domain;
+
+namespace RackPeek.Web.Api;
+
+public static class ApiStatusEndpoints {
+    public const string ApiVersion = "1";
+
+    public static void MapApiStatus(this WebApplication app) {
+        app.MapGet("/api/status", () => Results.Ok(new {
+            apiVersion = ApiVersion,
+            appVersion = RpkConstants.Version
+        }));
+    }
+}

+ 83 - 0
RackPeek.Web/Api/InventoryReadEndpoints.cs

@@ -0,0 +1,83 @@
+using System.ComponentModel.DataAnnotations;
+using RackPeek.Domain.Api;
+using RackPeek.Domain.Helpers;
+using RackPeek.Domain.Persistence;
+using RackPeek.Domain.Resources;
+using RackPeek.Domain.Resources.Hardware;
+using RackPeek.Domain.Resources.Services.UseCases;
+using RackPeek.Domain.Resources.SystemResources.UseCases;
+using RackPeek.Domain.Search;
+using RackPeek.Mcp.Tools;
+
+namespace RackPeek.Web.Api;
+
+/// <summary>
+///     Read-only counterparts to POST /api/inventory, gated by the same API key.
+///     The full document endpoint is the workhorse: a client fetches it once and
+///     renders lists, details and trees from it. Summary, search and tree mirror the
+///     MCP query tools and reuse their response records so the two surfaces cannot drift.
+/// </summary>
+public static class InventoryReadEndpoints {
+    public static void MapInventoryReadApi(this WebApplication app) {
+        RouteGroupBuilder group = app.MapGroup("/api/inventory")
+            .AddEndpointFilter<ApiKeyEndpointFilter>()
+            .DisableAntiforgery();
+
+        group.MapGet("", async (IResourceCollection repo) => {
+            try {
+                return Results.Json(await InventoryDocumentJson.BuildDocumentAsync(repo),
+                    InventoryDocumentJson.Options);
+            }
+            catch (Exception ex) {
+                return ReadError(ex);
+            }
+        });
+
+        group.MapGet("summary", async (
+            IResourceCollection repo,
+            GetHardwareUseCaseSummary hardwareSummary,
+            GetSystemSummaryUseCase systemsSummary,
+            GetServiceSummaryUseCase servicesSummary) => {
+                try {
+                    var result = new InfrastructureSummary(
+                        await hardwareSummary.ExecuteAsync(),
+                        await systemsSummary.ExecuteAsync(),
+                        await servicesSummary.ExecuteAsync(),
+                        await repo.GetTagsAsync(),
+                        await repo.GetLabelsAsync());
+
+                    return Results.Ok(result);
+                }
+                catch (Exception ex) {
+                    return ReadError(ex);
+                }
+            });
+
+        group.MapGet("search", async (string q, int? max, IResourceCollection repo) => {
+            try {
+                IReadOnlyList<Resource> all = await repo.GetAllOfTypeAsync<Resource>();
+
+                return Results.Ok(new SearchResults(
+                    GlobalSearchService.Search(all, q, max ?? 8)));
+            }
+            catch (Exception ex) {
+                return ReadError(ex);
+            }
+        });
+
+        group.MapGet("tree", async (IHardwareRepository repo) => {
+            try {
+                return Results.Ok(new TreeResult(await repo.GetTreeAsync()));
+            }
+            catch (Exception ex) {
+                return ReadError(ex);
+            }
+        });
+    }
+
+    private static IResult ReadError(Exception ex) => ex switch {
+        NotFoundException => Results.NotFound(new { error = ex.Message }),
+        ValidationException => Results.BadRequest(new { error = ex.Message }),
+        _ => Results.Problem(ex.Message)
+    };
+}

+ 12 - 2
RackPeek.Web/Components/Routes.razor

@@ -1,4 +1,5 @@
-@using RackPeek.Domain.Persistence
+@using RackPeek.Domain.Helpers
+@using RackPeek.Domain.Persistence
 @using RackPeek.Web.Components.Pages
 @using Shared.Rcl.Servers
 @inject IResourceCollection Resources
@@ -23,7 +24,16 @@ else
 
     protected override async Task OnInitializedAsync()
     {
-        await Resources.LoadAsync();
+        try
+        {
+            await Resources.LoadAsync();
+        }
+        catch (ConfigLoadException)
+        {
+            // A damaged config must not leave the app stuck on "Loading…" — the YAML
+            // editor is how someone repairs it. Pages that read the inventory surface
+            // the failure themselves; they can no longer report it as empty (#337).
+        }
 
         _ready = true;
     }

+ 40 - 0
RackPeek.Web/Program.cs

@@ -6,6 +6,8 @@ using RackPeek.Domain;
 using RackPeek.Domain.Git;
 using RackPeek.Domain.Persistence;
 using RackPeek.Domain.Persistence.Yaml;
+using ModelContextProtocol.AspNetCore;
+using RackPeek.Mcp;
 using RackPeek.Web.Api;
 using RackPeek.Web.Components;
 using Shared.Rcl;
@@ -88,12 +90,42 @@ public class Program {
         builder.Services.AddCommands();
         builder.Services.AddScoped<IConsoleEmulator, ConsoleEmulator>();
 
+        // MCP server, exposed over streamable HTTP at /mcp whenever the web server
+        // runs. Stateless: every call is a plain POST (no session affinity behind a
+        // reverse proxy) and tools resolve their services from the request scope,
+        // exactly like the inventory API does.
+        builder.Services.AddMcpServer(options => options.ServerInfo = new() {
+            Name = McpSetup.ServerName,
+            Version = RpkConstants.Version
+        })
+            .WithHttpTransport(options => options.SessionMode = HttpServerSessionMode.Stateless)
+            .WithRackPeekTools();
+
         // Razor Components
         builder.Services.AddRazorComponents()
             .AddInteractiveServerComponents();
 
         WebApplication app = builder.Build();
 
+        // Read the config into memory before anything can be served. Blazor reloads it
+        // on every circuit init, but the inventory API has no circuit — without this it
+        // would merge against an empty collection and persist that over the user's file,
+        // destroying the inventory on the first request after a restart.
+        await using (AsyncServiceScope scope = app.Services.CreateAsyncScope()) {
+            try {
+                await scope.ServiceProvider.GetRequiredService<IResourceCollection>().LoadAsync();
+            }
+            catch (Exception ex) {
+                // An unreadable config must not stop the server booting: the web UI is
+                // how someone fixes it, and a container that will not start is worse
+                // than one showing the error. Blazor surfaces it on the first page load,
+                // and every write path re-checks the load before persisting anything,
+                // so booting in this state cannot overwrite the file.
+                scope.ServiceProvider.GetRequiredService<ILogger<Program>>()
+                    .LogError(ex, "Could not read the config at {Path}. Fix it in the web UI.", yamlFilePath);
+            }
+        }
+
         if (!app.Environment.IsDevelopment()) {
             app.UseExceptionHandler("/Error");
             app.UseHsts();
@@ -105,7 +137,15 @@ public class Program {
         app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true);
         app.UseAntiforgery();
 
+        app.MapApiStatus();
         app.MapInventoryApi();
+        app.MapInventoryReadApi();
+
+        // Same key, same gate as /api/inventory: 503 until RPK_API_KEY is configured,
+        // so MCP is off by default and never exposes the inventory unauthenticated.
+        RouteGroupBuilder mcp = app.MapGroup("/mcp");
+        mcp.AddEndpointFilter<ApiKeyEndpointFilter>();
+        mcp.MapMcp();
 
         app.MapStaticAssets();
 

+ 6 - 1
RackPeek.Web/RackPeek.Web.csproj

@@ -13,7 +13,8 @@
     </PropertyGroup>
 
     <ItemGroup>
-        <ProjectReference Include="..\Shared.Rcl\Shared.Rcl.csproj"/>
+        <ProjectReference Include="..\Shared.Rcl\Shared.Rcl.csproj" />
+        <ProjectReference Include="..\RackPeek.Mcp\RackPeek.Mcp.csproj" />
     </ItemGroup>
 
     <ItemGroup>
@@ -22,4 +23,8 @@
         </Content>
     </ItemGroup>
 
+    <ItemGroup>
+      <PackageReference Include="ModelContextProtocol.AspNetCore" Version="2.3.0" />
+    </ItemGroup>
+
 </Project>

+ 28 - 2
RackPeek.Web/wwwroot/schemas/v3/schema.v3.json

@@ -102,6 +102,9 @@
         {
           "$ref": "#/$defs/ups"
         },
+        {
+          "$ref": "#/$defs/other"
+        },
         {
           "$ref": "#/$defs/desktop"
         },
@@ -582,6 +585,28 @@
       ],
       "unevaluatedProperties": false
     },
+    "other": {
+      "allOf": [
+        {
+          "$ref": "#/$defs/resourceBase"
+        },
+        {
+          "type": "object",
+          "properties": {
+            "kind": {
+              "const": "Other"
+            },
+            "model": {
+              "type": "string"
+            },
+            "description": {
+              "type": "string"
+            }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
     "service": {
       "allOf": [
         {
@@ -639,7 +664,8 @@
               ]
             },
             "ip": {
-              "type": "string"
+              "type": "string",
+              "pattern": "^(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}$"
             },
             "os": {
               "type": "string"
@@ -664,4 +690,4 @@
       "unevaluatedProperties": false
     }
   }
-}
+}

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

@@ -0,0 +1,718 @@
+{
+  "$schema": "https://json-schema.org/draft/2020-12/schema",
+  "$id": "https://timmoth.github.io/RackPeek/schemas/v4/schema.v4.json",
+  "title": "RackPeek Infrastructure Specification",
+  "type": "object",
+  "additionalProperties": false,
+  "required": [
+    "version",
+    "resources"
+  ],
+  "properties": {
+    "version": {
+      "type": "integer",
+      "const": 4
+    },
+    "resources": {
+      "type": "array",
+      "items": {
+        "$ref": "#/$defs/resource"
+      }
+    },
+    "connections": {
+      "type": [
+        "array",
+        "null"
+      ],
+      "items": {
+        "$ref": "#/$defs/connection"
+      }
+    }
+  },
+  "$defs": {
+    "labels": {
+      "type": "object",
+      "additionalProperties": {
+        "type": "string"
+      }
+    },
+    "runsOn": {
+      "type": [
+        "array",
+        "null"
+      ],
+      "items": {
+        "type": "string",
+        "minLength": 1
+      }
+    },
+    "resourceBase": {
+      "type": "object",
+      "required": [
+        "kind",
+        "name"
+      ],
+      "properties": {
+        "kind": {
+          "type": "string"
+        },
+        "name": {
+          "type": "string",
+          "minLength": 1
+        },
+        "discoveryId": {
+          "type": [
+            "string",
+            "null"
+          ],
+          "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": {
+            "type": "string"
+          },
+          "default": []
+        },
+        "labels": {
+          "$ref": "#/$defs/labels",
+          "default": {}
+        },
+        "notes": {
+          "type": [
+            "string",
+            "null"
+          ]
+        },
+        "runsOn": {
+          "$ref": "#/$defs/runsOn"
+        }
+      }
+    },
+    "resource": {
+      "oneOf": [
+        {
+          "$ref": "#/$defs/server"
+        },
+        {
+          "$ref": "#/$defs/firewall"
+        },
+        {
+          "$ref": "#/$defs/router"
+        },
+        {
+          "$ref": "#/$defs/switch"
+        },
+        {
+          "$ref": "#/$defs/accessPoint"
+        },
+        {
+          "$ref": "#/$defs/ups"
+        },
+        {
+          "$ref": "#/$defs/other"
+        },
+        {
+          "$ref": "#/$defs/desktop"
+        },
+        {
+          "$ref": "#/$defs/laptop"
+        },
+        {
+          "$ref": "#/$defs/service"
+        },
+        {
+          "$ref": "#/$defs/system"
+        }
+      ]
+    },
+    "portReference": {
+      "type": "object",
+      "required": [
+        "resource",
+        "portGroup",
+        "portIndex"
+      ],
+      "additionalProperties": false,
+      "properties": {
+        "resource": {
+          "type": "string",
+          "minLength": 1
+        },
+        "portGroup": {
+          "type": "integer",
+          "minimum": 0
+        },
+        "portIndex": {
+          "type": "integer",
+          "minimum": 0
+        }
+      }
+    },
+    "connection": {
+      "type": "object",
+      "required": [
+        "a",
+        "b"
+      ],
+      "additionalProperties": false,
+      "properties": {
+        "a": {
+          "$ref": "#/$defs/portReference"
+        },
+        "b": {
+          "$ref": "#/$defs/portReference"
+        },
+        "label": {
+          "type": [
+            "string",
+            "null"
+          ]
+        },
+        "notes": {
+          "type": [
+            "string",
+            "null"
+          ]
+        }
+      }
+    },
+    "ram": {
+      "type": "object",
+      "required": [
+        "size"
+      ],
+      "additionalProperties": false,
+      "properties": {
+        "size": {
+          "type": "number",
+          "minimum": 0
+        },
+        "mts": {
+          "type": "integer",
+          "minimum": 0
+        }
+      }
+    },
+    "cpu": {
+      "type": "object",
+      "additionalProperties": false,
+      "properties": {
+        "model": {
+          "type": "string"
+        },
+        "cores": {
+          "type": "integer",
+          "minimum": 1
+        },
+        "threads": {
+          "type": "integer",
+          "minimum": 1
+        }
+      }
+    },
+    "drive": {
+      "type": "object",
+      "required": [
+        "size"
+      ],
+      "additionalProperties": false,
+      "properties": {
+        "type": {
+          "type": "string",
+          "enum": [
+            "nvme",
+            "ssd",
+            "hdd",
+            "sas",
+            "sata",
+            "usb",
+            "sdcard",
+            "micro-sd"
+          ]
+        },
+        "size": {
+          "type": "number",
+          "minimum": 1
+        }
+      }
+    },
+    "gpu": {
+      "type": "object",
+      "additionalProperties": false,
+      "properties": {
+        "model": {
+          "type": "string"
+        },
+        "vram": {
+          "type": "number",
+          "minimum": 0
+        }
+      }
+    },
+    "port": {
+      "type": "object",
+      "required": [
+        "type",
+        "speed",
+        "count"
+      ],
+      "additionalProperties": false,
+      "properties": {
+        "type": {
+          "type": "string",
+          "enum": [
+            "rj45",
+            "sfp",
+            "sfp+",
+            "sfp28",
+            "sfp56",
+            "qsfp+",
+            "qsfp28",
+            "qsfp56",
+            "qsfp-dd",
+            "osfp",
+            "xfp",
+            "cx4",
+            "mgmt",
+            "usb"
+          ]
+        },
+        "speed": {
+          "type": "number",
+          "minimum": 0
+        },
+        "count": {
+          "type": "integer",
+          "minimum": 1
+        }
+      }
+    },
+    "network": {
+      "type": "object",
+      "required": [
+        "ip",
+        "port",
+        "protocol"
+      ],
+      "additionalProperties": false,
+      "properties": {
+        "ip": {
+          "type": "string",
+          "pattern": "^(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}$"
+        },
+        "port": {
+          "type": "integer",
+          "minimum": 1,
+          "maximum": 65535
+        },
+        "protocol": {
+          "type": "string",
+          "enum": [
+            "TCP",
+            "UDP"
+          ]
+        },
+        "url": {
+          "type": "string",
+          "format": "uri"
+        }
+      }
+    },
+    "server": {
+      "allOf": [
+        {
+          "$ref": "#/$defs/resourceBase"
+        },
+        {
+          "type": "object",
+          "properties": {
+            "kind": {
+              "const": "Server"
+            },
+            "ram": {
+              "$ref": "#/$defs/ram"
+            },
+            "ipmi": {
+              "type": "boolean"
+            },
+            "cpus": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/cpu"
+              }
+            },
+            "drives": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/drive"
+              }
+            },
+            "gpus": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/gpu"
+              }
+            },
+            "ports": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/port"
+              }
+            }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+    "desktop": {
+      "allOf": [
+        {
+          "$ref": "#/$defs/resourceBase"
+        },
+        {
+          "type": "object",
+          "properties": {
+            "kind": {
+              "const": "Desktop"
+            },
+            "ram": {
+              "$ref": "#/$defs/ram"
+            },
+            "cpus": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/cpu"
+              }
+            },
+            "drives": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/drive"
+              }
+            },
+            "gpus": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/gpu"
+              }
+            },
+            "ports": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/port"
+              }
+            }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+    "laptop": {
+      "allOf": [
+        {
+          "$ref": "#/$defs/resourceBase"
+        },
+        {
+          "type": "object",
+          "properties": {
+            "kind": {
+              "const": "Laptop"
+            },
+            "ram": {
+              "$ref": "#/$defs/ram"
+            },
+            "cpus": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/cpu"
+              }
+            },
+            "drives": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/drive"
+              }
+            },
+            "ports": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/port"
+              }
+            }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+    "firewall": {
+      "allOf": [
+        {
+          "$ref": "#/$defs/resourceBase"
+        },
+        {
+          "type": "object",
+          "required": [
+            "ports"
+          ],
+          "properties": {
+            "kind": {
+              "const": "Firewall"
+            },
+            "model": {
+              "type": "string"
+            },
+            "managed": {
+              "type": "boolean"
+            },
+            "poe": {
+              "type": "boolean"
+            },
+            "ports": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/port"
+              }
+            }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+    "router": {
+      "allOf": [
+        {
+          "$ref": "#/$defs/resourceBase"
+        },
+        {
+          "type": "object",
+          "required": [
+            "ports"
+          ],
+          "properties": {
+            "kind": {
+              "const": "Router"
+            },
+            "model": {
+              "type": "string"
+            },
+            "managed": {
+              "type": "boolean"
+            },
+            "poe": {
+              "type": "boolean"
+            },
+            "ports": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/port"
+              }
+            }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+    "switch": {
+      "allOf": [
+        {
+          "$ref": "#/$defs/resourceBase"
+        },
+        {
+          "type": "object",
+          "required": [
+            "ports"
+          ],
+          "properties": {
+            "kind": {
+              "const": "Switch"
+            },
+            "model": {
+              "type": "string"
+            },
+            "managed": {
+              "type": "boolean"
+            },
+            "poe": {
+              "type": "boolean"
+            },
+            "ports": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/port"
+              }
+            }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+    "accessPoint": {
+      "allOf": [
+        {
+          "$ref": "#/$defs/resourceBase"
+        },
+        {
+          "type": "object",
+          "properties": {
+            "kind": {
+              "const": "AccessPoint"
+            },
+            "model": {
+              "type": "string"
+            },
+            "speed": {
+              "type": "number",
+              "minimum": 0
+            },
+            "ports": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/port"
+              }
+            }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+    "ups": {
+      "allOf": [
+        {
+          "$ref": "#/$defs/resourceBase"
+        },
+        {
+          "type": "object",
+          "properties": {
+            "kind": {
+              "const": "Ups"
+            },
+            "model": {
+              "type": "string"
+            },
+            "va": {
+              "type": "integer",
+              "minimum": 1
+            },
+            "ports": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/port"
+              }
+            }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+    "other": {
+      "allOf": [
+        {
+          "$ref": "#/$defs/resourceBase"
+        },
+        {
+          "type": "object",
+          "properties": {
+            "kind": {
+              "const": "Other"
+            },
+            "model": {
+              "type": "string"
+            },
+            "description": {
+              "type": "string"
+            },
+            "ports": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/port"
+              }
+            }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+    "service": {
+      "allOf": [
+        {
+          "$ref": "#/$defs/resourceBase"
+        },
+        {
+          "type": "object",
+          "required": [
+            "network"
+          ],
+          "properties": {
+            "kind": {
+              "const": "Service"
+            },
+            "network": {
+              "$ref": "#/$defs/network"
+            }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+    "system": {
+      "allOf": [
+        {
+          "$ref": "#/$defs/resourceBase"
+        },
+        {
+          "type": "object",
+          "properties": {
+            "kind": {
+              "const": "System"
+            },
+            "type": {
+              "type": "string",
+              "enum": [
+                "baremetal",
+                "Baremetal",
+                "cluster",
+                "Cluster",
+                "hypervisor",
+                "Hypervisor",
+                "vm",
+                "VM",
+                "container",
+                "embedded",
+                "cloud",
+                "other"
+              ]
+            },
+            "ip": {
+              "type": "string",
+              "pattern": "^(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}$"
+            },
+            "os": {
+              "type": "string"
+            },
+            "cores": {
+              "type": "integer",
+              "minimum": 1
+            },
+            "ram": {
+              "type": "number",
+              "minimum": 0
+            },
+            "drives": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/drive"
+              }
+            }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    }
+  }
+}

+ 724 - 0
RackPeek.Web/wwwroot/schemas/v5/schema.v5.json

@@ -0,0 +1,724 @@
+{
+  "$schema": "https://json-schema.org/draft/2020-12/schema",
+  "$id": "https://timmoth.github.io/RackPeek/schemas/v5/schema.v5.json",
+  "title": "RackPeek Infrastructure Specification",
+  "type": "object",
+  "additionalProperties": false,
+  "required": [
+    "version",
+    "resources"
+  ],
+  "properties": {
+    "version": {
+      "type": "integer",
+      "const": 5
+    },
+    "resources": {
+      "type": "array",
+      "items": {
+        "$ref": "#/$defs/resource"
+      }
+    },
+    "connections": {
+      "type": [
+        "array",
+        "null"
+      ],
+      "items": {
+        "$ref": "#/$defs/connection"
+      }
+    }
+  },
+  "$defs": {
+    "labels": {
+      "type": "object",
+      "additionalProperties": {
+        "type": "string"
+      }
+    },
+    "runsOn": {
+      "type": [
+        "array",
+        "null"
+      ],
+      "items": {
+        "type": "string",
+        "minLength": 1
+      }
+    },
+    "resourceBase": {
+      "type": "object",
+      "required": [
+        "kind",
+        "name"
+      ],
+      "properties": {
+        "kind": {
+          "type": "string"
+        },
+        "name": {
+          "type": "string",
+          "minLength": 1
+        },
+        "discoveryId": {
+          "type": [
+            "string",
+            "null"
+          ],
+          "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": {
+            "type": "string"
+          },
+          "default": []
+        },
+        "labels": {
+          "$ref": "#/$defs/labels",
+          "default": {}
+        },
+        "notes": {
+          "type": [
+            "string",
+            "null"
+          ]
+        },
+        "runsOn": {
+          "$ref": "#/$defs/runsOn"
+        }
+      }
+    },
+    "resource": {
+      "oneOf": [
+        {
+          "$ref": "#/$defs/server"
+        },
+        {
+          "$ref": "#/$defs/firewall"
+        },
+        {
+          "$ref": "#/$defs/router"
+        },
+        {
+          "$ref": "#/$defs/switch"
+        },
+        {
+          "$ref": "#/$defs/accessPoint"
+        },
+        {
+          "$ref": "#/$defs/ups"
+        },
+        {
+          "$ref": "#/$defs/other"
+        },
+        {
+          "$ref": "#/$defs/desktop"
+        },
+        {
+          "$ref": "#/$defs/laptop"
+        },
+        {
+          "$ref": "#/$defs/service"
+        },
+        {
+          "$ref": "#/$defs/system"
+        }
+      ]
+    },
+    "portReference": {
+      "type": "object",
+      "required": [
+        "resource",
+        "portGroup",
+        "portIndex"
+      ],
+      "additionalProperties": false,
+      "properties": {
+        "resource": {
+          "type": "string",
+          "minLength": 1
+        },
+        "portGroup": {
+          "type": "integer",
+          "minimum": 0
+        },
+        "portIndex": {
+          "type": "integer",
+          "minimum": 0
+        }
+      }
+    },
+    "connection": {
+      "type": "object",
+      "required": [
+        "a",
+        "b"
+      ],
+      "additionalProperties": false,
+      "properties": {
+        "a": {
+          "$ref": "#/$defs/portReference"
+        },
+        "b": {
+          "$ref": "#/$defs/portReference"
+        },
+        "label": {
+          "type": [
+            "string",
+            "null"
+          ]
+        },
+        "notes": {
+          "type": [
+            "string",
+            "null"
+          ]
+        }
+      }
+    },
+    "ram": {
+      "type": "object",
+      "required": [
+        "size"
+      ],
+      "additionalProperties": false,
+      "properties": {
+        "size": {
+          "type": "number",
+          "minimum": 0
+        },
+        "mts": {
+          "type": "integer",
+          "minimum": 0
+        }
+      }
+    },
+    "cpu": {
+      "type": "object",
+      "additionalProperties": false,
+      "properties": {
+        "model": {
+          "type": "string"
+        },
+        "cores": {
+          "type": "integer",
+          "minimum": 1
+        },
+        "threads": {
+          "type": "integer",
+          "minimum": 1
+        }
+      }
+    },
+    "drive": {
+      "type": "object",
+      "required": [
+        "size"
+      ],
+      "additionalProperties": false,
+      "properties": {
+        "type": {
+          "type": "string",
+          "enum": [
+            "nvme",
+            "ssd",
+            "hdd",
+            "sas",
+            "sata",
+            "usb",
+            "sdcard",
+            "micro-sd"
+          ]
+        },
+        "size": {
+          "type": "number",
+          "minimum": 1
+        }
+      }
+    },
+    "gpu": {
+      "type": "object",
+      "additionalProperties": false,
+      "properties": {
+        "model": {
+          "type": "string"
+        },
+        "vram": {
+          "type": "number",
+          "minimum": 0
+        }
+      }
+    },
+    "port": {
+      "type": "object",
+      "required": [
+        "type",
+        "speed",
+        "count"
+      ],
+      "additionalProperties": false,
+      "properties": {
+        "type": {
+          "type": "string",
+          "enum": [
+            "rj45",
+            "sfp",
+            "sfp+",
+            "sfp28",
+            "sfp56",
+            "qsfp+",
+            "qsfp28",
+            "qsfp56",
+            "qsfp-dd",
+            "osfp",
+            "xfp",
+            "cx4",
+            "mgmt",
+            "usb"
+          ]
+        },
+        "speed": {
+          "type": "number",
+          "minimum": 0
+        },
+        "count": {
+          "type": "integer",
+          "minimum": 1
+        }
+      }
+    },
+    "network": {
+      "type": "object",
+      "required": [
+        "ip",
+        "port",
+        "protocol"
+      ],
+      "additionalProperties": false,
+      "properties": {
+        "ip": {
+          "type": "string",
+          "pattern": "^(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}$"
+        },
+        "port": {
+          "type": "integer",
+          "minimum": 1,
+          "maximum": 65535
+        },
+        "protocol": {
+          "type": "string",
+          "enum": [
+            "TCP",
+            "UDP"
+          ]
+        },
+        "url": {
+          "type": "string",
+          "format": "uri"
+        }
+      }
+    },
+    "server": {
+      "allOf": [
+        {
+          "$ref": "#/$defs/resourceBase"
+        },
+        {
+          "type": "object",
+          "properties": {
+            "kind": {
+              "const": "Server"
+            },
+            "ram": {
+              "$ref": "#/$defs/ram"
+            },
+            "ipmi": {
+              "type": "boolean"
+            },
+            "cpus": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/cpu"
+              }
+            },
+            "drives": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/drive"
+              }
+            },
+            "gpus": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/gpu"
+              }
+            },
+            "ports": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/port"
+              }
+            }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+    "desktop": {
+      "allOf": [
+        {
+          "$ref": "#/$defs/resourceBase"
+        },
+        {
+          "type": "object",
+          "properties": {
+            "kind": {
+              "const": "Desktop"
+            },
+            "ram": {
+              "$ref": "#/$defs/ram"
+            },
+            "cpus": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/cpu"
+              }
+            },
+            "drives": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/drive"
+              }
+            },
+            "gpus": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/gpu"
+              }
+            },
+            "ports": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/port"
+              }
+            }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+    "laptop": {
+      "allOf": [
+        {
+          "$ref": "#/$defs/resourceBase"
+        },
+        {
+          "type": "object",
+          "properties": {
+            "kind": {
+              "const": "Laptop"
+            },
+            "ram": {
+              "$ref": "#/$defs/ram"
+            },
+            "cpus": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/cpu"
+              }
+            },
+            "drives": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/drive"
+              }
+            },
+            "ports": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/port"
+              }
+            }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+    "firewall": {
+      "allOf": [
+        {
+          "$ref": "#/$defs/resourceBase"
+        },
+        {
+          "type": "object",
+          "required": [
+            "ports"
+          ],
+          "properties": {
+            "kind": {
+              "const": "Firewall"
+            },
+            "model": {
+              "type": "string"
+            },
+            "managed": {
+              "type": "boolean"
+            },
+            "poe": {
+              "type": "boolean"
+            },
+            "ports": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/port"
+              }
+            }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+    "router": {
+      "allOf": [
+        {
+          "$ref": "#/$defs/resourceBase"
+        },
+        {
+          "type": "object",
+          "required": [
+            "ports"
+          ],
+          "properties": {
+            "kind": {
+              "const": "Router"
+            },
+            "model": {
+              "type": "string"
+            },
+            "managed": {
+              "type": "boolean"
+            },
+            "poe": {
+              "type": "boolean"
+            },
+            "ports": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/port"
+              }
+            }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+    "switch": {
+      "allOf": [
+        {
+          "$ref": "#/$defs/resourceBase"
+        },
+        {
+          "type": "object",
+          "required": [
+            "ports"
+          ],
+          "properties": {
+            "kind": {
+              "const": "Switch"
+            },
+            "model": {
+              "type": "string"
+            },
+            "managed": {
+              "type": "boolean"
+            },
+            "poe": {
+              "type": "boolean"
+            },
+            "ports": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/port"
+              }
+            }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+    "accessPoint": {
+      "allOf": [
+        {
+          "$ref": "#/$defs/resourceBase"
+        },
+        {
+          "type": "object",
+          "properties": {
+            "kind": {
+              "const": "AccessPoint"
+            },
+            "model": {
+              "type": "string"
+            },
+            "speed": {
+              "type": "number",
+              "minimum": 0
+            },
+            "ports": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/port"
+              }
+            }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+    "ups": {
+      "allOf": [
+        {
+          "$ref": "#/$defs/resourceBase"
+        },
+        {
+          "type": "object",
+          "properties": {
+            "kind": {
+              "const": "Ups"
+            },
+            "model": {
+              "type": "string"
+            },
+            "va": {
+              "type": "integer",
+              "minimum": 1
+            },
+            "ports": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/port"
+              }
+            }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+    "other": {
+      "allOf": [
+        {
+          "$ref": "#/$defs/resourceBase"
+        },
+        {
+          "type": "object",
+          "properties": {
+            "kind": {
+              "const": "Other"
+            },
+            "model": {
+              "type": "string"
+            },
+            "description": {
+              "type": "string"
+            },
+            "ports": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/port"
+              }
+            }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+    "service": {
+      "allOf": [
+        {
+          "$ref": "#/$defs/resourceBase"
+        },
+        {
+          "type": "object",
+          "required": [
+            "network"
+          ],
+          "properties": {
+            "kind": {
+              "const": "Service"
+            },
+            "network": {
+              "$ref": "#/$defs/network"
+            },
+            "endpoints": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/network"
+              }
+            }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+    "system": {
+      "allOf": [
+        {
+          "$ref": "#/$defs/resourceBase"
+        },
+        {
+          "type": "object",
+          "properties": {
+            "kind": {
+              "const": "System"
+            },
+            "type": {
+              "type": "string",
+              "enum": [
+                "baremetal",
+                "Baremetal",
+                "cluster",
+                "Cluster",
+                "hypervisor",
+                "Hypervisor",
+                "vm",
+                "VM",
+                "container",
+                "embedded",
+                "cloud",
+                "other"
+              ]
+            },
+            "ip": {
+              "type": "string",
+              "pattern": "^(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}$"
+            },
+            "os": {
+              "type": "string"
+            },
+            "cores": {
+              "type": "integer",
+              "minimum": 1
+            },
+            "ram": {
+              "type": "number",
+              "minimum": 0
+            },
+            "drives": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/drive"
+              }
+            }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    }
+  }
+}

+ 105 - 0
RackPeek.sln

@@ -14,39 +14,144 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RackPeek.Web.Viewer", "Rack
 EndProject
 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tests.E2e", "Tests.E2e\Tests.E2e.csproj", "{47288A74-AD2C-4E5A-BD88-45648EA9029E}"
 EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tests.Discovery", "Tests.Discovery\Tests.Discovery.csproj", "{EB946412-1FE8-4F5A-BBC8-99BB5E04550C}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RackPeek.Mcp", "RackPeek.Mcp\RackPeek.Mcp.csproj", "{4038A024-B71F-4126-9310-5DC109AD5616}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tests.Mcp", "Tests.Mcp\Tests.Mcp.csproj", "{F82DD17A-0997-44B3-B3A3-15FC9E676427}"
+EndProject
 Global
 	GlobalSection(SolutionConfigurationPlatforms) = preSolution
 		Debug|Any CPU = Debug|Any CPU
+		Debug|x64 = Debug|x64
+		Debug|x86 = Debug|x86
 		Release|Any CPU = Release|Any CPU
+		Release|x64 = Release|x64
+		Release|x86 = Release|x86
 	EndGlobalSection
 	GlobalSection(ProjectConfigurationPlatforms) = postSolution
 		{EFB7357E-A6B7-4359-BA0F-45D733849E4C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
 		{EFB7357E-A6B7-4359-BA0F-45D733849E4C}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{EFB7357E-A6B7-4359-BA0F-45D733849E4C}.Debug|x64.ActiveCfg = Debug|Any CPU
+		{EFB7357E-A6B7-4359-BA0F-45D733849E4C}.Debug|x64.Build.0 = Debug|Any CPU
+		{EFB7357E-A6B7-4359-BA0F-45D733849E4C}.Debug|x86.ActiveCfg = Debug|Any CPU
+		{EFB7357E-A6B7-4359-BA0F-45D733849E4C}.Debug|x86.Build.0 = Debug|Any CPU
 		{EFB7357E-A6B7-4359-BA0F-45D733849E4C}.Release|Any CPU.ActiveCfg = Release|Any CPU
 		{EFB7357E-A6B7-4359-BA0F-45D733849E4C}.Release|Any CPU.Build.0 = Release|Any CPU
+		{EFB7357E-A6B7-4359-BA0F-45D733849E4C}.Release|x64.ActiveCfg = Release|Any CPU
+		{EFB7357E-A6B7-4359-BA0F-45D733849E4C}.Release|x64.Build.0 = Release|Any CPU
+		{EFB7357E-A6B7-4359-BA0F-45D733849E4C}.Release|x86.ActiveCfg = Release|Any CPU
+		{EFB7357E-A6B7-4359-BA0F-45D733849E4C}.Release|x86.Build.0 = Release|Any CPU
 		{2B19149A-FBD7-415E-9FE3-3BA53F2A0DD8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
 		{2B19149A-FBD7-415E-9FE3-3BA53F2A0DD8}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{2B19149A-FBD7-415E-9FE3-3BA53F2A0DD8}.Debug|x64.ActiveCfg = Debug|Any CPU
+		{2B19149A-FBD7-415E-9FE3-3BA53F2A0DD8}.Debug|x64.Build.0 = Debug|Any CPU
+		{2B19149A-FBD7-415E-9FE3-3BA53F2A0DD8}.Debug|x86.ActiveCfg = Debug|Any CPU
+		{2B19149A-FBD7-415E-9FE3-3BA53F2A0DD8}.Debug|x86.Build.0 = Debug|Any CPU
 		{2B19149A-FBD7-415E-9FE3-3BA53F2A0DD8}.Release|Any CPU.ActiveCfg = Release|Any CPU
 		{2B19149A-FBD7-415E-9FE3-3BA53F2A0DD8}.Release|Any CPU.Build.0 = Release|Any CPU
+		{2B19149A-FBD7-415E-9FE3-3BA53F2A0DD8}.Release|x64.ActiveCfg = Release|Any CPU
+		{2B19149A-FBD7-415E-9FE3-3BA53F2A0DD8}.Release|x64.Build.0 = Release|Any CPU
+		{2B19149A-FBD7-415E-9FE3-3BA53F2A0DD8}.Release|x86.ActiveCfg = Release|Any CPU
+		{2B19149A-FBD7-415E-9FE3-3BA53F2A0DD8}.Release|x86.Build.0 = Release|Any CPU
 		{760E6165-A7E0-4144-B289-1BAB6483FD29}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
 		{760E6165-A7E0-4144-B289-1BAB6483FD29}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{760E6165-A7E0-4144-B289-1BAB6483FD29}.Debug|x64.ActiveCfg = Debug|Any CPU
+		{760E6165-A7E0-4144-B289-1BAB6483FD29}.Debug|x64.Build.0 = Debug|Any CPU
+		{760E6165-A7E0-4144-B289-1BAB6483FD29}.Debug|x86.ActiveCfg = Debug|Any CPU
+		{760E6165-A7E0-4144-B289-1BAB6483FD29}.Debug|x86.Build.0 = Debug|Any CPU
 		{760E6165-A7E0-4144-B289-1BAB6483FD29}.Release|Any CPU.ActiveCfg = Release|Any CPU
 		{760E6165-A7E0-4144-B289-1BAB6483FD29}.Release|Any CPU.Build.0 = Release|Any CPU
+		{760E6165-A7E0-4144-B289-1BAB6483FD29}.Release|x64.ActiveCfg = Release|Any CPU
+		{760E6165-A7E0-4144-B289-1BAB6483FD29}.Release|x64.Build.0 = Release|Any CPU
+		{760E6165-A7E0-4144-B289-1BAB6483FD29}.Release|x86.ActiveCfg = Release|Any CPU
+		{760E6165-A7E0-4144-B289-1BAB6483FD29}.Release|x86.Build.0 = Release|Any CPU
 		{7E5A9ABE-B350-4D1B-86E5-03D9CDF44F84}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
 		{7E5A9ABE-B350-4D1B-86E5-03D9CDF44F84}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{7E5A9ABE-B350-4D1B-86E5-03D9CDF44F84}.Debug|x64.ActiveCfg = Debug|Any CPU
+		{7E5A9ABE-B350-4D1B-86E5-03D9CDF44F84}.Debug|x64.Build.0 = Debug|Any CPU
+		{7E5A9ABE-B350-4D1B-86E5-03D9CDF44F84}.Debug|x86.ActiveCfg = Debug|Any CPU
+		{7E5A9ABE-B350-4D1B-86E5-03D9CDF44F84}.Debug|x86.Build.0 = Debug|Any CPU
 		{7E5A9ABE-B350-4D1B-86E5-03D9CDF44F84}.Release|Any CPU.ActiveCfg = Release|Any CPU
 		{7E5A9ABE-B350-4D1B-86E5-03D9CDF44F84}.Release|Any CPU.Build.0 = Release|Any CPU
+		{7E5A9ABE-B350-4D1B-86E5-03D9CDF44F84}.Release|x64.ActiveCfg = Release|Any CPU
+		{7E5A9ABE-B350-4D1B-86E5-03D9CDF44F84}.Release|x64.Build.0 = Release|Any CPU
+		{7E5A9ABE-B350-4D1B-86E5-03D9CDF44F84}.Release|x86.ActiveCfg = Release|Any CPU
+		{7E5A9ABE-B350-4D1B-86E5-03D9CDF44F84}.Release|x86.Build.0 = Release|Any CPU
 		{5064A98A-A918-46A8-B44C-DB2720994643}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
 		{5064A98A-A918-46A8-B44C-DB2720994643}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{5064A98A-A918-46A8-B44C-DB2720994643}.Debug|x64.ActiveCfg = Debug|Any CPU
+		{5064A98A-A918-46A8-B44C-DB2720994643}.Debug|x64.Build.0 = Debug|Any CPU
+		{5064A98A-A918-46A8-B44C-DB2720994643}.Debug|x86.ActiveCfg = Debug|Any CPU
+		{5064A98A-A918-46A8-B44C-DB2720994643}.Debug|x86.Build.0 = Debug|Any CPU
 		{5064A98A-A918-46A8-B44C-DB2720994643}.Release|Any CPU.ActiveCfg = Release|Any CPU
 		{5064A98A-A918-46A8-B44C-DB2720994643}.Release|Any CPU.Build.0 = Release|Any CPU
+		{5064A98A-A918-46A8-B44C-DB2720994643}.Release|x64.ActiveCfg = Release|Any CPU
+		{5064A98A-A918-46A8-B44C-DB2720994643}.Release|x64.Build.0 = Release|Any CPU
+		{5064A98A-A918-46A8-B44C-DB2720994643}.Release|x86.ActiveCfg = Release|Any CPU
+		{5064A98A-A918-46A8-B44C-DB2720994643}.Release|x86.Build.0 = Release|Any CPU
 		{C8A622F1-0B7C-43DF-86E0-8FE25A5A5E0F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
 		{C8A622F1-0B7C-43DF-86E0-8FE25A5A5E0F}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{C8A622F1-0B7C-43DF-86E0-8FE25A5A5E0F}.Debug|x64.ActiveCfg = Debug|Any CPU
+		{C8A622F1-0B7C-43DF-86E0-8FE25A5A5E0F}.Debug|x64.Build.0 = Debug|Any CPU
+		{C8A622F1-0B7C-43DF-86E0-8FE25A5A5E0F}.Debug|x86.ActiveCfg = Debug|Any CPU
+		{C8A622F1-0B7C-43DF-86E0-8FE25A5A5E0F}.Debug|x86.Build.0 = Debug|Any CPU
 		{C8A622F1-0B7C-43DF-86E0-8FE25A5A5E0F}.Release|Any CPU.ActiveCfg = Release|Any CPU
 		{C8A622F1-0B7C-43DF-86E0-8FE25A5A5E0F}.Release|Any CPU.Build.0 = Release|Any CPU
+		{C8A622F1-0B7C-43DF-86E0-8FE25A5A5E0F}.Release|x64.ActiveCfg = Release|Any CPU
+		{C8A622F1-0B7C-43DF-86E0-8FE25A5A5E0F}.Release|x64.Build.0 = Release|Any CPU
+		{C8A622F1-0B7C-43DF-86E0-8FE25A5A5E0F}.Release|x86.ActiveCfg = Release|Any CPU
+		{C8A622F1-0B7C-43DF-86E0-8FE25A5A5E0F}.Release|x86.Build.0 = Release|Any CPU
 		{47288A74-AD2C-4E5A-BD88-45648EA9029E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
 		{47288A74-AD2C-4E5A-BD88-45648EA9029E}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{47288A74-AD2C-4E5A-BD88-45648EA9029E}.Debug|x64.ActiveCfg = Debug|Any CPU
+		{47288A74-AD2C-4E5A-BD88-45648EA9029E}.Debug|x64.Build.0 = Debug|Any CPU
+		{47288A74-AD2C-4E5A-BD88-45648EA9029E}.Debug|x86.ActiveCfg = Debug|Any CPU
+		{47288A74-AD2C-4E5A-BD88-45648EA9029E}.Debug|x86.Build.0 = Debug|Any CPU
 		{47288A74-AD2C-4E5A-BD88-45648EA9029E}.Release|Any CPU.ActiveCfg = Release|Any CPU
 		{47288A74-AD2C-4E5A-BD88-45648EA9029E}.Release|Any CPU.Build.0 = Release|Any CPU
+		{47288A74-AD2C-4E5A-BD88-45648EA9029E}.Release|x64.ActiveCfg = Release|Any CPU
+		{47288A74-AD2C-4E5A-BD88-45648EA9029E}.Release|x64.Build.0 = Release|Any CPU
+		{47288A74-AD2C-4E5A-BD88-45648EA9029E}.Release|x86.ActiveCfg = Release|Any CPU
+		{47288A74-AD2C-4E5A-BD88-45648EA9029E}.Release|x86.Build.0 = Release|Any CPU
+		{EB946412-1FE8-4F5A-BBC8-99BB5E04550C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{EB946412-1FE8-4F5A-BBC8-99BB5E04550C}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{EB946412-1FE8-4F5A-BBC8-99BB5E04550C}.Debug|x64.ActiveCfg = Debug|Any CPU
+		{EB946412-1FE8-4F5A-BBC8-99BB5E04550C}.Debug|x64.Build.0 = Debug|Any CPU
+		{EB946412-1FE8-4F5A-BBC8-99BB5E04550C}.Debug|x86.ActiveCfg = Debug|Any CPU
+		{EB946412-1FE8-4F5A-BBC8-99BB5E04550C}.Debug|x86.Build.0 = Debug|Any CPU
+		{EB946412-1FE8-4F5A-BBC8-99BB5E04550C}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{EB946412-1FE8-4F5A-BBC8-99BB5E04550C}.Release|Any CPU.Build.0 = Release|Any CPU
+		{EB946412-1FE8-4F5A-BBC8-99BB5E04550C}.Release|x64.ActiveCfg = Release|Any CPU
+		{EB946412-1FE8-4F5A-BBC8-99BB5E04550C}.Release|x64.Build.0 = Release|Any CPU
+		{EB946412-1FE8-4F5A-BBC8-99BB5E04550C}.Release|x86.ActiveCfg = Release|Any CPU
+		{EB946412-1FE8-4F5A-BBC8-99BB5E04550C}.Release|x86.Build.0 = Release|Any CPU
+		{4038A024-B71F-4126-9310-5DC109AD5616}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{4038A024-B71F-4126-9310-5DC109AD5616}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{4038A024-B71F-4126-9310-5DC109AD5616}.Debug|x64.ActiveCfg = Debug|Any CPU
+		{4038A024-B71F-4126-9310-5DC109AD5616}.Debug|x64.Build.0 = Debug|Any CPU
+		{4038A024-B71F-4126-9310-5DC109AD5616}.Debug|x86.ActiveCfg = Debug|Any CPU
+		{4038A024-B71F-4126-9310-5DC109AD5616}.Debug|x86.Build.0 = Debug|Any CPU
+		{4038A024-B71F-4126-9310-5DC109AD5616}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{4038A024-B71F-4126-9310-5DC109AD5616}.Release|Any CPU.Build.0 = Release|Any CPU
+		{4038A024-B71F-4126-9310-5DC109AD5616}.Release|x64.ActiveCfg = Release|Any CPU
+		{4038A024-B71F-4126-9310-5DC109AD5616}.Release|x64.Build.0 = Release|Any CPU
+		{4038A024-B71F-4126-9310-5DC109AD5616}.Release|x86.ActiveCfg = Release|Any CPU
+		{4038A024-B71F-4126-9310-5DC109AD5616}.Release|x86.Build.0 = Release|Any CPU
+		{F82DD17A-0997-44B3-B3A3-15FC9E676427}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{F82DD17A-0997-44B3-B3A3-15FC9E676427}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{F82DD17A-0997-44B3-B3A3-15FC9E676427}.Debug|x64.ActiveCfg = Debug|Any CPU
+		{F82DD17A-0997-44B3-B3A3-15FC9E676427}.Debug|x64.Build.0 = Debug|Any CPU
+		{F82DD17A-0997-44B3-B3A3-15FC9E676427}.Debug|x86.ActiveCfg = Debug|Any CPU
+		{F82DD17A-0997-44B3-B3A3-15FC9E676427}.Debug|x86.Build.0 = Debug|Any CPU
+		{F82DD17A-0997-44B3-B3A3-15FC9E676427}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{F82DD17A-0997-44B3-B3A3-15FC9E676427}.Release|Any CPU.Build.0 = Release|Any CPU
+		{F82DD17A-0997-44B3-B3A3-15FC9E676427}.Release|x64.ActiveCfg = Release|Any CPU
+		{F82DD17A-0997-44B3-B3A3-15FC9E676427}.Release|x64.Build.0 = Release|Any CPU
+		{F82DD17A-0997-44B3-B3A3-15FC9E676427}.Release|x86.ActiveCfg = Release|Any CPU
+		{F82DD17A-0997-44B3-B3A3-15FC9E676427}.Release|x86.Build.0 = Release|Any CPU
+	EndGlobalSection
+	GlobalSection(SolutionProperties) = preSolution
+		HideSolutionNode = FALSE
 	EndGlobalSection
 EndGlobal

+ 8 - 8
RackPeek/RackPeek.csproj

@@ -5,17 +5,17 @@
         <TargetFramework>net10.0</TargetFramework>
         <ImplicitUsings>enable</ImplicitUsings>
         <Nullable>enable</Nullable>
-        <AssemblyVersion>2.1.0</AssemblyVersion>
+        <AssemblyVersion>2.3.0</AssemblyVersion>
     </PropertyGroup>
 
     <ItemGroup>
-        <PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.12"/>
-        <PackageReference Include="Microsoft.Extensions.Configuration.FileExtensions" Version="10.0.12"/>
-        <PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="10.0.12"/>
-        <PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.12"/>
-        <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.12"/>
-        <PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.12"/>
-        <PackageReference Include="Microsoft.Extensions.Logging.Console" Version="10.0.12"/>
+        <PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.13"/>
+        <PackageReference Include="Microsoft.Extensions.Configuration.FileExtensions" Version="10.0.13"/>
+        <PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="10.0.13"/>
+        <PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.13"/>
+        <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.13"/>
+        <PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.13"/>
+        <PackageReference Include="Microsoft.Extensions.Logging.Console" Version="10.0.13"/>
         <PackageReference Include="Spectre.Console" Version="0.57.2"/>
         <PackageReference Include="Spectre.Console.Cli" Version="0.55.0"/>
         <PackageReference Include="Spectre.Console.Testing" Version="0.57.2"/>

+ 99 - 5
Shared.Rcl/CliBootstrap.cs

@@ -26,6 +26,7 @@ using Shared.Rcl.Commands.Desktops.Gpus;
 using Shared.Rcl.Commands.Desktops.Labels;
 using Shared.Rcl.Commands.Desktops.Nics;
 using Shared.Rcl.Commands.Desktops.Rename;
+using Shared.Rcl.Commands.Discovery;
 using Shared.Rcl.Commands.Exporters;
 using Shared.Rcl.Commands.Firewalls;
 using Shared.Rcl.Commands.Firewalls.Labels;
@@ -37,6 +38,7 @@ using Shared.Rcl.Commands.Laptops.Cpus;
 using Shared.Rcl.Commands.Laptops.Drive;
 using Shared.Rcl.Commands.Laptops.Gpus;
 using Shared.Rcl.Commands.Laptops.Labels;
+using Shared.Rcl.Commands.Laptops.Nics;
 using Shared.Rcl.Commands.Laptops.Rename;
 using Shared.Rcl.Commands.Routers;
 using Shared.Rcl.Commands.Routers.Labels;
@@ -61,10 +63,12 @@ using Shared.Rcl.Commands.Systems.Labels;
 using Shared.Rcl.Commands.Systems.Rename;
 using Shared.Rcl.Commands.OtherHardware;
 using Shared.Rcl.Commands.OtherHardware.Labels;
+using Shared.Rcl.Commands.OtherHardware.Ports;
 using Shared.Rcl.Commands.OtherHardware.Rename;
 using Shared.Rcl.Commands.Tags;
 using Shared.Rcl.Commands.Ups;
 using Shared.Rcl.Commands.Ups.Labels;
+using Shared.Rcl.Commands.Ups.Ports;
 using Shared.Rcl.Commands.Ups.Rename;
 using Spectre.Console;
 using Spectre.Console.Cli;
@@ -90,15 +94,28 @@ public static class CliBootstrap {
         services.AddSingleton(configuration);
         var appBasePath = AppContext.BaseDirectory;
 
+        // The store lives next to the binary, which is fine when rpk is run from its
+        // own directory but not when it is dropped somewhere read-only — a container,
+        // or /usr/local/bin as a non-root user. `rpk discover` is designed to run on
+        // machines that hold no inventory at all, so an unavailable store must not stop
+        // the process starting; commands that actually need one fail when they use it.
         var resolvedYamlDir = Path.IsPathRooted(yamlDir)
             ? yamlDir
             : Path.Combine(appBasePath, yamlDir);
 
-        Directory.CreateDirectory(resolvedYamlDir);
-
         var fullYamlPath = Path.Combine(resolvedYamlDir, yamlFile);
 
-        if (!File.Exists(fullYamlPath)) await File.WriteAllTextAsync(fullYamlPath, "");
+        try {
+            Directory.CreateDirectory(resolvedYamlDir);
+
+            if (!File.Exists(fullYamlPath)) await File.WriteAllTextAsync(fullYamlPath, "");
+        }
+        catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) {
+            await System.Console.Error.WriteLineAsync(
+                $"Warning: cannot use the config at {fullYamlPath} ({ex.Message}). " +
+                "Continuing with an empty inventory — reads will show nothing, and " +
+                "writes are refused until the config can be read.");
+        }
 
         services.AddLogging();
         services.AddScoped<RackPeekConfigMigrationDeserializer>();
@@ -113,13 +130,27 @@ public static class CliBootstrap {
             b.GetRequiredService<IResourceYamlMigrationService>());
 
 
-        await collection.LoadAsync();
+        try {
+            await collection.LoadAsync();
+        }
+        catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) {
+            // Unreadable store, warned about above. A malformed config is a different
+            // matter and is still allowed to fail loudly — the user has one to fix.
+            await System.Console.Error.WriteLineAsync($"Warning: could not read {fullYamlPath} ({ex.Message}).");
+        }
+        catch (ConfigLoadException) {
+            // A damaged config must not stop the process starting — `rpk discover` and
+            // `--help` do not need the inventory, and the web UI is how someone fixes
+            // the file. The failure is recorded on the collection, so every command
+            // that does touch the inventory fails with it instead of reporting an
+            // empty one (#337).
+        }
         services.AddSingleton<IResourceCollection>(collection);
 
         // Infrastructure
         services.AddYamlRepos();
 
-        // Application
+        // Application (also registers the discovery probes, for every host)
         services.AddUseCases();
         services.AddCommands();
     }
@@ -491,6 +522,16 @@ public static class CliBootstrap {
                 ups.AddCommand<UpsRenameCommand>("rename")
                     .WithDescription("Rename a UPS unit to a new name.");
 
+                ups.AddBranch("port", port => {
+                    port.SetDescription("Manage ports on a UPS unit.");
+
+                    port.AddCommand<UpsPortAddCommand>("add").WithDescription("Add a port to a UPS unit.");
+
+                    port.AddCommand<UpsPortUpdateCommand>("set").WithDescription("Update a UPS unit port.");
+
+                    port.AddCommand<UpsPortRemoveCommand>("del").WithDescription("Remove a port from a UPS unit.");
+                });
+
                 ups.AddBranch("label", label => {
                     label.SetDescription("Manage labels on a UPS unit.");
                     label.AddCommand<UpsLabelAddCommand>("add").WithDescription("Add a label to a UPS unit.");
@@ -532,6 +573,17 @@ public static class CliBootstrap {
                 other.AddCommand<OtherRenameCommand>("rename")
                     .WithDescription("Rename other hardware to a new name.");
 
+                other.AddBranch("port", port => {
+                    port.SetDescription("Manage ports on other hardware.");
+
+                    port.AddCommand<OtherPortAddCommand>("add").WithDescription("Add a port to other hardware.");
+
+                    port.AddCommand<OtherPortUpdateCommand>("set").WithDescription("Update an other hardware port.");
+
+                    port.AddCommand<OtherPortRemoveCommand>("del")
+                        .WithDescription("Remove a port from other hardware.");
+                });
+
                 other.AddBranch("label", label => {
                     label.SetDescription("Manage labels on other hardware.");
                     label.AddCommand<OtherLabelAddCommand>("add").WithDescription("Add a label to other hardware.");
@@ -667,6 +719,14 @@ public static class CliBootstrap {
                     gpu.AddCommand<LaptopGpuRemoveCommand>("del").WithDescription("Remove a GPU from a Laptop.");
                 });
 
+                // NICs
+                laptops.AddBranch("nic", nic => {
+                    nic.SetDescription("Manage network interface cards (NICs) for Laptops.");
+                    nic.AddCommand<LaptopNicAddCommand>("add").WithDescription("Add a NIC to a Laptop.");
+                    nic.AddCommand<LaptopNicSetCommand>("set").WithDescription("Update a Laptop NIC.");
+                    nic.AddCommand<LaptopNicRemoveCommand>("del").WithDescription("Remove a NIC from a Laptop.");
+                });
+
                 laptops.AddBranch("label", label => {
                     label.SetDescription("Manage labels on a laptop.");
                     label.AddCommand<LaptopLabelAddCommand>("add").WithDescription("Add a label to a laptop.");
@@ -728,6 +788,36 @@ public static class CliBootstrap {
             // ----------------------------
             // Ansible
             // ----------------------------
+            config.AddBranch("discover", discover => {
+                discover.SetDescription("Read infrastructure and emit it as RackPeek YAML.");
+
+                discover.AddCommand<DiscoverSystemCommand>("system")
+                    .WithDescription("Inspect this machine and emit it as a System resource.")
+                    .WithExample("discover", "system")
+                    .WithExample("discover", "system", "--name", "nas01", "--push");
+
+                discover.AddCommand<DiscoverDockerCommand>("docker")
+                    .WithDescription("Read the Docker API and emit each published container as a Service on this host's System.")
+                    .WithExample("discover", "docker")
+                    .WithExample("discover", "docker", "--push");
+
+                discover.AddCommand<DiscoverProxmoxCommand>("proxmox")
+                    .WithDescription("Read a Proxmox cluster and emit its nodes and guests as Systems.")
+                    .WithExample("discover", "proxmox", "--host", "https://pve.lan:8006", "--insecure")
+                    .WithExample("discover", "proxmox", "--host", "pve.lan", "--push");
+
+                discover.AddCommand<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")
+                    .WithExample("discover", "network", "--cidr", "192.168.1.0/24")
+                    .WithExample("discover", "network", "--cidr", "10.0.0.0/24", "--ports", "22,80,443", "--push");
+            });
+
             config.AddBranch("ansible", ansible => {
                 ansible.SetDescription("Generate and manage Ansible inventory.");
 
@@ -801,6 +891,10 @@ public static class CliBootstrap {
                 AnsiConsole.MarkupLine($"[red]Not found:[/] {ne.Message}");
                 return 4;
 
+            case ConfigLoadException cle:
+                AnsiConsole.MarkupLine($"[red]Config error:[/] {Markup.Escape(cle.Message)}");
+                return 5;
+
             case CommandParseException pe:
                 if (_showingHelp) return 1; // suppress errors during help lookup
                 AnsiConsole.MarkupLine($"[red]Invalid command:[/] {pe.Message}");

+ 139 - 0
Shared.Rcl/Commands/Discovery/DiscoverDockerCommand.cs

@@ -0,0 +1,139 @@
+using System.ComponentModel;
+using RackPeek.Domain.Discovery;
+using RackPeek.Domain.Resources;
+using RackPeek.Domain.Resources.Services;
+using RackPeek.Domain.Resources.SystemResources;
+using Spectre.Console;
+using Spectre.Console.Cli;
+
+namespace Shared.Rcl.Commands.Discovery;
+
+public sealed class DiscoverDockerSettings : DiscoverSettings {
+    [CommandOption("--docker-host <URI>")]
+    [Description("Docker endpoint, e.g. unix:///var/run/docker.sock or tcp://host:2375. " +
+                 "Defaults to DOCKER_HOST, then the local socket.")]
+    public string? DockerHost { get; init; }
+
+    [CommandOption("--host <NAME>")]
+    [Description("Name of the machine these containers run on. Required for remote daemons without a reported hostname.")]
+    public string? HostName { get; init; }
+}
+
+/// <summary>Reads the Docker Engine API and emits each published container as a Service.</summary>
+public sealed class DiscoverDockerCommand(IEnumerable<ISystemProbe> probes)
+    : AsyncCommand<DiscoverDockerSettings> {
+    protected override async Task<int> ExecuteAsync(
+        CommandContext context,
+        DiscoverDockerSettings settings,
+        CancellationToken cancellationToken) {
+        // The host's own facts give the services a stable id seed, the address they are
+        // reachable on, and something to hang runsOn off.
+        SystemFacts host = await ReadHostAsync(cancellationToken);
+
+        DockerApiClient client;
+
+        try {
+            client = new DockerApiClient(settings.DockerHost);
+        }
+        catch (UriFormatException ex) {
+            AnsiConsole.MarkupLine(
+                $"[red]'{Markup.Escape(settings.DockerHost ?? string.Empty)}' is not a usable Docker endpoint.[/] " +
+                $"{Markup.Escape(ex.Message)}");
+
+            return 1;
+        }
+
+        using DockerApiClient _ = client;
+
+        IReadOnlyList<DockerContainer> containers;
+
+        try {
+            containers = await client.ListContainersAsync(cancellationToken);
+        }
+        catch (Exception ex) when (
+            ex is HttpRequestException or IOException or TimeoutException
+            // HttpClient reports its own timeout as a cancellation.
+            || (ex is TaskCanceledException && !cancellationToken.IsCancellationRequested)) {
+            AnsiConsole.MarkupLine(
+                $"[red]Could not reach Docker at {Markup.Escape(client.Endpoint)}.[/] " +
+                $"{Markup.Escape(ex.Message)}");
+
+            return 1;
+        }
+
+        // Over TCP the machine running this command is not the machine running the
+        // containers, so the engine is asked about itself instead of trusting the local
+        // probe: its daemon id seeds the services' identity (the same ids from any
+        // workstation), and its hostname is what runsOn should point at.
+        DockerEngineInfo? engine = client.IsLocal ? null : await client.GetInfoAsync(cancellationToken);
+
+        if (!client.IsLocal && string.IsNullOrWhiteSpace(settings.HostName)
+            && string.IsNullOrWhiteSpace(engine?.Hostname)) {
+            AnsiConsole.MarkupLine(
+                "[red]Could not determine the remote Docker host name.[/] " +
+                "The engine did not provide a hostname; pass --host <NAME> to identify the machine. " +
+                "No resources were emitted.");
+
+            return 1;
+        }
+
+        // Named through the same mapper the system collector uses, so runsOn always
+        // points at exactly the resource 'rpk discover system' produces on that machine.
+        SystemResource hostResource = SystemResourceMapper.ToResource(host, settings.HostName);
+
+        var hostName = client.IsLocal
+            ? hostResource.Name
+            : settings.HostName ?? engine!.Hostname!;
+
+        var seed = client.IsLocal
+            ? host.MachineId ?? host.Hostname
+            : engine?.Id ?? client.Endpoint;
+
+        // Published ports live on the engine host, so a remote service's address is the
+        // 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);
+
+        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);
+
+        var skipped = containers.Count - services.Count;
+
+        if (skipped > 0)
+            AnsiConsole.MarkupLine(
+                $"[grey]Skipped {skipped} container(s) not reachable from outside the host.[/]");
+
+        // The host System rides along so the server can line runsOn up by the host's id
+        // even after the user has renamed it — a name alone could not be reconciled. Over
+        // TCP the facts probed here describe this machine, not the engine's, so they stay
+        // out; a rename there is preserved instead by the merge keeping the stored link
+        // whenever an update's runsOn points at nothing.
+        List<Resource> resources = client.IsLocal && services.Count > 0
+            ? [hostResource, .. services]
+            : [.. services];
+
+        return await DiscoveryOutput.EmitAsync(resources, settings, cancellationToken);
+    }
+
+    private async Task<SystemFacts> ReadHostAsync(CancellationToken cancellationToken) {
+        // Unlike `discover system`, an unsupported platform is not fatal here — the
+        // containers can still be read; only the host's own facts fall back to basics.
+        return await SystemProbes.TryReadHostAsync(probes, cancellationToken)
+               ?? SystemFactsParser.Parse(new RawSystemSnapshot {
+                   Hostname = Environment.MachineName,
+                   Cores = Environment.ProcessorCount
+               });
+    }
+}

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

@@ -0,0 +1,158 @@
+using System.ComponentModel;
+using RackPeek.Domain.Discovery;
+using RackPeek.Domain.Resources;
+using RackPeek.Domain.Resources.Services.Networking;
+using Spectre.Console;
+using Spectre.Console.Cli;
+using NetworkCidr = RackPeek.Domain.Resources.Services.Networking.Cidr;
+
+namespace Shared.Rcl.Commands.Discovery;
+
+public sealed class DiscoverNetworkSettings : DiscoverSettings {
+    private IReadOnlyList<int>? _resolvedPorts;
+
+    [CommandOption("--cidr <CIDR>")]
+    [Description("Subnet to sweep, e.g. 192.168.1.0/24. Defaults to this machine's own subnet.")]
+    public string? Cidr { get; init; }
+
+    [CommandOption("--ports <LIST>")]
+    [Description("TCP ports probed to catch hosts that ignore ping, e.g. 22,80,443. " +
+                 "Defaults to a curated homelab list.")]
+    public string? Ports { get; init; }
+
+    [CommandOption("--timeout <MS>")]
+    [Description("Milliseconds to wait on each port probe.")]
+    public int Timeout { get; init; } = 500;
+
+    [CommandOption("--parallel <N>")]
+    [Description("How many hosts to probe at once.")]
+    public int Parallel { get; init; } = 128;
+
+    [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;
+
+    public IReadOnlyList<int> ResolvedPorts =>
+        _resolvedPorts ??= string.IsNullOrWhiteSpace(Ports)
+            ? WellKnownPorts.Defaults
+            : ParsePorts(Ports) ?? WellKnownPorts.Defaults;
+
+    public override ValidationResult Validate() {
+        if (Cidr != null) {
+            if (ParsedCidr is not { } parsed)
+                return ValidationResult.Error(
+                    $"'{Cidr}' is not a usable CIDR block. Use e.g. --cidr 192.168.1.0/24");
+
+            if (parsed.Prefix < NetworkScanner.MinPrefix)
+                return ValidationResult.Error(
+                    $"/{parsed.Prefix} is more than 65,534 hosts. Narrow the sweep to /{NetworkScanner.MinPrefix} or smaller.");
+        }
+
+        if (Ports != null && ParsePorts(Ports) == null)
+            return ValidationResult.Error(
+                $"'{Ports}' is not a usable port list. Use e.g. --ports 22,80,443");
+
+        if (Timeout is < 1 or > 60_000)
+            return ValidationResult.Error("--timeout must be between 1 and 60000 milliseconds.");
+
+        if (Parallel is < 1 or > 1024)
+            return ValidationResult.Error("--parallel must be between 1 and 1024.");
+
+        return base.Validate();
+    }
+
+    private static IReadOnlyList<int>? ParsePorts(string list) {
+        var ports = new List<int>();
+
+        foreach (var part in list.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) {
+            if (!int.TryParse(part, out var port) || port is < 1 or > 65_535)
+                return null;
+
+            ports.Add(port);
+        }
+
+        return ports.Count == 0 ? null : ports;
+    }
+}
+
+/// <summary>
+///     Sweeps a subnet and emits every answering host as a System resource — the
+///     collector for machines nothing else can describe: no agent, no API, just an
+///     address that answers.
+/// </summary>
+public sealed class DiscoverNetworkCommand(INetworkProbe probe)
+    : AsyncCommand<DiscoverNetworkSettings> {
+    protected override async Task<int> ExecuteAsync(
+        CommandContext context,
+        DiscoverNetworkSettings settings,
+        CancellationToken cancellationToken) {
+        if (!probe.IsSupported) {
+            // Without this, the browser console's sandboxed sockets would swallow every
+            // probe and the command would report an empty network as if it were true.
+            AnsiConsole.MarkupLine(
+                "[red]Network scanning is not supported on this platform.[/] " +
+                "Run rpk on a machine attached to the network instead.");
+
+            return 1;
+        }
+
+        Cidr cidr;
+
+        if (settings.ParsedCidr is { } requested) {
+            cidr = requested;
+        }
+        else {
+            Cidr? detected = probe.LocalSubnet();
+
+            if (detected == null) {
+                AnsiConsole.MarkupLine(
+                    "[red]Could not detect this machine's subnet.[/] Pass --cidr, e.g. --cidr 192.168.1.0/24");
+
+                return 1;
+            }
+
+            // The same cap --cidr gets: a VPN or CGNAT interface can carry a /10, and
+            // auto-detection must never be the way around the sweep limit.
+            if (detected.Value.Prefix < NetworkScanner.MinPrefix) {
+                AnsiConsole.MarkupLine(
+                    $"[red]This machine's subnet is {Markup.Escape(detected.Value.ToString())} — more than " +
+                    $"65,534 hosts.[/] Pass --cidr with a narrower block, e.g. --cidr 192.168.1.0/24");
+
+                return 1;
+            }
+
+            cidr = detected.Value;
+        }
+
+        var options = new NetworkScanOptions {
+            Cidr = cidr,
+            Ports = settings.ResolvedPorts,
+            PortTimeout = TimeSpan.FromMilliseconds(settings.Timeout),
+            Concurrency = settings.Parallel,
+            IdentifyServices = !settings.NoIdentify
+        };
+
+        var targets = NetworkScanner.EnumerateTargets(cidr).Count();
+
+        AnsiConsole.MarkupLine(
+            $"[grey]Sweeping {Markup.Escape(cidr.ToString())} — {targets} address(es), " +
+            $"ping + {options.Ports.Count} TCP port(s)…[/]");
+
+        IReadOnlyList<NetworkHostFact> hosts = await NetworkScanner.ScanAsync(probe, options, cancellationToken);
+
+        var withoutMac = hosts.Count(h => h.Mac == null);
+
+        if (withoutMac > 0)
+            AnsiConsole.MarkupLine(
+                $"[grey]{withoutMac} host(s) had no ARP entry, so their identity is seeded on the IP " +
+                "address — a DHCP re-lease will make them look like new machines.[/]");
+
+        List<Resource> resources = NetworkScanMapper.ToResources(hosts);
+
+        return await DiscoveryOutput.EmitAsync(resources, settings, cancellationToken);
+    }
+}

Некоторые файлы не были показаны из-за большого количества измененных файлов