feat: sync hardware modules, interfaces, and MAC addresses
Build Windows agent / build (win-arm64) (push) Has been cancelled
Build Windows agent / build (win-x64) (push) Has been cancelled

docs: add complete copy-and-paste deployment guide
This commit is contained in:
2026-07-21 13:59:11 +02:00
parent dc7554eb41
commit d8f3820dae
5 changed files with 257 additions and 68 deletions
+200 -65
View File
@@ -1,36 +1,69 @@
# NetBox Windows Client Agent # NetBox Client Agent für Windows
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. Der Agent inventarisiert einen Windows-PC automatisch in NetBox und im Plugin [ArnesSI/netbox-inventory](https://github.com/ArnesSI/netbox-inventory). Das Ergebnis ist eine einzelne, selbstenthaltende `agent.exe`; auf dem Client muss kein .NET installiert sein.
## Was synchronisiert wird ## Ergebnis in NetBox
- Rechnername, Hersteller, Modell, BIOS-Seriennummer und Geräte-UUID Nach dem ersten Lauf entstehen beziehungsweise aktualisieren sich:
- Windows-Version und Architektur, CPU, RAM, Datenträger inklusive Seriennummern, MAC- und IP-Adressen
- Maschinenweit installierte Software aus der 32- und 64-Bit-Windows-Registry
- 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. - ein `DCIM > Device` für den Windows-PC,
- ein damit verknüpftes Inventory-Asset,
- Interfaces für erkannte Netzwerkadapter,
- je Interface eine zugewiesene primäre MAC-Adresse,
- Module und Module Bays für CPU, RAM-Riegel und Datenträger,
- ein Hardware- und Softwarebericht in den Kommentaren von Device und Asset.
## Voraussetzungen in NetBox Der Agent erkennt den PC anhand seiner BIOS-Seriennummer wieder. Wiederholte Ausführungen aktualisieren deshalb die vorhandenen Objekte. Es werden keine NetBox-Objekte gelöscht.
1. Das Plugin `netbox-inventory` ist installiert. > NetBox 4.2 oder neuer wird empfohlen, da MAC-Adressen seit 4.2 eigenständige Objekte sind. Module sind in NetBox eigentlich für austauschbare Hardwarekomponenten vorgesehen; für PC-Inventarisierung werden CPU, RAM und Datenträger bewusst auf dieses Modell abgebildet.
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. Standardmäßig werden keine Custom Fields gesendet. Die linke Seite in `custom_fields` ist der Agent-Schlüssel, die rechte Seite der NetBox-Custom-Field-Name. Unterstützte Agent-Schlüssel sind `agent_version`, `windows_version`, `device_uuid` und `last_sync`. ## 1. NetBox vorbereiten
## Konfiguration Erforderlich:
`config.example.json` als `config.json` neben die EXE kopieren. Die URL enthält nur die NetBox-Basis-URL, nicht `/api`. 1. `netbox-inventory` ist installiert.
2. Eine Device Role mit dem Slug `windows-client` existiert.
3. Die in der Konfiguration genannten Site und Location existieren, sofern sie verwendet werden.
4. Der API-Token besitzt Lese-, Erstell- und Änderungsrechte für:
- DCIM Devices, Manufacturers und Device Types
- DCIM Interfaces und MAC Addresses
- DCIM Module Types, Module Bays und Modules
- Inventory Assets des Plugins
Site, Location und Device Role legt der Agent absichtlich nicht automatisch an. Hersteller, Device Types und erkannte Hardwaretypen dagegen schon.
## 2. Repository herunterladen und EXE bauen
PowerShell öffnen und ausführen:
```powershell
git clone https://git.mrblake.cc/MrBlake/Netbox-Client-Agent-for-Windows.git
Set-Location .\Netbox-Client-Agent-for-Windows
powershell.exe -NoProfile -ExecutionPolicy Bypass `
-File .\scripts\build.ps1 `
-Runtime win-x64
```
Zum Bauen wird das [.NET 8 SDK](https://dotnet.microsoft.com/download/dotnet/8.0) oder neuer benötigt. Die fertige Datei liegt anschließend hier:
```text
dist\win-x64\agent.exe
```
## 3. Konfiguration erstellen
```powershell
Copy-Item .\config.example.json .\dist\win-x64\config.json
notepad .\dist\win-x64\config.json
```
Minimalbeispiel zum Kopieren:
```json ```json
{ {
"netbox_url": "https://netbox.example.com", "netbox_url": "https://netbox.example.local",
"api_token": "0123456789abcdef", "api_token": "HIER_DEN_NETBOX_TOKEN_EINTRAGEN",
"region": "emea", "region": "emea",
"site": "berlin", "site": "berlin",
"location": "office-1", "location": "office-1",
@@ -40,61 +73,163 @@ Optionale Custom Fields müssen für `dcim.Device` existieren. Standardmäßig w
"timeout_seconds": 30, "timeout_seconds": 30,
"collect_software": true, "collect_software": true,
"max_software_entries": 250, "max_software_entries": 250,
"tags": [], "create_hardware_modules": true,
"create_interfaces": true,
"custom_fields": {} "custom_fields": {}
} }
``` ```
`site` und `location` sind optional. Für NetBox-Installationen mit selbstsigniertem Zertifikat kann `allow_insecure_tls` auf `true` gesetzt werden. Damit werden sowohl Fehler der Zertifikatskette als auch abweichende Zertifikatsnamen ignoriert; dies reduziert die Sicherheit und sollte nur in vertrauenswürdigen internen Netzen verwendet werden. `verify_tls: false` bleibt aus Kompatibilitätsgründen ebenfalls unterstützt. `site` und `location` dürfen vollständig entfernt oder auf `null` gesetzt werden. `region` grenzt die Suche nach der Site ein.
`collect_software` aktiviert die Softwareinventarisierung. `max_software_entries` begrenzt die Anzahl der alphabetisch sortierten Einträge zwischen 0 und 1000. Erfasst wird maschinenweit installierte klassische Windows-Software; benutzerspezifische Store-/AppX-Pakete sind im SYSTEM-Kontext nicht zuverlässig verfügbar. ### Selbstsigniertes Zertifikat
## Ausführen und bauen Wenn NetBox ein selbstsigniertes Zertifikat verwendet und Windows der ausstellenden CA nicht vertraut:
```powershell ```json
.\agent.exe --dry-run "allow_insecure_tls": true
.\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: Dies ignoriert Zertifikatsketten- und Namensfehler. In produktiven Umgebungen ist es sicherer, die interne CA auf dem Client zu installieren und `allow_insecure_tls` auf `false` zu lassen.
### Optionale Funktionen abschalten
```json
"collect_software": false,
"create_hardware_modules": false,
"create_interfaces": false
```
`max_software_entries` darf zwischen 0 und 1000 liegen. Erfasst wird maschinenweit installierte klassische Windows-Software aus der 32- und 64-Bit-Registry. Benutzerspezifische Store-/AppX-Pakete sind beim Lauf als SYSTEM nicht zuverlässig verfügbar.
Custom Fields werden standardmäßig nicht benötigt. Sollen sie verwendet werden, müssen sie vorher für `DCIM > Device` in NetBox existieren. Unterstützte Zuordnungen sind:
```json
"custom_fields": {
"agent_version": "netbox_agent_version",
"windows_version": "windows_version",
"device_uuid": "device_uuid",
"last_sync": "agent_last_sync"
}
```
## 4. Verbindung testen
Der Dry-Run liest Hardware und NetBox, schreibt aber absichtlich nichts:
```powershell ```powershell
.\dist\win-x64\agent.exe `
--config .\dist\win-x64\config.json `
--dry-run
```
Erwartete Ausgabe:
```text
Erfasst: CLIENT01, LENOVO 21MW, S/N ABC123
Dry-Run erfolgreich; keine Änderungen geschrieben.
```
## 5. Erste echte Synchronisation
```powershell
.\dist\win-x64\agent.exe `
--config .\dist\win-x64\config.json
```
Erwartete Ausgabe:
```text
Synchronisiert: Device #123, Asset #456
```
## 6. Dauerhaft installieren
PowerShell **als Administrator** öffnen:
```powershell
Set-Location C:\Pfad\zum\Netbox-Client-Agent-for-Windows
powershell.exe -NoProfile -ExecutionPolicy Bypass `
-File .\scripts\install.ps1 `
-SourceDirectory .\dist\win-x64
```
Das Skript:
- kopiert EXE und Konfiguration nach `C:\Program Files\NetBox Windows Agent`,
- erlaubt nur SYSTEM und lokalen Administratoren den Zugriff auf den API-Token,
- erstellt die tägliche geplante Aufgabe `NetBox Windows Agent`,
- startet sofort die erste Synchronisation.
Deinstallation, ebenfalls als Administrator:
```powershell
powershell.exe -NoProfile -ExecutionPolicy Bypass `
-File .\scripts\uninstall.ps1
```
## Intune-Verteilung
Diese vier Dateien gemeinsam als Win32-App (`.intunewin`) paketieren:
```text
agent.exe
config.json
install.ps1
uninstall.ps1
```
Copy-&-Paste-Werte für Intune:
```text
Installationsbefehl:
powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\install.ps1 -SourceDirectory .
Deinstallationsbefehl:
powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\uninstall.ps1
Installationsverhalten:
System
Erkennungsregel:
Datei C:\Program Files\NetBox Windows Agent\agent.exe vorhanden
```
## Fehlerbehebung
### `agent.exe fehlt im Quellverzeichnis`
Als `SourceDirectory` muss der Ordner angegeben werden, in dem sowohl `agent.exe` als auch `config.json` liegen:
```powershell
-SourceDirectory .\dist\win-x64
```
### `RemoteCertificateNameMismatch` oder `RemoteCertificateChainErrors`
Zum Testen `allow_insecure_tls` auf `true` setzen. Dauerhaft besser die korrekte interne CA installieren und einen zur URL passenden Zertifikatsnamen verwenden.
### Custom Field ist nicht vorhanden
Wenn keine eigenen NetBox Custom Fields angelegt wurden:
```json
"custom_fields": {}
```
### `403 Forbidden`
Dem Benutzer des API-Tokens fehlen Rechte für den im Fehler genannten Objekttyp.
### `400 Bad Request`
Die vollständige Antwort hinter `FEHLER:` enthält das von NetBox beanstandete Feld. Häufig fehlen die konfigurierte Device Role, Site oder Location.
## Entwicklung
```powershell
dotnet build
.\scripts\build.ps1 -Runtime win-x64 .\scripts\build.ps1 -Runtime win-x64
``` ```
Das Ergebnis liegt in `dist\win-x64\agent.exe`. GitHub Actions erzeugt zusätzlich ARM64-Artefakte. GitHub Actions baut automatisch x64- und ARM64-Artefakte. `config.json`, `dist`, `bin`, `obj` und lokale Build-Werkzeuge werden nicht committed.
## 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
```
+2 -1
View File
@@ -10,6 +10,7 @@
"timeout_seconds": 30, "timeout_seconds": 30,
"collect_software": true, "collect_software": true,
"max_software_entries": 250, "max_software_entries": 250,
"tags": ["windows-agent"], "create_hardware_modules": true,
"create_interfaces": true,
"custom_fields": {} "custom_fields": {}
} }
+5 -1
View File
@@ -19,11 +19,15 @@ internal static class HardwareCollector
.Where(x => x["MACAddress"] is not null) .Where(x => x["MACAddress"] is not null)
.Select(x => new NetworkInfo(S(x, "Description"), S(x, "MACAddress"), .Select(x => new NetworkInfo(S(x, "Description"), S(x, "MACAddress"),
(x["IPAddress"] as string[] ?? []).Where(ip => !ip.StartsWith("169.254.")).ToList())).ToList(); (x["IPAddress"] as string[] ?? []).Where(ip => !ip.StartsWith("169.254.")).ToList())).ToList();
var memoryModules = Query("Win32_PhysicalMemory", "BankLabel", "DeviceLocator", "Manufacturer", "PartNumber", "SerialNumber", "Capacity")
.Select(x => new MemoryInfo(
string.IsNullOrWhiteSpace(S(x, "BankLabel")) ? S(x, "DeviceLocator") : S(x, "BankLabel"),
S(x, "Manufacturer"), S(x, "PartNumber"), S(x, "SerialNumber"), U(x, "Capacity"))).ToList();
return new HardwareInfo(Environment.MachineName, S(computer, "Manufacturer"), S(computer, "Model"), return new HardwareInfo(Environment.MachineName, S(computer, "Manufacturer"), S(computer, "Model"),
S(bios, "SerialNumber"), S(product, "UUID"), S(os, "Caption"), S(os, "Version"), S(bios, "SerialNumber"), S(product, "UUID"), S(os, "Caption"), S(os, "Version"),
S(os, "OSArchitecture"), S(cpu, "Name"), U(computer, "TotalPhysicalMemory"), disks, networks, S(os, "OSArchitecture"), S(cpu, "Name"), U(computer, "TotalPhysicalMemory"), disks, networks,
collectSoftware ? CollectSoftware(maxSoftwareEntries) : []); memoryModules, collectSoftware ? CollectSoftware(maxSoftwareEntries) : []);
} }
private static IReadOnlyList<SoftwareInfo> CollectSoftware(int maximum) private static IReadOnlyList<SoftwareInfo> CollectSoftware(int maximum)
+4 -1
View File
@@ -15,6 +15,8 @@ internal sealed class AgentConfig
[JsonPropertyName("timeout_seconds")] public int TimeoutSeconds { get; set; } = 30; [JsonPropertyName("timeout_seconds")] public int TimeoutSeconds { get; set; } = 30;
[JsonPropertyName("collect_software")] public bool CollectSoftware { get; set; } = true; [JsonPropertyName("collect_software")] public bool CollectSoftware { get; set; } = true;
[JsonPropertyName("max_software_entries")] public int MaxSoftwareEntries { get; set; } = 250; [JsonPropertyName("max_software_entries")] public int MaxSoftwareEntries { get; set; } = 250;
[JsonPropertyName("create_hardware_modules")] public bool CreateHardwareModules { get; set; } = true;
[JsonPropertyName("create_interfaces")] public bool CreateInterfaces { get; set; } = true;
[JsonPropertyName("tags")] public List<string> Tags { get; set; } = []; [JsonPropertyName("tags")] public List<string> Tags { get; set; } = [];
[JsonPropertyName("custom_fields")] public Dictionary<string, string> CustomFields { get; set; } = new(); [JsonPropertyName("custom_fields")] public Dictionary<string, string> CustomFields { get; set; } = new();
} }
@@ -23,9 +25,10 @@ internal sealed record HardwareInfo(
string Hostname, string Manufacturer, string Model, string Serial, string Uuid, string Hostname, string Manufacturer, string Model, string Serial, string Uuid,
string WindowsCaption, string WindowsVersion, string Architecture, string Processor, string WindowsCaption, string WindowsVersion, string Architecture, string Processor,
ulong MemoryBytes, IReadOnlyList<DiskInfo> Disks, IReadOnlyList<NetworkInfo> Networks, ulong MemoryBytes, IReadOnlyList<DiskInfo> Disks, IReadOnlyList<NetworkInfo> Networks,
IReadOnlyList<SoftwareInfo> Software); IReadOnlyList<MemoryInfo> MemoryModules, IReadOnlyList<SoftwareInfo> Software);
internal sealed record DiskInfo(string DeviceId, string Model, string Serial, ulong SizeBytes); 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 record NetworkInfo(string Name, string MacAddress, IReadOnlyList<string> IpAddresses);
internal sealed record MemoryInfo(string Bank, string Manufacturer, string PartNumber, string Serial, ulong CapacityBytes);
internal sealed record SoftwareInfo(string Name, string Version, string Publisher); internal sealed record SoftwareInfo(string Name, string Version, string Publisher);
internal sealed class Page<T> internal sealed class Page<T>
+46
View File
@@ -26,6 +26,9 @@ internal sealed class SyncService(NetBoxClient client, AgentConfig config)
device = await client.Create("dcim/devices", devicePayload); device = await client.Create("dcim/devices", devicePayload);
} else if (!dryRun) device = await client.Patch("dcim/devices", device.Id, devicePayload); } else if (!dryRun) device = await client.Patch("dcim/devices", device.Id, devicePayload);
if (!dryRun && config.CreateInterfaces) await SyncInterfaces(device.Id, hw.Networks);
if (!dryRun && config.CreateHardwareModules) await SyncHardwareModules(device.Id, hw);
var asset = await client.FindOne("plugins/inventory/assets", ("serial", hw.Serial)); var asset = await client.FindOne("plugins/inventory/assets", ("serial", hw.Serial));
var assetPayload = new Dictionary<string, object?> { var assetPayload = new Dictionary<string, object?> {
["name"] = hw.Hostname, ["serial"] = hw.Serial, ["status"] = "used", ["name"] = hw.Hostname, ["serial"] = hw.Serial, ["status"] = "used",
@@ -39,6 +42,48 @@ internal sealed class SyncService(NetBoxClient client, AgentConfig config)
return (device.Id, asset.Id); return (device.Id, asset.Id);
} }
private async Task SyncInterfaces(int deviceId, IReadOnlyList<NetworkInfo> networks)
{
var usedNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var network in networks.Where(n => !string.IsNullOrWhiteSpace(n.MacAddress)))
{
var baseName = network.Name.Length > 60 ? network.Name[..60] : network.Name;
var name = baseName;
for (var suffix = 2; !usedNames.Add(name); suffix++) name = $"{baseName[..Math.Min(baseName.Length, 56)]} {suffix}";
var iface = await client.FindOne("dcim/interfaces", ("device_id", deviceId.ToString()), ("name", name));
var payload = new { device = deviceId, name, type = "other", enabled = true,
description = network.IpAddresses.Count == 0 ? "Windows Agent" : $"Windows Agent; IP: {string.Join(", ", network.IpAddresses)}" };
iface = iface is null ? await client.Create("dcim/interfaces", payload) : await client.Patch("dcim/interfaces", iface.Id, payload);
var mac = await client.FindOne("dcim/mac-addresses", ("mac_address", network.MacAddress));
var macPayload = new { mac_address = network.MacAddress, assigned_object_type = "dcim.interface", assigned_object_id = iface.Id };
mac = mac is null ? await client.Create("dcim/mac-addresses", macPayload) : await client.Patch("dcim/mac-addresses", mac.Id, macPayload);
await client.Patch("dcim/interfaces", iface.Id, new { primary_mac_address = mac.Id });
}
}
private async Task SyncHardwareModules(int deviceId, HardwareInfo hw)
{
var genericManufacturer = await EnsureManufacturer("Agent-discovered", false);
var components = new List<(string Bay, string Model, string Serial)> { ("CPU 1", hw.Processor, "") };
components.AddRange(hw.MemoryModules.Select((m, i) =>
($"RAM {CleanName(string.IsNullOrWhiteSpace(m.Bank) ? (i + 1).ToString() : m.Bank)}",
$"RAM {m.CapacityBytes / 1073741824d:F0} GiB {m.PartNumber}".Trim(), m.Serial)));
components.AddRange(hw.Disks.Select((d, i) => ($"Disk {i + 1}", $"{d.Model} {d.SizeBytes / 1000000000d:F0} GB", d.Serial)));
foreach (var component in components.Where(c => !string.IsNullOrWhiteSpace(c.Model)))
{
var typeSlug = Slug(component.Model);
var moduleType = await client.FindOne("dcim/module-types", ("manufacturer_id", genericManufacturer.Id.ToString()), ("model", component.Model));
moduleType ??= await client.Create("dcim/module-types", new { manufacturer = genericManufacturer.Id, model = component.Model, slug = typeSlug });
var bay = await client.FindOne("dcim/module-bays", ("device_id", deviceId.ToString()), ("name", component.Bay));
bay ??= await client.Create("dcim/module-bays", new { device = deviceId, name = component.Bay, enabled = true });
var module = await client.FindOne("dcim/modules", ("device_id", deviceId.ToString()), ("module_bay_id", bay.Id.ToString()));
var modulePayload = new { device = deviceId, module_bay = bay.Id, module_type = moduleType.Id, status = "active", serial = component.Serial };
if (module is null) await client.Create("dcim/modules", modulePayload); else await client.Patch("dcim/modules", module.Id, modulePayload);
}
}
private async Task<NetBoxObject?> ResolveSite() private async Task<NetBoxObject?> ResolveSite()
{ {
if (string.IsNullOrWhiteSpace(config.Site)) return null; if (string.IsNullOrWhiteSpace(config.Site)) return null;
@@ -96,5 +141,6 @@ internal sealed class SyncService(NetBoxClient client, AgentConfig config)
return string.Join('\n', lines); return string.Join('\n', lines);
} }
private static string Slug(string value) => string.Concat(value.Trim().ToLowerInvariant().Select(c => char.IsLetterOrDigit(c) ? c : '-')).Trim('-'); private static string Slug(string value) => string.Concat(value.Trim().ToLowerInvariant().Select(c => char.IsLetterOrDigit(c) ? c : '-')).Trim('-');
private static string CleanName(string value) => new(value.Where(c => char.IsLetterOrDigit(c) || c is '-' or '_').ToArray());
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); } 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); }
} }