commit 3119af1784d34c7fb381585a395611fe2c7184da Author: Louis Date: Tue Jul 21 13:25:51 2026 +0200 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 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..c5fc01f --- /dev/null +++ b/.github/workflows/build.yml @@ -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 + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..01e295d --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +bin/ +obj/ +dist/ +.tools/ +config.json +*.log + diff --git a/NetBoxAgent.csproj b/NetBoxAgent.csproj new file mode 100644 index 0000000..7d8ada3 --- /dev/null +++ b/NetBoxAgent.csproj @@ -0,0 +1,15 @@ + + + Exe + net8.0-windows + enable + enable + agent + NetBoxWindowsAgent + 0.1.0 + + + + + + diff --git a/README.md b/README.md new file mode 100644 index 0000000..a73a076 --- /dev/null +++ b/README.md @@ -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 +``` diff --git a/config.example.json b/config.example.json new file mode 100644 index 0000000..922243f --- /dev/null +++ b/config.example.json @@ -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" + } +} diff --git a/scripts/build.ps1 b/scripts/build.ps1 new file mode 100644 index 0000000..d549a8e --- /dev/null +++ b/scripts/build.ps1 @@ -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" + diff --git a/scripts/install.ps1 b/scripts/install.ps1 new file mode 100644 index 0000000..36e272d --- /dev/null +++ b/scripts/install.ps1 @@ -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') + diff --git a/scripts/uninstall.ps1 b/scripts/uninstall.ps1 new file mode 100644 index 0000000..b46f79a --- /dev/null +++ b/scripts/uninstall.ps1 @@ -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 +} + diff --git a/src/HardwareCollector.cs b/src/HardwareCollector.cs new file mode 100644 index 0000000..5a6b964 --- /dev/null +++ b/src/HardwareCollector.cs @@ -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 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; +} diff --git a/src/Models.cs b/src/Models.cs new file mode 100644 index 0000000..d7fd44d --- /dev/null +++ b/src/Models.cs @@ -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 Tags { get; set; } = []; + [JsonPropertyName("custom_fields")] public Dictionary 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 Disks, IReadOnlyList Networks); +internal sealed record DiskInfo(string DeviceId, string Model, string Serial, ulong SizeBytes); +internal sealed record NetworkInfo(string Name, string MacAddress, IReadOnlyList IpAddresses); + +internal sealed class Page +{ + [JsonPropertyName("count")] public int Count { get; set; } + [JsonPropertyName("results")] public List 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; } +} + diff --git a/src/NetBoxClient.cs b/src/NetBoxClient.cs new file mode 100644 index 0000000..6f8788c --- /dev/null +++ b/src/NetBoxClient.cs @@ -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 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>(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 Create(string endpoint, object payload) => + await Send(HttpMethod.Post, $"api/{endpoint}/", payload); + public async Task Patch(string endpoint, int id, object payload) => + await Send(HttpMethod.Patch, $"api/{endpoint}/{id}/", payload); + + private async Task Send(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(body, _json) ?? throw new InvalidOperationException("NetBox lieferte eine leere Antwort."); + } + public void Dispose() => _http.Dispose(); +} diff --git a/src/Program.cs b/src/Program.cs new file mode 100644 index 0000000..e726f87 --- /dev/null +++ b/src/Program.cs @@ -0,0 +1,33 @@ +using System.Text.Json; +using NetBoxWindowsAgent; + +return await MainAsync(args); + +static async Task 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(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]"); diff --git a/src/SyncService.cs b/src/SyncService.cs new file mode 100644 index 0000000..39891ac --- /dev/null +++ b/src/SyncService.cs @@ -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 { + ["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 { + ["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 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 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 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 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 BuildCustomFields(HardwareInfo hw) + { + var available = new Dictionary { + ["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 data) { foreach (var key in data.Where(x => x.Value is null).Select(x => x.Key).ToList()) data.Remove(key); } +}