using System.Management; using System.Net.Http.Json; using System.Net.NetworkInformation; using System.Runtime.Versioning; using System.Text.Json; using System.Text.Json.Serialization; namespace ShopDbAdoptionTool; // ShopDB adoption tool (SPIKE). A one-shot, agentless enrollment client: it uses // only the endpoints collector key to create a ShopDB PC (Computer) + Endpoint // entry via the existing collector adopt path, then exits. It is NOT the endpoint // agent - it installs nothing, runs no service, and reports no ongoing telemetry. // For sites that want an inventory entry without running the full agent. // // Configuration comes from adopt.config.json beside the exe, or from // --baseurl / --collectorkey / --hostname arguments (arguments win). The // collector key is never written to disk by this tool; it is read from the // config the operator generated and placed next to the exe. [SupportedOSPlatform("windows")] internal static class Program { private const string ReportPath = "/api/collector/endpoints"; private static async Task Main(string[] args) { try { var config = LoadConfig(args); if (string.IsNullOrWhiteSpace(config.BaseUrl) || string.IsNullOrWhiteSpace(config.CollectorKey)) { Console.Error.WriteLine( "Missing baseurl or collectorkey. Provide adopt.config.json next to the " + "exe or pass --baseurl and --collectorkey."); return 2; } // The app manifest forces UAC elevation, so this is a belt-and-braces // guard for edge cases (for example a stripped manifest). Full WMI and // the machine Uninstall hives need admin, or collection is partial. if (!IsElevated()) { Console.Error.WriteLine( "This tool must run as administrator so hardware and installed-" + "software collection succeeds. Right-click and Run as administrator."); return 3; } var payload = BuildPayload(config); Console.WriteLine($"Adopting '{payload.Hostname}' into {config.BaseUrl} ..."); Console.WriteLine( $"Collected {payload.Applications?.Length ?? 0} installed applications."); using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(60) }; client.DefaultRequestHeaders.Add("X-API-Key", config.CollectorKey); var url = config.BaseUrl!.TrimEnd('/') + ReportPath; using var response = await client.PostAsJsonAsync(url, payload); var body = await response.Content.ReadAsStringAsync(); if (!response.IsSuccessStatusCode) { Console.Error.WriteLine( $"Adoption failed: HTTP {(int)response.StatusCode}. {body}"); return 1; } Console.WriteLine("Adoption succeeded."); Console.WriteLine(body); return 0; } catch (Exception error) { Console.Error.WriteLine($"Adoption error: {error.Message}"); return 1; } } private static AdoptConfig LoadConfig(string[] args) { var config = new AdoptConfig(); var configPath = Path.Combine(AppContext.BaseDirectory, "adopt.config.json"); if (File.Exists(configPath)) { var fromFile = JsonSerializer.Deserialize( File.ReadAllText(configPath), new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); if (fromFile is not null) config = fromFile; } for (var i = 0; i < args.Length - 1; i++) { var value = args[i + 1]; switch (args[i].ToLowerInvariant()) { case "--baseurl": config.BaseUrl = value; break; case "--collectorkey": config.CollectorKey = value; break; case "--hostname": config.Hostname = value; break; } } return config; } private static AdoptPayload BuildPayload(AdoptConfig config) { var hostname = !string.IsNullOrWhiteSpace(config.Hostname) ? config.Hostname! : Environment.MachineName; return new AdoptPayload { Hostname = hostname, Adopt = true, LoggedInUser = GetLoggedInUser(), SerialNumber = WmiFirst("SELECT SerialNumber FROM Win32_BIOS", "SerialNumber"), PcType = WmiFirst("SELECT Model FROM Win32_ComputerSystem", "Model"), VendorName = WmiFirst("SELECT Manufacturer FROM Win32_ComputerSystem", "Manufacturer"), ModelNumber = GetModelNumber(), OsName = WmiFirst("SELECT Caption FROM Win32_OperatingSystem", "Caption"), IpAddress = ResolveIpAddress(), LastBootTime = GetLastBootTime(), Applications = GetApplications(), AgentVersion = "adopt-0.1.0", }; } // Mirrors the endpoint agent: prefer the WMI logged-on user, fall back to // the process identity so the field is never blank. private static string GetLoggedInUser() { var user = WmiFirst("SELECT UserName FROM Win32_ComputerSystem", "UserName"); return string.IsNullOrWhiteSpace(user) ? $"{Environment.UserDomainName}\\{Environment.UserName}" : user; } // Human-readable model first (what pctype classifies on); SystemSKUNumber is // an OEM SKU code, used only when Model is blank. Matches EndpointCollector. private static string? GetModelNumber() { var model = WmiFirst("SELECT Model FROM Win32_ComputerSystem", "Model"); if (!string.IsNullOrWhiteSpace(model)) return model; return WmiFirst("SELECT SystemSKUNumber FROM Win32_ComputerSystem", "SystemSKUNumber"); } private static DateTimeOffset? GetLastBootTime() { var raw = WmiFirst("SELECT LastBootUpTime FROM Win32_OperatingSystem", "LastBootUpTime"); if (string.IsNullOrWhiteSpace(raw)) return null; try { return ManagementDateTimeConverter.ToDateTime(raw).ToUniversalTime(); } catch { return null; } } // Run one WMI query and return the first row's column as a trimmed string, // or null. Best-effort: WMI failures never abort adoption. private static string? WmiFirst(string query, string column) { try { using var searcher = new ManagementObjectSearcher(query); foreach (var item in searcher.Get()) { var value = item[column]?.ToString()?.Trim(); if (!string.IsNullOrWhiteSpace(value)) return value; } } catch { // Best-effort: the server treats every hardware field as optional. } return null; } // Installed-software SBOM. Mirrors the endpoint agent exactly: enumerate both // Uninstall registry hives (64-bit and WOW6432Node), keep entries with a // DisplayName, de-dupe on name+version, and emit {displayname, displayversion, // publisher}. Requires admin to read the machine hives reliably (see the // app manifest). Never throws out to the caller. private static object[] GetApplications() { const string uninstall64 = @"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall"; const string uninstall32 = @"SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall"; var result = new List(); var seen = new HashSet(StringComparer.OrdinalIgnoreCase); CollectUninstallHive(uninstall64, result, seen); CollectUninstallHive(uninstall32, result, seen); return result.ToArray(); } private static void CollectUninstallHive(string subKey, List result, HashSet seen) { try { using var machine = Microsoft.Win32.RegistryKey.OpenBaseKey( Microsoft.Win32.RegistryHive.LocalMachine, Microsoft.Win32.RegistryView.Default); using var uninstall = machine.OpenSubKey(subKey); if (uninstall is null) return; foreach (var name in uninstall.GetSubKeyNames()) { try { using var entry = uninstall.OpenSubKey(name); var displayName = entry?.GetValue("DisplayName")?.ToString()?.Trim(); if (string.IsNullOrWhiteSpace(displayName)) continue; var displayVersion = entry?.GetValue("DisplayVersion")?.ToString()?.Trim() ?? ""; var publisher = entry?.GetValue("Publisher")?.ToString()?.Trim() ?? ""; if (!seen.Add(displayName + "\u0000" + displayVersion)) continue; result.Add(new { displayname = displayName, displayversion = displayVersion, publisher, }); } catch { // Skip unreadable entries; keep collecting the rest. } } } catch { // Skip an unreadable hive; the other hive may still return data. } } private static string? ResolveIpAddress() { try { foreach (var network in NetworkInterface.GetAllNetworkInterfaces()) { if (network.OperationalStatus != OperationalStatus.Up) continue; if (network.NetworkInterfaceType == NetworkInterfaceType.Loopback) continue; foreach (var address in network.GetIPProperties().UnicastAddresses) { if (address.Address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork) return address.Address.ToString(); } } } catch { // Best-effort: the server treats ipaddress as optional. } return null; } // True when the process runs with the Administrators group in its token. private static bool IsElevated() { try { using var identity = System.Security.Principal.WindowsIdentity.GetCurrent(); var principal = new System.Security.Principal.WindowsPrincipal(identity); return principal.IsInRole( System.Security.Principal.WindowsBuiltInRole.Administrator); } catch { return false; } } } internal sealed class AdoptConfig { [JsonPropertyName("baseurl")] public string? BaseUrl { get; set; } [JsonPropertyName("collectorkey")] public string? CollectorKey { get; set; } [JsonPropertyName("hostname")] public string? Hostname { get; set; } } // Payload keys mirror the endpoints collector adopt schema exactly (lowercase, // concatenated) so the server stores them without translation. This tool sends // the adopt identity plus the hardware and SBOM subset the endpoint agent // collects; ongoing health telemetry (cpu, services, events) is intentionally // absent because this is agentless enrollment, not the agent. internal sealed class AdoptPayload { [JsonPropertyName("hostname")] public string Hostname { get; set; } = ""; [JsonPropertyName("adopt")] public bool Adopt { get; set; } [JsonPropertyName("loggedinuser")] public string? LoggedInUser { get; set; } [JsonPropertyName("serialnumber")] public string? SerialNumber { get; set; } [JsonPropertyName("pctype")] public string? PcType { get; set; } [JsonPropertyName("vendorname")] public string? VendorName { get; set; } [JsonPropertyName("modelnumber")] public string? ModelNumber { get; set; } [JsonPropertyName("osname")] public string? OsName { get; set; } [JsonPropertyName("ipaddress")] public string? IpAddress { get; set; } [JsonPropertyName("lastboottime")] public DateTimeOffset? LastBootTime { get; set; } [JsonPropertyName("applications")] public object[]? Applications { get; set; } [JsonPropertyName("agentversion")] public string? AgentVersion { get; set; } }