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
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
bin/
|
||||
obj/
|
||||
dist/
|
||||
.tools/
|
||||
config.json
|
||||
*.log
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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]");
|
||||
@@ -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); }
|
||||
}
|
||||
Reference in New Issue
Block a user