feat: add Windows hardware inventory collector and NetBox sync
Build Windows agent / build (win-arm64) (push) Has been cancelled
Build Windows agent / build (win-x64) (push) Has been cancelled

feat: add self-contained Windows build and Intune deployment scripts
docs: document configuration, permissions, and deployment
ci: build x64 and arm64 agent artifacts
This commit is contained in:
2026-07-21 13:25:51 +02:00
commit 3119af1784
13 changed files with 443 additions and 0 deletions
+28
View File
@@ -0,0 +1,28 @@
name: Build Windows agent
on:
push:
branches: [main]
tags: ['v*']
pull_request:
jobs:
build:
runs-on: windows-latest
strategy:
matrix:
runtime: [win-x64, win-arm64]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
- shell: pwsh
run: ./scripts/build.ps1 -Runtime ${{ matrix.runtime }}
- uses: actions/upload-artifact@v4
with:
name: agent-${{ matrix.runtime }}
path: |
dist/${{ matrix.runtime }}/agent.exe
config.example.json
scripts/install.ps1
scripts/uninstall.ps1
+7
View File
@@ -0,0 +1,7 @@
bin/
obj/
dist/
.tools/
config.json
*.log
+15
View File
@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AssemblyName>agent</AssemblyName>
<RootNamespace>NetBoxWindowsAgent</RootNamespace>
<Version>0.1.0</Version>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="System.Management" Version="8.0.0" />
</ItemGroup>
</Project>
+94
View File
@@ -0,0 +1,94 @@
# NetBox Windows Client Agent
Ein schlanker Windows-Agent, der Hardwaredaten per WMI erfasst und einen NetBox-Device-Datensatz sowie das zugehörige Asset im Plugin [ArnesSI/netbox-inventory](https://github.com/ArnesSI/netbox-inventory) anlegt oder aktualisiert. Das Release ist eine einzelne, selbstenthaltende `agent.exe`; auf dem Zielsystem muss kein .NET installiert sein.
## Was synchronisiert wird
- Rechnername, Hersteller, Modell, BIOS-Seriennummer und Geräte-UUID
- Windows-Version und Architektur, CPU, RAM, Datenträger, MAC- und IP-Adressen
- NetBox Site und Location aus der Konfiguration
- `dcim.Device` sowie das verknüpfte `netbox_inventory.Asset`
- Hersteller und Device Type werden bei Bedarf angelegt
- Wiederholbare Updates anhand der BIOS-Seriennummer statt doppelter Datensätze
Der Agent löscht keine NetBox-Objekte. Site, Location und Device Role müssen bereits existieren. Die Region dient zur eindeutigen Auswahl der Site.
## Voraussetzungen in NetBox
1. Das Plugin `netbox-inventory` ist installiert.
2. Eine Device Role mit dem konfigurierten Slug (Standard: `windows-client`) existiert.
3. Site/Location existieren, falls sie in `config.json` gesetzt sind.
4. Der API-Token darf Devices, Manufacturers, Device Types und Inventory Assets lesen, anlegen und ändern.
Optionale Custom Fields müssen für `dcim.Device` existieren. Die linke Seite in `custom_fields` ist der Agent-Schlüssel, die rechte Seite der NetBox-Custom-Field-Name. Nicht benötigte Einträge können entfernt werden.
## Konfiguration
`config.example.json` als `config.json` neben die EXE kopieren. Die URL enthält nur die NetBox-Basis-URL, nicht `/api`.
```json
{
"netbox_url": "https://netbox.example.com",
"api_token": "0123456789abcdef",
"region": "emea",
"site": "berlin",
"location": "office-1",
"device_role": "windows-client",
"verify_tls": true,
"timeout_seconds": 30,
"tags": [],
"custom_fields": {}
}
```
`site` und `location` sind optional. `verify_tls: false` ist nur für kurzfristige Tests mit selbstsignierten Zertifikaten gedacht. `config.json` ist wegen des Tokens in `.gitignore` enthalten.
## Ausführen und bauen
```powershell
.\agent.exe --dry-run
.\agent.exe
.\agent.exe --config C:\ProgramData\NetBoxAgent\config.json
```
Der Dry-Run liest NetBox und prüft die Zuordnungen, schreibt aber nichts. Zum lokalen Build wird das .NET 8 SDK benötigt:
```powershell
.\scripts\build.ps1 -Runtime win-x64
```
Das Ergebnis liegt in `dist\win-x64\agent.exe`. GitHub Actions erzeugt zusätzlich ARM64-Artefakte.
## Manuelle Installation und Intune
Für eine manuelle Installation `agent.exe`, `config.json`, `install.ps1` und `uninstall.ps1` in dasselbe Paketverzeichnis legen und PowerShell als Administrator starten:
```powershell
.\install.ps1 -SourceDirectory $PWD
```
Die Installation kopiert die Dateien nach `%ProgramFiles%\NetBox Windows Agent`, schränkt die ACL auf Administratoren/SYSTEM ein, erstellt die tägliche geplante Aufgabe `NetBox Windows Agent` und führt die erste Synchronisation aus.
Für Intune die vier Dateien mit dem Microsoft Win32 Content Prep Tool als `.intunewin` paketieren:
- Installationsbefehl: `powershell.exe -ExecutionPolicy Bypass -File .\install.ps1 -SourceDirectory .`
- Deinstallationsbefehl: `powershell.exe -ExecutionPolicy Bypass -File .\uninstall.ps1`
- Installationskontext: System
- Erkennungsregel: Datei `%ProgramFiles%\NetBox Windows Agent\agent.exe` vorhanden
- Rückgabecode `0` bedeutet erfolgreiche erste Synchronisation
## Sicherheit und Betrieb
- Einen eigenen NetBox-Token mit minimal nötigen Rechten verwenden.
- `verify_tls` in Produktion immer aktiviert lassen.
- Für größere Umgebungen den Token künftig besser über Windows Credential Manager oder ein Intune-verwaltetes Maschinenzertifikat bereitstellen.
- Logs können in Intune über die Ausgabe der geplanten Aufgabe ergänzt werden; eine Event-Log-Integration ist ein sinnvoller nächster Ausbau.
## Vorgeschlagene Commits
```text
feat: add Windows hardware inventory collector and NetBox sync
feat: add self-contained Windows build and Intune deployment scripts
docs: document configuration, permissions, and deployment
ci: build x64 and arm64 agent artifacts
```
+17
View File
@@ -0,0 +1,17 @@
{
"netbox_url": "https://netbox.example.com",
"api_token": "CHANGE_ME",
"region": "emea",
"site": "berlin",
"location": "office-1",
"device_role": "windows-client",
"verify_tls": true,
"timeout_seconds": 30,
"tags": ["windows-agent"],
"custom_fields": {
"agent_version": "netbox_agent_version",
"windows_version": "windows_version",
"device_uuid": "device_uuid",
"last_sync": "agent_last_sync"
}
}
+13
View File
@@ -0,0 +1,13 @@
param(
[ValidateSet('win-x64', 'win-arm64')][string]$Runtime = 'win-x64',
[string]$Configuration = 'Release'
)
$ErrorActionPreference = 'Stop'
$projectRoot = Split-Path $PSScriptRoot -Parent
$output = Join-Path $projectRoot "dist\$Runtime"
dotnet publish (Join-Path $projectRoot 'NetBoxAgent.csproj') `
-c $Configuration -r $Runtime --self-contained true `
-p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true `
-p:DebugType=None -p:DebugSymbols=false -o $output
Write-Host "Erstellt: $output\agent.exe"
+21
View File
@@ -0,0 +1,21 @@
param(
[string]$SourceDirectory = $PSScriptRoot,
[string]$InstallDirectory = "$env:ProgramFiles\NetBox Windows Agent",
[string]$Schedule = 'DAILY',
[string]$StartTime = '09:00'
)
$ErrorActionPreference = 'Stop'
if (-not (Test-Path (Join-Path $SourceDirectory 'agent.exe'))) { throw 'agent.exe fehlt im Quellverzeichnis.' }
if (-not (Test-Path (Join-Path $SourceDirectory 'config.json'))) { throw 'config.json fehlt im Quellverzeichnis.' }
New-Item -ItemType Directory -Path $InstallDirectory -Force | Out-Null
Copy-Item (Join-Path $SourceDirectory 'agent.exe') (Join-Path $InstallDirectory 'agent.exe') -Force
Copy-Item (Join-Path $SourceDirectory 'config.json') (Join-Path $InstallDirectory 'config.json') -Force
$acl = Get-Acl $InstallDirectory
$acl.SetAccessRuleProtection($true, $false)
$admins = New-Object System.Security.AccessControl.FileSystemAccessRule('BUILTIN\Administrators', 'FullControl', 'ContainerInherit,ObjectInherit', 'None', 'Allow')
$system = New-Object System.Security.AccessControl.FileSystemAccessRule('NT AUTHORITY\SYSTEM', 'FullControl', 'ContainerInherit,ObjectInherit', 'None', 'Allow')
$acl.SetAccessRule($admins); $acl.SetAccessRule($system); Set-Acl $InstallDirectory $acl
$command = '"' + (Join-Path $InstallDirectory 'agent.exe') + '" --config "' + (Join-Path $InstallDirectory 'config.json') + '"'
schtasks.exe /Create /TN 'NetBox Windows Agent' /SC $Schedule /ST $StartTime /RU SYSTEM /RL HIGHEST /TR $command /F | Out-Null
& (Join-Path $InstallDirectory 'agent.exe') --config (Join-Path $InstallDirectory 'config.json')
+10
View File
@@ -0,0 +1,10 @@
param([string]$InstallDirectory = "$env:ProgramFiles\NetBox Windows Agent")
$ErrorActionPreference = 'Stop'
schtasks.exe /Delete /TN 'NetBox Windows Agent' /F 2>$null
if (Test-Path -LiteralPath $InstallDirectory) {
$resolved = (Resolve-Path -LiteralPath $InstallDirectory).Path
$programFiles = (Resolve-Path -LiteralPath $env:ProgramFiles).Path
if (-not $resolved.StartsWith($programFiles + '\', [StringComparison]::OrdinalIgnoreCase)) { throw "Unsicheres Ziel: $resolved" }
Remove-Item -LiteralPath $resolved -Recurse -Force
}
+35
View File
@@ -0,0 +1,35 @@
using System.Management;
namespace NetBoxWindowsAgent;
internal static class HardwareCollector
{
public static HardwareInfo Collect()
{
var computer = First("Win32_ComputerSystem", "Manufacturer", "Model", "TotalPhysicalMemory");
var product = First("Win32_ComputerSystemProduct", "UUID");
var bios = First("Win32_BIOS", "SerialNumber");
var os = First("Win32_OperatingSystem", "Caption", "Version", "OSArchitecture");
var cpu = First("Win32_Processor", "Name");
var disks = Query("Win32_DiskDrive", "DeviceID", "Model", "SerialNumber", "Size")
.Select(x => new DiskInfo(S(x, "DeviceID"), S(x, "Model"), S(x, "SerialNumber"), U(x, "Size"))).ToList();
var networks = Query("Win32_NetworkAdapterConfiguration", "Description", "MACAddress", "IPAddress")
.Where(x => x["MACAddress"] is not null)
.Select(x => new NetworkInfo(S(x, "Description"), S(x, "MACAddress"),
(x["IPAddress"] as string[] ?? []).Where(ip => !ip.StartsWith("169.254.")).ToList())).ToList();
return new HardwareInfo(Environment.MachineName, S(computer, "Manufacturer"), S(computer, "Model"),
S(bios, "SerialNumber"), S(product, "UUID"), S(os, "Caption"), S(os, "Version"),
S(os, "OSArchitecture"), S(cpu, "Name"), U(computer, "TotalPhysicalMemory"), disks, networks);
}
private static ManagementBaseObject First(string cls, params string[] fields) => Query(cls, fields).First();
private static IEnumerable<ManagementBaseObject> Query(string cls, params string[] fields)
{
using var searcher = new ManagementObjectSearcher($"SELECT {string.Join(',', fields)} FROM {cls}");
foreach (ManagementBaseObject item in searcher.Get()) yield return item;
}
private static string S(ManagementBaseObject x, string key) => Convert.ToString(x[key])?.Trim() ?? "";
private static ulong U(ManagementBaseObject x, string key) => ulong.TryParse(Convert.ToString(x[key]), out var n) ? n : 0;
}
+38
View File
@@ -0,0 +1,38 @@
using System.Text.Json.Serialization;
namespace NetBoxWindowsAgent;
internal sealed class AgentConfig
{
[JsonPropertyName("netbox_url")] public string NetBoxUrl { get; set; } = "";
[JsonPropertyName("api_token")] public string ApiToken { get; set; } = "";
[JsonPropertyName("region")] public string Region { get; set; } = "";
[JsonPropertyName("site")] public string? Site { get; set; }
[JsonPropertyName("location")] public string? Location { get; set; }
[JsonPropertyName("device_role")] public string DeviceRole { get; set; } = "windows-client";
[JsonPropertyName("verify_tls")] public bool VerifyTls { get; set; } = true;
[JsonPropertyName("timeout_seconds")] public int TimeoutSeconds { get; set; } = 30;
[JsonPropertyName("tags")] public List<string> Tags { get; set; } = [];
[JsonPropertyName("custom_fields")] public Dictionary<string, string> CustomFields { get; set; } = new();
}
internal sealed record HardwareInfo(
string Hostname, string Manufacturer, string Model, string Serial, string Uuid,
string WindowsCaption, string WindowsVersion, string Architecture, string Processor,
ulong MemoryBytes, IReadOnlyList<DiskInfo> Disks, IReadOnlyList<NetworkInfo> Networks);
internal sealed record DiskInfo(string DeviceId, string Model, string Serial, ulong SizeBytes);
internal sealed record NetworkInfo(string Name, string MacAddress, IReadOnlyList<string> IpAddresses);
internal sealed class Page<T>
{
[JsonPropertyName("count")] public int Count { get; set; }
[JsonPropertyName("results")] public List<T> Results { get; set; } = [];
}
internal sealed class NetBoxObject
{
[JsonPropertyName("id")] public int Id { get; set; }
[JsonPropertyName("name")] public string? Name { get; set; }
[JsonPropertyName("slug")] public string? Slug { get; set; }
[JsonPropertyName("serial")] public string? Serial { get; set; }
}
+47
View File
@@ -0,0 +1,47 @@
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
namespace NetBoxWindowsAgent;
internal sealed class NetBoxClient : IDisposable
{
private readonly HttpClient _http;
private readonly JsonSerializerOptions _json = new(JsonSerializerDefaults.Web) { WriteIndented = false };
public NetBoxClient(AgentConfig config)
{
var handler = new HttpClientHandler();
if (!config.VerifyTls) handler.ServerCertificateCustomValidationCallback = (_, _, _, _) => true;
_http = new HttpClient(handler) { BaseAddress = new Uri(config.NetBoxUrl.TrimEnd('/') + "/"), Timeout = TimeSpan.FromSeconds(config.TimeoutSeconds) };
_http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Token", config.ApiToken);
_http.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
_http.DefaultRequestHeaders.UserAgent.ParseAdd("NetBox-Windows-Agent/0.1.0");
}
public async Task<NetBoxObject?> FindOne(string endpoint, params (string Key, string? Value)[] filters)
{
var query = string.Join('&', filters.Where(f => !string.IsNullOrWhiteSpace(f.Value))
.Select(f => $"{Uri.EscapeDataString(f.Key)}={Uri.EscapeDataString(f.Value!)}"));
var page = await Send<Page<NetBoxObject>>(HttpMethod.Get, $"api/{endpoint}/?limit=2&{query}");
if (page.Count > 1) throw new InvalidOperationException($"NetBox-Abfrage für {endpoint} ist nicht eindeutig.");
return page.Results.SingleOrDefault();
}
public async Task<NetBoxObject> Create(string endpoint, object payload) =>
await Send<NetBoxObject>(HttpMethod.Post, $"api/{endpoint}/", payload);
public async Task<NetBoxObject> Patch(string endpoint, int id, object payload) =>
await Send<NetBoxObject>(HttpMethod.Patch, $"api/{endpoint}/{id}/", payload);
private async Task<T> Send<T>(HttpMethod method, string uri, object? payload = null)
{
using var request = new HttpRequestMessage(method, uri);
if (payload is not null) request.Content = JsonContent.Create(payload, options: _json);
using var response = await _http.SendAsync(request);
var body = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode)
throw new InvalidOperationException($"NetBox {method} {uri}: {(int)response.StatusCode} {response.ReasonPhrase}: {body}");
return JsonSerializer.Deserialize<T>(body, _json) ?? throw new InvalidOperationException("NetBox lieferte eine leere Antwort.");
}
public void Dispose() => _http.Dispose();
}
+33
View File
@@ -0,0 +1,33 @@
using System.Text.Json;
using NetBoxWindowsAgent;
return await MainAsync(args);
static async Task<int> MainAsync(string[] args)
{
try
{
var configPath = ValueAfter(args, "--config") ?? Path.Combine(AppContext.BaseDirectory, "config.json");
var dryRun = args.Contains("--dry-run");
if (args.Contains("--help")) { Usage(); return 0; }
if (!File.Exists(configPath)) throw new FileNotFoundException("Konfigurationsdatei nicht gefunden.", configPath);
var config = JsonSerializer.Deserialize<AgentConfig>(await File.ReadAllTextAsync(configPath), new JsonSerializerOptions(JsonSerializerDefaults.Web))
?? throw new InvalidOperationException("Konfiguration ist leer.");
Validate(config);
var hardware = HardwareCollector.Collect();
Console.WriteLine($"Erfasst: {hardware.Hostname}, {hardware.Manufacturer} {hardware.Model}, S/N {hardware.Serial}");
using var client = new NetBoxClient(config);
var result = await new SyncService(client, config).Sync(hardware, dryRun);
Console.WriteLine(dryRun ? "Dry-Run erfolgreich; keine Änderungen geschrieben." : $"Synchronisiert: Device #{result.DeviceId}, Asset #{result.AssetId}");
return 0;
}
catch (Exception ex) { Console.Error.WriteLine($"FEHLER: {ex.Message}"); return 1; }
}
static string? ValueAfter(string[] args, string key) { var i = Array.IndexOf(args, key); return i >= 0 && i + 1 < args.Length ? args[i + 1] : null; }
static void Validate(AgentConfig c)
{
if (!Uri.TryCreate(c.NetBoxUrl, UriKind.Absolute, out var uri) || (uri.Scheme != "https" && uri.Scheme != "http")) throw new InvalidOperationException("netbox_url ist ungültig.");
if (string.IsNullOrWhiteSpace(c.ApiToken) || c.ApiToken == "CHANGE_ME") throw new InvalidOperationException("api_token fehlt.");
if (string.IsNullOrWhiteSpace(c.DeviceRole)) throw new InvalidOperationException("device_role fehlt.");
}
static void Usage() => Console.WriteLine("agent.exe [--config PFAD] [--dry-run] [--help]");
+85
View File
@@ -0,0 +1,85 @@
namespace NetBoxWindowsAgent;
internal sealed class SyncService(NetBoxClient client, AgentConfig config)
{
public async Task<(int DeviceId, int AssetId)> Sync(HardwareInfo hw, bool dryRun)
{
var site = await ResolveSite();
var location = await ResolveLocation(site);
var manufacturer = await EnsureManufacturer(hw.Manufacturer, dryRun);
var deviceType = await EnsureDeviceType(manufacturer, hw.Model, dryRun);
var role = await client.FindOne("dcim/device-roles", ("slug", config.DeviceRole))
?? throw new InvalidOperationException($"Geräterolle '{config.DeviceRole}' fehlt in NetBox.");
var device = await client.FindOne("dcim/devices", ("serial", hw.Serial));
var customFields = BuildCustomFields(hw);
var devicePayload = new Dictionary<string, object?> {
["name"] = hw.Hostname, ["device_type"] = deviceType.Id, ["role"] = role.Id,
["site"] = site?.Id, ["location"] = location?.Id, ["status"] = "active",
["serial"] = hw.Serial, ["description"] = $"{hw.WindowsCaption} ({hw.Architecture})",
["comments"] = HardwareSummary(hw), ["custom_fields"] = customFields
};
RemoveNulls(devicePayload);
if (device is null) {
if (dryRun) return (0, 0);
device = await client.Create("dcim/devices", devicePayload);
} else if (!dryRun) device = await client.Patch("dcim/devices", device.Id, devicePayload);
var asset = await client.FindOne("plugins/inventory/assets", ("serial", hw.Serial));
var assetPayload = new Dictionary<string, object?> {
["name"] = hw.Hostname, ["serial"] = hw.Serial, ["status"] = "used",
["device_type"] = deviceType.Id, ["device"] = device.Id,
["description"] = $"Windows-Client; UUID {hw.Uuid}"
};
if (asset is null) {
if (dryRun) return (device.Id, 0);
asset = await client.Create("plugins/inventory/assets", assetPayload);
} else if (!dryRun) asset = await client.Patch("plugins/inventory/assets", asset.Id, assetPayload);
return (device.Id, asset.Id);
}
private async Task<NetBoxObject?> ResolveSite()
{
if (string.IsNullOrWhiteSpace(config.Site)) return null;
var filters = new List<(string, string?)> { ("slug", config.Site) };
if (!string.IsNullOrWhiteSpace(config.Region)) filters.Add(("region", config.Region));
return await client.FindOne("dcim/sites", filters.ToArray())
?? throw new InvalidOperationException($"Standort '{config.Site}' (Region '{config.Region}') fehlt in NetBox.");
}
private async Task<NetBoxObject?> ResolveLocation(NetBoxObject? site)
{
if (string.IsNullOrWhiteSpace(config.Location)) return null;
return await client.FindOne("dcim/locations", ("slug", config.Location), ("site_id", site?.Id.ToString()))
?? throw new InvalidOperationException($"Lokation '{config.Location}' fehlt in NetBox.");
}
private async Task<NetBoxObject> EnsureManufacturer(string name, bool dryRun)
{
var slug = Slug(name);
var item = await client.FindOne("dcim/manufacturers", ("slug", slug));
if (item is not null) return item;
if (dryRun) return new NetBoxObject { Id = 0, Name = name, Slug = slug };
return await client.Create("dcim/manufacturers", new { name, slug });
}
private async Task<NetBoxObject> EnsureDeviceType(NetBoxObject manufacturer, string model, bool dryRun)
{
var slug = Slug(model);
var item = await client.FindOne("dcim/device-types", ("manufacturer_id", manufacturer.Id.ToString()), ("slug", slug));
if (item is not null) return item;
if (dryRun) return new NetBoxObject { Id = 0, Name = model, Slug = slug };
return await client.Create("dcim/device-types", new { manufacturer = manufacturer.Id, model, slug });
}
private Dictionary<string, object> BuildCustomFields(HardwareInfo hw)
{
var available = new Dictionary<string, object> {
["agent_version"] = "0.1.0", ["windows_version"] = hw.WindowsVersion,
["device_uuid"] = hw.Uuid, ["last_sync"] = DateTimeOffset.UtcNow.ToString("O")
};
return config.CustomFields.Where(x => available.ContainsKey(x.Key)).ToDictionary(x => x.Value, x => available[x.Key]);
}
private static string HardwareSummary(HardwareInfo hw) =>
$"Automatisch durch NetBox Windows Agent aktualisiert.\n\nCPU: {hw.Processor}\nRAM: {hw.MemoryBytes / 1073741824d:F1} GiB\n" +
$"Datenträger: {string.Join(", ", hw.Disks.Select(d => $"{d.Model} ({d.SizeBytes / 1000000000d:F0} GB)"))}\n" +
$"Netzwerk: {string.Join(", ", hw.Networks.Select(n => $"{n.Name} [{n.MacAddress}]"))}";
private static string Slug(string value) => string.Concat(value.Trim().ToLowerInvariant().Select(c => char.IsLetterOrDigit(c) ? c : '-')).Trim('-');
private static void RemoveNulls(Dictionary<string, object?> data) { foreach (var key in data.Where(x => x.Value is null).Select(x => x.Key).ToList()) data.Remove(key); }
}