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,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