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 ## Was synchronisiert wird
- Rechnername, Hersteller, Modell, BIOS-Seriennummer und Geräte-UUID - 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 - NetBox Site und Location aus der Konfiguration
- `dcim.Device` sowie das verknüpfte `netbox_inventory.Asset` - `dcim.Device` sowie das verknüpfte `netbox_inventory.Asset`
- Hersteller und Device Type werden bei Bedarf angelegt - 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", "location": "office-1",
"device_role": "windows-client", "device_role": "windows-client",
"verify_tls": true, "verify_tls": true,
"allow_insecure_tls": false,
"timeout_seconds": 30, "timeout_seconds": 30,
"collect_software": true,
"max_software_entries": 250,
"tags": [], "tags": [],
"custom_fields": {} "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 ## Ausführen und bauen
+3
View File
@@ -6,7 +6,10 @@
"location": "office-1", "location": "office-1",
"device_role": "windows-client", "device_role": "windows-client",
"verify_tls": true, "verify_tls": true,
"allow_insecure_tls": false,
"timeout_seconds": 30, "timeout_seconds": 30,
"collect_software": true,
"max_software_entries": 250,
"tags": ["windows-agent"], "tags": ["windows-agent"],
"custom_fields": { "custom_fields": {
"agent_version": "netbox_agent_version", "agent_version": "netbox_agent_version",
+25 -2
View File
@@ -1,10 +1,11 @@
using System.Management; using System.Management;
using Microsoft.Win32;
namespace NetBoxWindowsAgent; namespace NetBoxWindowsAgent;
internal static class HardwareCollector 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 computer = First("Win32_ComputerSystem", "Manufacturer", "Model", "TotalPhysicalMemory");
var product = First("Win32_ComputerSystemProduct", "UUID"); 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"), 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) : []);
}
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(); 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("location")] public string? Location { get; set; }
[JsonPropertyName("device_role")] public string DeviceRole { get; set; } = "windows-client"; [JsonPropertyName("device_role")] public string DeviceRole { get; set; } = "windows-client";
[JsonPropertyName("verify_tls")] public bool VerifyTls { get; set; } = true; [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("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("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();
} }
@@ -19,9 +22,11 @@ internal sealed class AgentConfig
internal sealed record HardwareInfo( 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);
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 SoftwareInfo(string Name, string Version, string Publisher);
internal sealed class Page<T> internal sealed class Page<T>
{ {
@@ -35,4 +40,3 @@ internal sealed class NetBoxObject
[JsonPropertyName("slug")] public string? Slug { get; set; } [JsonPropertyName("slug")] public string? Slug { get; set; }
[JsonPropertyName("serial")] public string? Serial { 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) public NetBoxClient(AgentConfig config)
{ {
var handler = new HttpClientHandler(); 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 = new HttpClient(handler) { BaseAddress = new Uri(config.NetBoxUrl.TrimEnd('/') + "/"), Timeout = TimeSpan.FromSeconds(config.TimeoutSeconds) };
_http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Token", config.ApiToken); _http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Token", config.ApiToken);
_http.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); _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)) var config = JsonSerializer.Deserialize<AgentConfig>(await File.ReadAllTextAsync(configPath), new JsonSerializerOptions(JsonSerializerDefaults.Web))
?? throw new InvalidOperationException("Konfiguration ist leer."); ?? throw new InvalidOperationException("Konfiguration ist leer.");
Validate(config); 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}"); Console.WriteLine($"Erfasst: {hardware.Hostname}, {hardware.Manufacturer} {hardware.Model}, S/N {hardware.Serial}");
using var client = new NetBoxClient(config); using var client = new NetBoxClient(config);
var result = await new SyncService(client, config).Sync(hardware, dryRun); 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 (!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.ApiToken) || c.ApiToken == "CHANGE_ME") throw new InvalidOperationException("api_token fehlt.");
if (string.IsNullOrWhiteSpace(c.DeviceRole)) throw new InvalidOperationException("device_role 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]"); 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 device = await client.FindOne("dcim/devices", ("serial", hw.Serial));
var customFields = BuildCustomFields(hw); var customFields = BuildCustomFields(hw);
var inventoryReport = HardwareSummary(hw);
var devicePayload = new Dictionary<string, object?> { var devicePayload = new Dictionary<string, object?> {
["name"] = hw.Hostname, ["device_type"] = deviceType.Id, ["role"] = role.Id, ["name"] = hw.Hostname, ["device_type"] = deviceType.Id, ["role"] = role.Id,
["site"] = site?.Id, ["location"] = location?.Id, ["status"] = "active", ["site"] = site?.Id, ["location"] = location?.Id, ["status"] = "active",
["serial"] = hw.Serial, ["description"] = $"{hw.WindowsCaption} ({hw.Architecture})", ["serial"] = hw.Serial, ["description"] = $"{hw.WindowsCaption} ({hw.Architecture})",
["comments"] = HardwareSummary(hw), ["custom_fields"] = customFields ["comments"] = inventoryReport, ["custom_fields"] = customFields
}; };
RemoveNulls(devicePayload); RemoveNulls(devicePayload);
if (device is null) { if (device is null) {
@@ -29,7 +30,7 @@ internal sealed class SyncService(NetBoxClient client, AgentConfig config)
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",
["device_type"] = deviceType.Id, ["device"] = device.Id, ["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 (asset is null) {
if (dryRun) return (device.Id, 0); 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]); return config.CustomFields.Where(x => available.ContainsKey(x.Key)).ToDictionary(x => x.Value, x => available[x.Key]);
} }
private static string HardwareSummary(HardwareInfo hw) => 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" + var lines = new List<string> {
$"Netzwerk: {string.Join(", ", hw.Networks.Select(n => $"{n.Name} [{n.MacAddress}]"))}"; "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 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); } 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); }
} }