feat: add software inventory and insecure TLS option
Build Windows agent / build (win-arm64) (push) Has been cancelled
Build Windows agent / build (win-x64) (push) Has been cancelled

This commit is contained in:
2026-07-21 13:46:43 +02:00
parent 61aa9ad61b
commit a7b4ac297a
7 changed files with 67 additions and 14 deletions
+8 -2
View File
@@ -5,7 +5,8 @@ Ein schlanker Windows-Agent, der Hardwaredaten per WMI erfasst und einen NetBox-
## Was synchronisiert wird
- Rechnername, Hersteller, Modell, BIOS-Seriennummer und Geräte-UUID
- Windows-Version und Architektur, CPU, RAM, Datenträger, MAC- und IP-Adressen
- 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
@@ -35,13 +36,18 @@ Optionale Custom Fields müssen für `dcim.Device` existieren. Die linke Seite i
"location": "office-1",
"device_role": "windows-client",
"verify_tls": true,
"allow_insecure_tls": false,
"timeout_seconds": 30,
"collect_software": true,
"max_software_entries": 250,
"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.
`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.
`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.
## Ausführen und bauen
+3
View File
@@ -6,7 +6,10 @@
"location": "office-1",
"device_role": "windows-client",
"verify_tls": true,
"allow_insecure_tls": false,
"timeout_seconds": 30,
"collect_software": true,
"max_software_entries": 250,
"tags": ["windows-agent"],
"custom_fields": {
"agent_version": "netbox_agent_version",
+25 -2
View File
@@ -1,10 +1,11 @@
using System.Management;
using Microsoft.Win32;
namespace NetBoxWindowsAgent;
internal static class HardwareCollector
{
public static HardwareInfo Collect()
public static HardwareInfo Collect(bool collectSoftware = true, int maxSoftwareEntries = 250)
{
var computer = First("Win32_ComputerSystem", "Manufacturer", "Model", "TotalPhysicalMemory");
var product = First("Win32_ComputerSystemProduct", "UUID");
@@ -21,7 +22,29 @@ internal static class HardwareCollector
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);
S(os, "OSArchitecture"), S(cpu, "Name"), U(computer, "TotalPhysicalMemory"), disks, networks,
collectSoftware ? CollectSoftware(maxSoftwareEntries) : []);
}
private static IReadOnlyList<SoftwareInfo> CollectSoftware(int maximum)
{
var software = new Dictionary<string, SoftwareInfo>(StringComparer.OrdinalIgnoreCase);
foreach (var view in new[] { RegistryView.Registry64, RegistryView.Registry32 })
{
using var hive = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, view);
using var uninstall = hive.OpenSubKey(@"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall");
if (uninstall is null) continue;
foreach (var subKeyName in uninstall.GetSubKeyNames())
{
using var entry = uninstall.OpenSubKey(subKeyName);
var name = Convert.ToString(entry?.GetValue("DisplayName"))?.Trim();
if (string.IsNullOrWhiteSpace(name) || Convert.ToInt32(entry?.GetValue("SystemComponent", 0)) == 1) continue;
var version = Convert.ToString(entry?.GetValue("DisplayVersion"))?.Trim() ?? "";
var publisher = Convert.ToString(entry?.GetValue("Publisher"))?.Trim() ?? "";
software.TryAdd($"{name}\0{version}", new SoftwareInfo(name, version, publisher));
}
}
return software.Values.OrderBy(x => x.Name, StringComparer.OrdinalIgnoreCase).Take(Math.Clamp(maximum, 0, 1000)).ToList();
}
private static ManagementBaseObject First(string cls, params string[] fields) => Query(cls, fields).First();
+6 -2
View File
@@ -11,7 +11,10 @@ internal sealed class AgentConfig
[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("allow_insecure_tls")] public bool AllowInsecureTls { get; set; } = false;
[JsonPropertyName("timeout_seconds")] public int TimeoutSeconds { get; set; } = 30;
[JsonPropertyName("collect_software")] public bool CollectSoftware { get; set; } = true;
[JsonPropertyName("max_software_entries")] public int MaxSoftwareEntries { get; set; } = 250;
[JsonPropertyName("tags")] public List<string> Tags { get; set; } = [];
[JsonPropertyName("custom_fields")] public Dictionary<string, string> CustomFields { get; set; } = new();
}
@@ -19,9 +22,11 @@ internal sealed class AgentConfig
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);
ulong MemoryBytes, IReadOnlyList<DiskInfo> Disks, IReadOnlyList<NetworkInfo> Networks,
IReadOnlyList<SoftwareInfo> Software);
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 SoftwareInfo(string Name, string Version, string Publisher);
internal sealed class Page<T>
{
@@ -35,4 +40,3 @@ internal sealed class NetBoxObject
[JsonPropertyName("slug")] public string? Slug { get; set; }
[JsonPropertyName("serial")] public string? Serial { get; set; }
}
+2 -1
View File
@@ -12,7 +12,8 @@ internal sealed class NetBoxClient : IDisposable
public NetBoxClient(AgentConfig config)
{
var handler = new HttpClientHandler();
if (!config.VerifyTls) handler.ServerCertificateCustomValidationCallback = (_, _, _, _) => true;
if (config.AllowInsecureTls || !config.VerifyTls)
handler.ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator;
_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"));
+2 -1
View File
@@ -14,7 +14,7 @@ static async Task<int> MainAsync(string[] args)
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();
var hardware = HardwareCollector.Collect(config.CollectSoftware, config.MaxSoftwareEntries);
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);
@@ -36,5 +36,6 @@ 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.");
if (c.MaxSoftwareEntries < 0 || c.MaxSoftwareEntries > 1000) throw new InvalidOperationException("max_software_entries muss zwischen 0 und 1000 liegen.");
}
static void Usage() => Console.WriteLine("agent.exe [--config PFAD] [--dry-run] [--help]");
+21 -6
View File
@@ -13,11 +13,12 @@ internal sealed class SyncService(NetBoxClient client, AgentConfig config)
var device = await client.FindOne("dcim/devices", ("serial", hw.Serial));
var customFields = BuildCustomFields(hw);
var inventoryReport = HardwareSummary(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
["comments"] = inventoryReport, ["custom_fields"] = customFields
};
RemoveNulls(devicePayload);
if (device is null) {
@@ -29,7 +30,7 @@ internal sealed class SyncService(NetBoxClient client, AgentConfig config)
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}"
["description"] = $"Windows-Client; UUID {hw.Uuid}", ["comments"] = inventoryReport
};
if (asset is null) {
if (dryRun) return (device.Id, 0);
@@ -76,10 +77,24 @@ internal sealed class SyncService(NetBoxClient client, AgentConfig config)
};
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 HardwareSummary(HardwareInfo hw)
{
var lines = new List<string> {
"Automatisch durch NetBox Windows Agent aktualisiert.", "", "## Hardware",
$"- System: {hw.Manufacturer} {hw.Model}", $"- Seriennummer: {hw.Serial}", $"- UUID: {hw.Uuid}",
$"- Betriebssystem: {hw.WindowsCaption} {hw.WindowsVersion} ({hw.Architecture})",
$"- CPU: {hw.Processor}", $"- RAM: {hw.MemoryBytes / 1073741824d:F1} GiB", "", "### Datenträger"
};
lines.AddRange(hw.Disks.Select(d => $"- {d.Model} — {d.SizeBytes / 1000000000d:F0} GB — S/N {d.Serial} — {d.DeviceId}"));
lines.AddRange(["", "### Netzwerk"]);
lines.AddRange(hw.Networks.Select(n => $"- {n.Name} — {n.MacAddress} — {string.Join(", ", n.IpAddresses)}"));
if (hw.Software.Count > 0)
{
lines.AddRange(["", $"## Installierte Software ({hw.Software.Count})"]);
lines.AddRange(hw.Software.Select(s => $"- {s.Name}{(string.IsNullOrWhiteSpace(s.Version) ? "" : $" {s.Version}")}{(string.IsNullOrWhiteSpace(s.Publisher) ? "" : $" {s.Publisher}")}"));
}
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 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); }
}