Files
benjamin.baker 7040832565 Add agentless adoption tool for endpoints
Standalone one-shot enrollment tool that creates a ShopDB PC + Endpoint entry using only the endpoints collector key, reusing the existing collector adopt path. Collects identity, WMI hardware, and installed-software SBOM; requires admin. Server-side packaging route attaches to the existing endpoints blueprint; no core contract change.
2026-08-30 15:19:35 -04:00

359 lines
13 KiB
C#

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<int> 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<AdoptConfig>(
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<object>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
CollectUninstallHive(uninstall64, result, seen);
CollectUninstallHive(uninstall32, result, seen);
return result.ToArray();
}
private static void CollectUninstallHive(string subKey, List<object> result,
HashSet<string> 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; }
}