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