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.Core/ShopDbEndpointAgent.Core.csproj" />
|
||||||
<Project Path="ShopDbEndpointAgent.Tray/ShopDbEndpointAgent.Tray.csproj" />
|
<Project Path="ShopDbEndpointAgent.Tray/ShopDbEndpointAgent.Tray.csproj" />
|
||||||
<Project Path="ShopDbEndpointAgent/ShopDbEndpointAgent.csproj" />
|
<Project Path="ShopDbEndpointAgent/ShopDbEndpointAgent.csproj" />
|
||||||
|
<Project Path="ShopDbAdoptionTool/ShopDbAdoptionTool.csproj" />
|
||||||
</Solution>
|
</Solution>
|
||||||
|
|||||||
77
plugins/endpoints/ADOPTION-TOOL-SPIKE.md
Normal file
77
plugins/endpoints/ADOPTION-TOOL-SPIKE.md
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
# Adoption tool spike
|
||||||
|
|
||||||
|
Status: spike. Built on the `origin/main` baseline so it can be cherry-picked as
|
||||||
|
a single unit once endpoint agent development is complete.
|
||||||
|
|
||||||
|
## What it is
|
||||||
|
|
||||||
|
A one-shot, agentless enrollment tool. An admin generates a small download from
|
||||||
|
the Endpoints key settings page; running the tool once on a PC creates a ShopDB
|
||||||
|
PC (Computer) and Endpoint entry using only the endpoints collector key. It is
|
||||||
|
NOT the endpoint agent: it installs nothing, runs no service, and sends no
|
||||||
|
ongoing telemetry. It targets sites that want an inventory entry without running
|
||||||
|
the full agent.
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
1. Admin generates or reuses an endpoint agent key (existing `/agentkey` flow).
|
||||||
|
2. Admin opens the "Adoption tool" section, confirms the base URL (prefilled
|
||||||
|
from the browser origin) and pastes the collector key (prefilled when a key
|
||||||
|
was just generated, since the secret is only shown once).
|
||||||
|
3. `POST /api/endpoints/adoptiontool/package` (admin-gated) returns a ZIP with
|
||||||
|
`adopt.config.json`, `README.txt`, and `shopdb-adopt.exe` when a prebuilt
|
||||||
|
binary is staged on the server.
|
||||||
|
4. On the target PC, `shopdb-adopt.exe` reads the config beside it (or CLI args),
|
||||||
|
then POSTs to `/api/collector/endpoints` with `X-API-Key` and `adopt=true`.
|
||||||
|
The existing `_adopt_computer` path creates the Computer + EndpointState.
|
||||||
|
|
||||||
|
## Collection
|
||||||
|
|
||||||
|
The tool gathers the same identity, hardware, and SBOM subset the endpoint
|
||||||
|
agent collects, using identical WMI queries and wire keys so the server stores
|
||||||
|
it without translation:
|
||||||
|
|
||||||
|
- Identity: `hostname`, `loggedinuser`, `ipaddress`.
|
||||||
|
- Hardware: `serialnumber` (Win32_BIOS), `vendorname` / `pctype` / `modelnumber`
|
||||||
|
(Win32_ComputerSystem), `osname` and `lastboottime` (Win32_OperatingSystem).
|
||||||
|
- SBOM: `applications` = both Uninstall registry hives (64-bit + WOW6432Node),
|
||||||
|
de-duped `{displayname, displayversion, publisher}`. The server logs this as
|
||||||
|
an unpersisted key today (same as the agent) until the SBOM store lands.
|
||||||
|
|
||||||
|
The tool requires administrator rights (enforced by its app manifest
|
||||||
|
`requireAdministrator`, with a runtime elevation guard) because the machine
|
||||||
|
Uninstall hives and full WMI need elevation, or collection would be silently
|
||||||
|
partial.
|
||||||
|
|
||||||
|
## Cherry-pick boundary
|
||||||
|
|
||||||
|
The spike is deliberately isolated. Cherry-pick exactly:
|
||||||
|
|
||||||
|
- `agent/ShopDbAdoptionTool/` (new standalone tool project, incl. `app.manifest`)
|
||||||
|
- `agent/ShopDbEndpointAgent.slnx` (one line adding the project)
|
||||||
|
- `plugins/endpoints/api/adoption.py` (new module; attaches to `endpoints_bp`)
|
||||||
|
- `plugins/endpoints/api/__init__.py` (one-line import to register the route)
|
||||||
|
- `plugins/endpoints/frontend/views/AgentKeySettings.vue` (new UI section only)
|
||||||
|
- `plugins/endpoints/ADOPTION-TOOL-SPIKE.md` (this file)
|
||||||
|
|
||||||
|
`plugins/endpoints/api/routes.py` is intentionally untouched so the delivery
|
||||||
|
spike changes there do not tangle with this feature.
|
||||||
|
|
||||||
|
## Prebuilt exe distribution
|
||||||
|
|
||||||
|
The api container has no .NET SDK, so the package embeds the exe only when a
|
||||||
|
prebuilt copy is staged at `instance/adoptiontool/shopdb-adopt.exe`. Otherwise
|
||||||
|
the ZIP ships the config plus README (build the exe out of band, same model as
|
||||||
|
the endpoint agent installer bundle). Follow-up: a CI job that publishes
|
||||||
|
`shopdb-adopt.exe` and stages it into the image or instance path.
|
||||||
|
|
||||||
|
## Known follow-ups
|
||||||
|
|
||||||
|
- Agentless health signal: an adopted PC never reports again, so
|
||||||
|
`EndpointState.lastreportedat` reflects only the adoption moment. The fleet
|
||||||
|
health/dashboard logic should distinguish "agentless, adopted" from
|
||||||
|
"agent expected but silent" so these PCs do not read as permanently failed.
|
||||||
|
Left out of this spike to keep the server create-path unchanged for a clean
|
||||||
|
cherry-pick; the tool does not send an `agentless` marker yet because the
|
||||||
|
server does not persist one (`KNOWN_PAYLOAD_KEYS`).
|
||||||
|
- CI packaging of the prebuilt exe (see above).
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
"""Endpoints plugin API."""
|
"""Endpoints plugin API."""
|
||||||
|
|
||||||
from .routes import endpoints_bp
|
from .routes import endpoints_bp
|
||||||
|
# Attaches the adoption-tool packaging route to endpoints_bp (spike).
|
||||||
|
from . import adoption # noqa: F401
|
||||||
|
|
||||||
__all__ = ['endpoints_bp']
|
__all__ = ['endpoints_bp']
|
||||||
|
|||||||
107
plugins/endpoints/api/adoption.py
Normal file
107
plugins/endpoints/api/adoption.py
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
"""Endpoints adoption-tool packaging (SPIKE).
|
||||||
|
|
||||||
|
This module is deliberately self-contained so it can be cherry-picked onto
|
||||||
|
main as a single unit. It attaches ONE admin-gated route to the existing
|
||||||
|
endpoints blueprint and never edits routes.py.
|
||||||
|
|
||||||
|
The adoption tool is NOT the endpoint agent. It is a one-shot, agentless
|
||||||
|
enrollment client: given only the endpoints collector key and this server's
|
||||||
|
base URL, it creates a ShopDB PC (Computer) + Endpoint entry via the existing
|
||||||
|
POST /api/collector/endpoints adopt path, then exits. This route packages that
|
||||||
|
tool for download: a ZIP carrying the operator-supplied config, a README, and
|
||||||
|
(when a prebuilt binary is staged on the server) the shopdb-adopt.exe.
|
||||||
|
|
||||||
|
The collector key is supplied by the admin and is NOT stored or logged here.
|
||||||
|
The minted secret is unretrievable after generation, so the operator pastes it
|
||||||
|
into the generator; it only lives inside the downloaded config file.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import zipfile
|
||||||
|
|
||||||
|
from flask import request, current_app, send_file
|
||||||
|
|
||||||
|
from shopdb.api import error_response, ErrorCodes, require_role
|
||||||
|
|
||||||
|
from .routes import endpoints_bp
|
||||||
|
|
||||||
|
# A staged, prebuilt shopdb-adopt.exe is embedded when present. The container
|
||||||
|
# has no .NET SDK, so this mirrors the endpoint agent installer model: build the
|
||||||
|
# exe out of band, drop it under instance/adoptiontool/, and it ships in the ZIP.
|
||||||
|
STAGED_EXE_RELPATH = os.path.join('adoptiontool', 'shopdb-adopt.exe')
|
||||||
|
|
||||||
|
_README = """ShopDB adoption tool (spike)
|
||||||
|
|
||||||
|
WHAT THIS IS
|
||||||
|
A one-shot, agentless enrollment tool. Run it once on a PC and it creates a
|
||||||
|
ShopDB PC and Endpoint entry using only the collector key in the config file
|
||||||
|
next to this exe. It collects identity, hardware, and installed-software (SBOM)
|
||||||
|
details, then exits. It installs nothing, runs no service, and sends no
|
||||||
|
ongoing telemetry. It is NOT the endpoint agent.
|
||||||
|
|
||||||
|
FILES
|
||||||
|
shopdb-adopt.exe The tool (present only if the server had a prebuilt copy).
|
||||||
|
adopt.config.json The base URL and collector key this tool will use.
|
||||||
|
|
||||||
|
USAGE
|
||||||
|
1. Keep shopdb-adopt.exe and adopt.config.json in the same folder.
|
||||||
|
2. Right-click shopdb-adopt.exe and choose Run as administrator. Elevation is
|
||||||
|
required so hardware and installed-software collection succeeds.
|
||||||
|
3. It reports the machine to ShopDB and exits 0 on success.
|
||||||
|
|
||||||
|
You can override config values with arguments:
|
||||||
|
shopdb-adopt.exe --baseurl https://shopdb.example --collectorkey KEY
|
||||||
|
|
||||||
|
SECURITY
|
||||||
|
adopt.config.json contains a live collector key. Treat it as a secret. The
|
||||||
|
key is not recoverable from the ShopDB server after it was generated, so keep
|
||||||
|
this download safe or regenerate the key to revoke it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _build_readme():
|
||||||
|
return _README
|
||||||
|
|
||||||
|
|
||||||
|
@endpoints_bp.route('/adoptiontool/package', methods=['POST'])
|
||||||
|
@require_role('admin')
|
||||||
|
def package_adoption_tool():
|
||||||
|
"""Bundle the adoption tool as a downloadable ZIP.
|
||||||
|
|
||||||
|
Body: {"baseurl": "...", "collectorkey": "..."}. Returns an in-memory ZIP
|
||||||
|
with adopt.config.json, README.txt, and the staged exe when available. The
|
||||||
|
collector key is embedded in the config only; it is never stored or logged.
|
||||||
|
"""
|
||||||
|
body = request.get_json(silent=True) or {}
|
||||||
|
baseurl = (body.get('baseurl') or '').strip()
|
||||||
|
collectorkey = (body.get('collectorkey') or '').strip()
|
||||||
|
|
||||||
|
if not baseurl or not collectorkey:
|
||||||
|
return error_response(
|
||||||
|
ErrorCodes.VALIDATION_ERROR,
|
||||||
|
'baseurl and collectorkey are required',
|
||||||
|
http_code=400)
|
||||||
|
|
||||||
|
config = {
|
||||||
|
'baseurl': baseurl.rstrip('/'),
|
||||||
|
'collectorkey': collectorkey,
|
||||||
|
}
|
||||||
|
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
with zipfile.ZipFile(buffer, 'w', zipfile.ZIP_DEFLATED) as archive:
|
||||||
|
archive.writestr('adopt.config.json',
|
||||||
|
json.dumps(config, indent=2) + '\n')
|
||||||
|
archive.writestr('README.txt', _build_readme())
|
||||||
|
|
||||||
|
staged = os.path.join(current_app.instance_path, STAGED_EXE_RELPATH)
|
||||||
|
if os.path.isfile(staged):
|
||||||
|
archive.write(staged, 'shopdb-adopt.exe')
|
||||||
|
|
||||||
|
buffer.seek(0)
|
||||||
|
return send_file(
|
||||||
|
buffer,
|
||||||
|
mimetype='application/zip',
|
||||||
|
as_attachment=True,
|
||||||
|
download_name='shopdb-adoption-tool.zip')
|
||||||
@@ -138,6 +138,57 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- ====================================================================
|
||||||
|
Adoption tool (spike)
|
||||||
|
================================================================= -->
|
||||||
|
<section class="settings-section">
|
||||||
|
<h3>Adoption tool</h3>
|
||||||
|
<div class="card form-card">
|
||||||
|
<p class="field-hint">
|
||||||
|
Generate a one-shot, agentless enrollment tool. Run it once on a PC and
|
||||||
|
it creates a ShopDB PC and Endpoint entry using only the collector key -
|
||||||
|
no service, no polling, no ongoing telemetry. Use this for machines you
|
||||||
|
want inventoried without running the full endpoint agent.
|
||||||
|
</p>
|
||||||
|
<p class="field-hint">
|
||||||
|
Paste a collector key below (generate one above first; the secret is
|
||||||
|
only shown once). The key is embedded in the downloaded config and is
|
||||||
|
not stored on the server.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<label class="field-label" for="adopt-baseurl">Server base URL</label>
|
||||||
|
<input
|
||||||
|
id="adopt-baseurl"
|
||||||
|
v-model="adoptbaseurl"
|
||||||
|
class="form-control"
|
||||||
|
type="text"
|
||||||
|
placeholder="https://shopdb.example"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<label class="field-label" for="adopt-key">Collector key</label>
|
||||||
|
<input
|
||||||
|
id="adopt-key"
|
||||||
|
v-model="adoptcollectorkey"
|
||||||
|
class="form-control mono"
|
||||||
|
type="text"
|
||||||
|
placeholder="Paste the endpoint agent key"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="button-row">
|
||||||
|
<button
|
||||||
|
class="btn btn-primary"
|
||||||
|
type="button"
|
||||||
|
:disabled="adoptbusy || !adoptbaseurl || !adoptcollectorkey"
|
||||||
|
@click="downloadAdoptionTool"
|
||||||
|
>
|
||||||
|
{{ adoptbusy ? 'Building...' : 'Download adoption tool' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="adoptmessage" class="field-hint">{{ adoptmessage }}</p>
|
||||||
|
<p v-if="adopterror" class="field-error">{{ adopterror }}</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- ====================================================================
|
<!-- ====================================================================
|
||||||
Build site agent (placeholder)
|
Build site agent (placeholder)
|
||||||
================================================================= -->
|
================================================================= -->
|
||||||
@@ -197,6 +248,13 @@ const copied = ref(false)
|
|||||||
const message = ref('')
|
const message = ref('')
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
|
|
||||||
|
// Adoption tool (spike): build a one-shot, agentless enrollment download.
|
||||||
|
const adoptbaseurl = ref('')
|
||||||
|
const adoptcollectorkey = ref('')
|
||||||
|
const adoptbusy = ref(false)
|
||||||
|
const adoptmessage = ref('')
|
||||||
|
const adopterror = ref('')
|
||||||
|
|
||||||
function formatDate(value) {
|
function formatDate(value) {
|
||||||
if (!value) return ''
|
if (!value) return ''
|
||||||
const parsed = new Date(value)
|
const parsed = new Date(value)
|
||||||
@@ -209,6 +267,7 @@ function applyStatus(data) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
adoptbaseurl.value = window.location.origin
|
||||||
try {
|
try {
|
||||||
const response = await api.get('/endpoints/agentkey')
|
const response = await api.get('/endpoints/agentkey')
|
||||||
applyStatus(response.data.data || {})
|
applyStatus(response.data.data || {})
|
||||||
@@ -238,6 +297,9 @@ async function regenerate() {
|
|||||||
const data = response.data.data || {}
|
const data = response.data.data || {}
|
||||||
applyStatus(data)
|
applyStatus(data)
|
||||||
secret.value = data.secret || ''
|
secret.value = data.secret || ''
|
||||||
|
if (data.secret) {
|
||||||
|
adoptcollectorkey.value = data.secret
|
||||||
|
}
|
||||||
message.value = 'Endpoint agent key generated.'
|
message.value = 'Endpoint agent key generated.'
|
||||||
} catch (regenError) {
|
} catch (regenError) {
|
||||||
error.value = regenError.response?.data?.data?.error?.message
|
error.value = regenError.response?.data?.data?.error?.message
|
||||||
@@ -276,6 +338,34 @@ async function copySecret() {
|
|||||||
console.error(copyError)
|
console.error(copyError)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function downloadAdoptionTool() {
|
||||||
|
adoptbusy.value = true
|
||||||
|
adoptmessage.value = ''
|
||||||
|
adopterror.value = ''
|
||||||
|
try {
|
||||||
|
const response = await api.post(
|
||||||
|
'/endpoints/adoptiontool/package',
|
||||||
|
{ baseurl: adoptbaseurl.value, collectorkey: adoptcollectorkey.value },
|
||||||
|
{ responseType: 'blob' })
|
||||||
|
const url = window.URL.createObjectURL(response.data)
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = url
|
||||||
|
link.download = 'shopdb-adoption-tool.zip'
|
||||||
|
document.body.appendChild(link)
|
||||||
|
link.click()
|
||||||
|
link.remove()
|
||||||
|
window.URL.revokeObjectURL(url)
|
||||||
|
adoptmessage.value = 'Adoption tool downloaded.'
|
||||||
|
} catch (downloadError) {
|
||||||
|
adopterror.value = downloadError.response?.status === 403
|
||||||
|
? 'Only an administrator can build the adoption tool'
|
||||||
|
: 'Could not build the adoption tool'
|
||||||
|
console.error(downloadError)
|
||||||
|
} finally {
|
||||||
|
adoptbusy.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
Reference in New Issue
Block a user