diff --git a/README.md b/README.md index a73a076..f88c0f7 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/config.example.json b/config.example.json index 922243f..fd3ce84 100644 --- a/config.example.json +++ b/config.example.json @@ -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", diff --git a/src/HardwareCollector.cs b/src/HardwareCollector.cs index 5a6b964..1b9543f 100644 --- a/src/HardwareCollector.cs +++ b/src/HardwareCollector.cs @@ -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 CollectSoftware(int maximum) + { + var software = new Dictionary(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(); diff --git a/src/Models.cs b/src/Models.cs index d7fd44d..e0899ab 100644 --- a/src/Models.cs +++ b/src/Models.cs @@ -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 Tags { get; set; } = []; [JsonPropertyName("custom_fields")] public Dictionary 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 Disks, IReadOnlyList Networks); + ulong MemoryBytes, IReadOnlyList Disks, IReadOnlyList Networks, + IReadOnlyList Software); internal sealed record DiskInfo(string DeviceId, string Model, string Serial, ulong SizeBytes); internal sealed record NetworkInfo(string Name, string MacAddress, IReadOnlyList IpAddresses); +internal sealed record SoftwareInfo(string Name, string Version, string Publisher); internal sealed class Page { @@ -35,4 +40,3 @@ internal sealed class NetBoxObject [JsonPropertyName("slug")] public string? Slug { get; set; } [JsonPropertyName("serial")] public string? Serial { get; set; } } - diff --git a/src/NetBoxClient.cs b/src/NetBoxClient.cs index 6f8788c..d2361dd 100644 --- a/src/NetBoxClient.cs +++ b/src/NetBoxClient.cs @@ -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")); diff --git a/src/Program.cs b/src/Program.cs index f351ec0..a14b634 100644 --- a/src/Program.cs +++ b/src/Program.cs @@ -14,7 +14,7 @@ static async Task MainAsync(string[] args) var config = JsonSerializer.Deserialize(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]"); diff --git a/src/SyncService.cs b/src/SyncService.cs index 39891ac..9589ced 100644 --- a/src/SyncService.cs +++ b/src/SyncService.cs @@ -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 { ["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 { ["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 { + "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 data) { foreach (var key in data.Where(x => x.Value is null).Select(x => x.Key).ToList()) data.Remove(key); } }