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.
This commit is contained in:
committed by
cproudlock
parent
cfb0b9ca4e
commit
7040832565
358
agent/ShopDbAdoptionTool/Program.cs
Normal file
358
agent/ShopDbAdoptionTool/Program.cs
Normal file
@@ -0,0 +1,358 @@
|
||||
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; }
|
||||
}
|
||||
30
agent/ShopDbAdoptionTool/ShopDbAdoptionTool.csproj
Normal file
30
agent/ShopDbAdoptionTool/ShopDbAdoptionTool.csproj
Normal file
@@ -0,0 +1,30 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net8.0-windows</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>ShopDbAdoptionTool</RootNamespace>
|
||||
<AssemblyName>shopdb-adopt</AssemblyName>
|
||||
<!-- 0.1.0 (SPIKE): one-shot, agentless enrollment. Uses only the endpoints
|
||||
collector key to create a ShopDB PC (Computer) + Endpoint entry via the
|
||||
existing POST /api/collector/endpoints adopt path. Deliberately NOT the
|
||||
endpoint agent: no service, no polling, no delivery. For sites that want
|
||||
an inventory entry without running the full agent. -->
|
||||
<Version>0.1.0</Version>
|
||||
<InvariantGlobalization>true</InvariantGlobalization>
|
||||
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||
<SelfContained>true</SelfContained>
|
||||
<PublishSingleFile>true</PublishSingleFile>
|
||||
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
|
||||
<EnableCompressionInSingleFile>true</EnableCompressionInSingleFile>
|
||||
<DebugType>none</DebugType>
|
||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="System.Management" Version="8.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
20
agent/ShopDbAdoptionTool/app.manifest
Normal file
20
agent/ShopDbAdoptionTool/app.manifest
Normal file
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||
<assemblyIdentity version="0.1.0.0" name="ShopDbAdoptionTool.app" />
|
||||
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||
<security>
|
||||
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v2">
|
||||
<!-- Adoption reads the machine Uninstall hives and full WMI hardware for
|
||||
the SBOM and hardware collection. Require elevation so the run is not
|
||||
silently partial when launched from a standard user token. -->
|
||||
<requestedExecutionLevel level="requireAdministrator" uiAccess="false" />
|
||||
</requestedPrivileges>
|
||||
</security>
|
||||
</trustInfo>
|
||||
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
|
||||
<application>
|
||||
<!-- Windows 10 and Windows 11 -->
|
||||
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
|
||||
</application>
|
||||
</compatibility>
|
||||
</assembly>
|
||||
@@ -2,4 +2,5 @@
|
||||
<Project Path="ShopDbEndpointAgent.Core/ShopDbEndpointAgent.Core.csproj" />
|
||||
<Project Path="ShopDbEndpointAgent.Tray/ShopDbEndpointAgent.Tray.csproj" />
|
||||
<Project Path="ShopDbEndpointAgent/ShopDbEndpointAgent.csproj" />
|
||||
<Project Path="ShopDbAdoptionTool/ShopDbAdoptionTool.csproj" />
|
||||
</Solution>
|
||||
|
||||
Reference in New Issue
Block a user