Merge the wilm delivery work into main
Ben Baker's installed-software collection, software delivery (run_script and
update_app), agentless adoption tool and command-queue hardening, with his
authorship preserved.
Reviewed before promotion. Fixed: the endpoints plugin tests were never collected
by pytest, the SBOM snapshot table's key would exhaust on MySQL, ingest did not
truncate two columns strict MySQL would reject, and update_app's version branch
pinned a digest for a file that route never serves. Agent packaging moved off
require_role('admin') onto a new endpoints.agentpackage permission.
Contract 0.27.0. Full suite 2231, frontend 314.
This commit is contained in:
7
agent/.gitignore
vendored
7
agent/.gitignore
vendored
@@ -3,5 +3,10 @@ bin/
|
||||
obj/
|
||||
publish/
|
||||
|
||||
# Local publish scratch and command-output captures.
|
||||
# Application-delivery development builds. Held in reserve for extensive
|
||||
# testing; never shipped to a site publish folder or tracked.
|
||||
dev-bin/
|
||||
|
||||
# Local publish/staging scratch and command-output captures.
|
||||
_stage/
|
||||
*.user
|
||||
|
||||
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>
|
||||
@@ -80,10 +80,76 @@ public sealed class AgentConfig
|
||||
return bare;
|
||||
}
|
||||
|
||||
// Per-name allowlist of pre-staged scripts for the generic run_script
|
||||
// action. Exactly like LaunchPaths, the wire never carries a script body or
|
||||
// path - only a script NAME the agent already holds here. A name absent
|
||||
// from this map fails closed. There are no shipped defaults: a script is a
|
||||
// site-specific artifact, so an empty map means run_script does nothing
|
||||
// until a site stages scripts and lists them in appsettings.json. Keyed by
|
||||
// script name (case-insensitive, .ps1 optional).
|
||||
[JsonPropertyName("scriptpaths")]
|
||||
public Dictionary<string, string> ScriptPaths { get; set; } = new();
|
||||
|
||||
// The effective script map: appsettings.json entries only (no defaults).
|
||||
// Keys are normalized to bare names so a lookup tolerates 'reset' or
|
||||
// 'reset.ps1'.
|
||||
public IReadOnlyDictionary<string, string> ResolveScriptPaths()
|
||||
{
|
||||
var resolved = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var pair in ScriptPaths)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(pair.Key) || string.IsNullOrWhiteSpace(pair.Value))
|
||||
continue;
|
||||
resolved[NormalizeScript(pair.Key)] = pair.Value;
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
private static string NormalizeScript(string name)
|
||||
{
|
||||
var bare = (name ?? "").Trim();
|
||||
if (bare.EndsWith(".ps1", StringComparison.OrdinalIgnoreCase))
|
||||
bare = bare[..^4];
|
||||
return bare;
|
||||
}
|
||||
|
||||
public bool IsUsable()
|
||||
=> !string.IsNullOrWhiteSpace(BaseUrl)
|
||||
&& !string.IsNullOrWhiteSpace(ApiKey)
|
||||
&& ApiKey != "REPLACE_WITH_COLLECTOR_KEY";
|
||||
|
||||
// Validates the BaseUrl scheme before any request is made. A plaintext
|
||||
// http:// URL aimed at a real (non-loopback) server is the classic
|
||||
// misconfiguration that hits the TLS port and used to spin the command
|
||||
// long-poll into a storm; refusing to start on it turns a silent, noisy
|
||||
// runtime failure into a single clear startup error. Loopback over http is
|
||||
// still allowed so a developer running the API on plain http locally works.
|
||||
// Returns a human-readable problem, or null when the BaseUrl is acceptable.
|
||||
public string? BaseUrlProblem()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(BaseUrl))
|
||||
return null;
|
||||
|
||||
if (!Uri.TryCreate(BaseUrl.Trim(), UriKind.Absolute, out var uri))
|
||||
return $"BaseUrl '{BaseUrl}' is not a valid absolute URL.";
|
||||
|
||||
if (uri.Scheme == Uri.UriSchemeHttps)
|
||||
return null;
|
||||
|
||||
if (uri.Scheme == Uri.UriSchemeHttp)
|
||||
{
|
||||
if (uri.IsLoopback)
|
||||
return null;
|
||||
return $"BaseUrl '{BaseUrl}' uses plaintext http:// against a " +
|
||||
"non-loopback host. The server requires https://; an http:// " +
|
||||
"URL hits the TLS port and fails every request. Reprovision " +
|
||||
"BaseUrl in HKLM\\SOFTWARE\\GE\\ShopDB (or appsettings.json) " +
|
||||
"with an https:// URL.";
|
||||
}
|
||||
|
||||
return $"BaseUrl '{BaseUrl}' uses unsupported scheme '{uri.Scheme}'. " +
|
||||
"Use https:// (or http:// only for a loopback dev server).";
|
||||
}
|
||||
}
|
||||
|
||||
// Watched targets for this endpoint. Both lists carry entries with a wire name
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Diagnostics;
|
||||
using System.Security.Cryptography;
|
||||
using System.ServiceProcess;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
@@ -58,12 +59,18 @@ public sealed class CommandExecutor
|
||||
// map cannot be started - process_start fails closed.
|
||||
private readonly IReadOnlyDictionary<string, string> launchPaths;
|
||||
|
||||
// Held for update_app: the agent joins InstallPath to BaseUrl and downloads
|
||||
// the pinned installer with its own collector credential (ApiKey), so the
|
||||
// installer body never rides the command wire.
|
||||
private readonly AgentConfig config;
|
||||
|
||||
public CommandExecutor(ILogger<CommandExecutor> logger,
|
||||
AgentConfig config, TimeSpan? startTimeout = null)
|
||||
{
|
||||
this.logger = logger;
|
||||
this.startTimeout = startTimeout ?? TimeSpan.FromSeconds(30);
|
||||
this.launchPaths = (config ?? new AgentConfig()).ResolveLaunchPaths();
|
||||
this.config = config ?? new AgentConfig();
|
||||
this.launchPaths = this.config.ResolveLaunchPaths();
|
||||
}
|
||||
|
||||
public async Task<CommandResult> ExecuteAsync(
|
||||
@@ -103,6 +110,17 @@ public sealed class CommandExecutor
|
||||
return RequireTarget(action, target)
|
||||
?? await ProcessStart(target, token);
|
||||
|
||||
// Software delivery. run_script fetches a ShopDB-hosted script
|
||||
// pinned by SHA-256 and runs it in the script's configured context
|
||||
// (system or the interactive user); update_app fetches a ShopDB
|
||||
// installer pinned by SHA-256 and runs it silently. Both download
|
||||
// the body themselves with the collector credential and fail closed.
|
||||
case "run_script":
|
||||
return RequireTarget(action, target)
|
||||
?? await RunScript(target, claim.Script, token);
|
||||
case "update_app":
|
||||
return await UpdateApp(claim.App, token);
|
||||
|
||||
// Fixed destructive action: reboot this endpoint immediately. Runs
|
||||
// as the SYSTEM agent, so shutdown does not need a user session.
|
||||
case "system_restart":
|
||||
@@ -470,6 +488,433 @@ public sealed class CommandExecutor
|
||||
return found;
|
||||
}
|
||||
|
||||
// ---- Software delivery: run_script and update_app ----
|
||||
|
||||
// Run a ShopDB-hosted script pinned by the server's delivery descriptor. The
|
||||
// wire carries no body: the agent downloads the script from BaseUrl +
|
||||
// InstallPath with its own collector credential, verifies the SHA-256 (when
|
||||
// the server pinned one), then runs it in the descriptor's run context -
|
||||
// 'system' through the agent's own service account, or 'user' in the active
|
||||
// console session so the script sees the interactive user's resources (for
|
||||
// example mapped network drives). Anything missing or mismatched fails closed.
|
||||
private async Task<CommandResult> RunScript(
|
||||
string name, ScriptDeliveryClaim? script, CancellationToken token)
|
||||
{
|
||||
if (script is null || string.IsNullOrWhiteSpace(script.InstallPath))
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Rejected run_script for {Name}: no delivery descriptor on the claim", name);
|
||||
return new CommandResult
|
||||
{
|
||||
Succeeded = false,
|
||||
Message = $"run_script for {name} arrived without a resolved script descriptor",
|
||||
Detail = ScriptDetail(name, script, scriptPath: null, exitCode: null),
|
||||
};
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(config.BaseUrl) || string.IsNullOrWhiteSpace(config.ApiKey))
|
||||
{
|
||||
return new CommandResult
|
||||
{
|
||||
Succeeded = false,
|
||||
Message = "run_script cannot download: agent has no base URL or collector key",
|
||||
Detail = ScriptDetail(name, script, scriptPath: null, exitCode: null),
|
||||
};
|
||||
}
|
||||
|
||||
var scriptUrl = CombineUrl(config.BaseUrl, script.InstallPath!);
|
||||
var tempPath = Path.Combine(GetStagingDirectory(),
|
||||
$"shopdb-script-{script.ScriptId}.ps1");
|
||||
|
||||
try
|
||||
{
|
||||
using var handler = new HttpClientHandler();
|
||||
using var client = new HttpClient(handler);
|
||||
client.Timeout = TimeSpan.FromMinutes(5);
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, scriptUrl);
|
||||
httpRequest.Headers.Add("X-API-Key", config.ApiKey);
|
||||
|
||||
using var response = await client.SendAsync(
|
||||
httpRequest, HttpCompletionOption.ResponseHeadersRead, token);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return new CommandResult
|
||||
{
|
||||
Succeeded = false,
|
||||
Message = $"run_script download failed: HTTP {(int)response.StatusCode}",
|
||||
Detail = ScriptDetail(name, script, scriptPath: null, exitCode: null),
|
||||
};
|
||||
}
|
||||
|
||||
await using (var source = await response.Content.ReadAsStreamAsync(token))
|
||||
await using (var file = File.Create(tempPath))
|
||||
{
|
||||
await source.CopyToAsync(file, token);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(script.Sha256))
|
||||
{
|
||||
var actual = HashFile(tempPath);
|
||||
if (!actual.Equals(script.Sha256, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
logger.LogWarning(
|
||||
"run_script SHA-256 mismatch for {Name}: expected {Expected} got {Actual}",
|
||||
name, script.Sha256, actual);
|
||||
return new CommandResult
|
||||
{
|
||||
Succeeded = false,
|
||||
Message = "run_script integrity check failed: script SHA-256 does not match",
|
||||
Detail = ScriptDetail(name, script, tempPath, exitCode: null),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
var runInUser = string.Equals(
|
||||
script.RunContext, "user", StringComparison.OrdinalIgnoreCase);
|
||||
return runInUser
|
||||
? RunScriptInUserSession(name, script, tempPath)
|
||||
: await RunScriptAsSystem(name, script, tempPath, token);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
logger.LogWarning(error, "run_script for {Name} failed", name);
|
||||
return new CommandResult
|
||||
{
|
||||
Succeeded = false,
|
||||
Message = $"Script {name} failed: {error.Message}",
|
||||
Detail = ScriptDetail(name, script, tempPath, exitCode: null),
|
||||
};
|
||||
}
|
||||
finally
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(tempPath))
|
||||
File.Delete(tempPath);
|
||||
}
|
||||
catch (Exception cleanupError)
|
||||
{
|
||||
logger.LogDebug(cleanupError, "run_script temp cleanup failed: {Path}", tempPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// System context: run through the agent's own service account and wait for
|
||||
// the exit code, the same as before. Fine for machine-scoped scripts that do
|
||||
// not need the interactive user's session resources.
|
||||
private async Task<CommandResult> RunScriptAsSystem(
|
||||
string name, ScriptDeliveryClaim script, string scriptPath, CancellationToken token)
|
||||
{
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = "powershell.exe",
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
WorkingDirectory = Path.GetDirectoryName(scriptPath) ?? "",
|
||||
};
|
||||
startInfo.ArgumentList.Add("-NoProfile");
|
||||
startInfo.ArgumentList.Add("-NonInteractive");
|
||||
startInfo.ArgumentList.Add("-ExecutionPolicy");
|
||||
startInfo.ArgumentList.Add("Bypass");
|
||||
startInfo.ArgumentList.Add("-File");
|
||||
startInfo.ArgumentList.Add(scriptPath);
|
||||
|
||||
using var process = Process.Start(startInfo);
|
||||
if (process is null)
|
||||
{
|
||||
return new CommandResult
|
||||
{
|
||||
Succeeded = false,
|
||||
Message = $"Failed to start script {name}",
|
||||
Detail = ScriptDetail(name, script, scriptPath, exitCode: null),
|
||||
};
|
||||
}
|
||||
|
||||
await process.WaitForExitAsync(token);
|
||||
var exit = process.ExitCode;
|
||||
return new CommandResult
|
||||
{
|
||||
Succeeded = exit == 0,
|
||||
Message = exit == 0
|
||||
? $"Script {name} completed (system context)"
|
||||
: $"Script {name} exited with code {exit}",
|
||||
Detail = ScriptDetail(name, script, scriptPath, exit),
|
||||
};
|
||||
}
|
||||
|
||||
// User context: launch PowerShell in the ACTIVE CONSOLE session so the script
|
||||
// runs at the interactive user's integrity and sees that user's resources
|
||||
// (mapped network drives, HKCU, profile). The launcher waits for the process
|
||||
// to exit and returns its real exit code, so success here means the script
|
||||
// actually ran to a zero exit - not merely that it was launched. Fails closed
|
||||
// when no interactive user is signed in.
|
||||
private CommandResult RunScriptInUserSession(
|
||||
string name, ScriptDeliveryClaim script, string scriptPath)
|
||||
{
|
||||
var quotedPath = "\"" + scriptPath + "\"";
|
||||
var arguments =
|
||||
"-NoProfile -NonInteractive -ExecutionPolicy Bypass -File " + quotedPath;
|
||||
var workingDirectory = Path.GetDirectoryName(scriptPath) ?? "";
|
||||
|
||||
var outcome = InteractiveProcessLauncher.LaunchInActiveSession(
|
||||
"powershell.exe", arguments, workingDirectory, waitForExit: true);
|
||||
if (!outcome.Launched)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"run_script for {Name} could not launch in the user session: {Error}",
|
||||
name, outcome.Error);
|
||||
return new CommandResult
|
||||
{
|
||||
Succeeded = false,
|
||||
Message = $"Script {name} could not run in the user session: {outcome.Error}",
|
||||
Detail = ScriptDetail(name, script, scriptPath, exitCode: null),
|
||||
};
|
||||
}
|
||||
|
||||
var exit = outcome.ExitCode;
|
||||
var ok = exit is null || exit == 0;
|
||||
return new CommandResult
|
||||
{
|
||||
Succeeded = ok,
|
||||
Message = exit is null
|
||||
? $"Script {name} launched in the interactive user session"
|
||||
: ok
|
||||
? $"Script {name} completed (user context)"
|
||||
: $"Script {name} exited with code {exit}",
|
||||
Detail = ScriptDetail(name, script, scriptPath, exit),
|
||||
};
|
||||
}
|
||||
|
||||
private static Dictionary<string, object> ScriptDetail(
|
||||
string name, ScriptDeliveryClaim? script, string? scriptPath, int? exitCode)
|
||||
{
|
||||
var detail = new Dictionary<string, object>
|
||||
{
|
||||
["kind"] = "script",
|
||||
["name"] = name,
|
||||
};
|
||||
if (script is not null)
|
||||
{
|
||||
detail["scriptid"] = script.ScriptId;
|
||||
if (!string.IsNullOrWhiteSpace(script.RunContext))
|
||||
detail["runcontext"] = script.RunContext!;
|
||||
}
|
||||
if (scriptPath is not null)
|
||||
detail["path"] = scriptPath;
|
||||
if (exitCode is not null)
|
||||
detail["exitcode"] = exitCode;
|
||||
return detail;
|
||||
}
|
||||
|
||||
// Fetch a ShopDB-hosted installer pinned by the server's delivery descriptor
|
||||
// and run it silently. The wire carries no bytes: the agent downloads the
|
||||
// package itself from BaseUrl + InstallPath using its own collector
|
||||
// credential, verifies the SHA-256 (when the server pinned one), then runs
|
||||
// an MSI via msiexec /qn or an EXE with the version's SilentArgs. Anything
|
||||
// missing or mismatched fails closed.
|
||||
private async Task<CommandResult> UpdateApp(AppDeliveryClaim? app, CancellationToken token)
|
||||
{
|
||||
if (app is null || string.IsNullOrWhiteSpace(app.InstallPath))
|
||||
{
|
||||
logger.LogWarning("Rejected update_app: no delivery descriptor on the claim");
|
||||
return new CommandResult
|
||||
{
|
||||
Succeeded = false,
|
||||
Message = "update_app arrived without a resolved package descriptor",
|
||||
Detail = AppDetail(app, installerPath: null, exitCode: null),
|
||||
};
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(config.BaseUrl) || string.IsNullOrWhiteSpace(config.ApiKey))
|
||||
{
|
||||
return new CommandResult
|
||||
{
|
||||
Succeeded = false,
|
||||
Message = "update_app cannot download: agent has no base URL or collector key",
|
||||
Detail = AppDetail(app, installerPath: null, exitCode: null),
|
||||
};
|
||||
}
|
||||
|
||||
var packageUrl = CombineUrl(config.BaseUrl, app.InstallPath!);
|
||||
var ext = Path.GetExtension(app.InstallPath) ?? "";
|
||||
var packageKey = app.AppVersionId?.ToString() ?? $"app{app.AppId}";
|
||||
var tempPath = Path.Combine(GetStagingDirectory(),
|
||||
$"shopdb-app-{packageKey}{ext}");
|
||||
|
||||
try
|
||||
{
|
||||
using var handler = new HttpClientHandler();
|
||||
using var client = new HttpClient(handler);
|
||||
client.Timeout = TimeSpan.FromMinutes(10);
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, packageUrl);
|
||||
httpRequest.Headers.Add("X-API-Key", config.ApiKey);
|
||||
|
||||
using var response = await client.SendAsync(
|
||||
httpRequest, HttpCompletionOption.ResponseHeadersRead, token);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return new CommandResult
|
||||
{
|
||||
Succeeded = false,
|
||||
Message = $"update_app download failed: HTTP {(int)response.StatusCode}",
|
||||
Detail = AppDetail(app, installerPath: null, exitCode: null),
|
||||
};
|
||||
}
|
||||
|
||||
await using (var source = await response.Content.ReadAsStreamAsync(token))
|
||||
await using (var file = File.Create(tempPath))
|
||||
{
|
||||
await source.CopyToAsync(file, token);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(app.Sha256))
|
||||
{
|
||||
var actual = HashFile(tempPath);
|
||||
if (!actual.Equals(app.Sha256, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
logger.LogWarning(
|
||||
"update_app SHA-256 mismatch for {App} v{Version}: expected {Expected} got {Actual}",
|
||||
app.AppName, app.Version, app.Sha256, actual);
|
||||
return new CommandResult
|
||||
{
|
||||
Succeeded = false,
|
||||
Message = "update_app integrity check failed: package SHA-256 does not match",
|
||||
Detail = AppDetail(app, tempPath, exitCode: null),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
var (fileName, arguments) = BuildInstallInvocation(tempPath, ext, app.SilentArgs);
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = fileName,
|
||||
Arguments = arguments,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
WorkingDirectory = Path.GetDirectoryName(tempPath) ?? "",
|
||||
};
|
||||
|
||||
using var installer = Process.Start(startInfo);
|
||||
if (installer is null)
|
||||
{
|
||||
return new CommandResult
|
||||
{
|
||||
Succeeded = false,
|
||||
Message = $"Failed to start installer for {app.AppName}",
|
||||
Detail = AppDetail(app, tempPath, exitCode: null),
|
||||
};
|
||||
}
|
||||
|
||||
await installer.WaitForExitAsync(token);
|
||||
var exit = installer.ExitCode;
|
||||
// MSI treats 3010 as success-pending-reboot; keep that as success.
|
||||
var ok = exit == 0 || exit == 3010;
|
||||
return new CommandResult
|
||||
{
|
||||
Succeeded = ok,
|
||||
Message = ok
|
||||
? $"Installed {app.AppName} v{app.Version}"
|
||||
+ (exit == 3010 ? " (reboot required)" : "")
|
||||
: $"Installer for {app.AppName} exited with code {exit}",
|
||||
Detail = AppDetail(app, tempPath, exit),
|
||||
};
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
logger.LogWarning(error, "update_app for {App} failed", app.AppName);
|
||||
return new CommandResult
|
||||
{
|
||||
Succeeded = false,
|
||||
Message = $"update_app for {app.AppName} failed: {error.Message}",
|
||||
Detail = AppDetail(app, tempPath, exitCode: null),
|
||||
};
|
||||
}
|
||||
finally
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(tempPath))
|
||||
File.Delete(tempPath);
|
||||
}
|
||||
catch (Exception cleanupError)
|
||||
{
|
||||
logger.LogDebug(cleanupError, "update_app temp cleanup failed: {Path}", tempPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MSI installs run through msiexec /i <pkg> /qn (any SilentArgs appended);
|
||||
// an EXE runs directly with its version's SilentArgs (homegrown EXEs vary by
|
||||
// developer, so the switches are per-version server data, not guessed here).
|
||||
private static (string fileName, string arguments) BuildInstallInvocation(
|
||||
string packagePath, string ext, string? silentArgs)
|
||||
{
|
||||
var extra = (silentArgs ?? "").Trim();
|
||||
if (ext.Equals(".msi", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var args = $"/i \"{packagePath}\" /qn";
|
||||
if (extra.Length > 0)
|
||||
args += " " + extra;
|
||||
return ("msiexec.exe", args);
|
||||
}
|
||||
return (packagePath, extra);
|
||||
}
|
||||
|
||||
// Fixed, known staging folder we control and that is Defender-excluded in
|
||||
// the environment. LocalSystem's Path.GetTempPath() resolves to
|
||||
// C:\Windows\SystemTemp on Win11/Server2022+, which we do not use; stage
|
||||
// downloads here instead so run_script and update_app land in a predictable
|
||||
// spot. Created on demand.
|
||||
private static string GetStagingDirectory()
|
||||
{
|
||||
var programData = Environment.GetFolderPath(
|
||||
Environment.SpecialFolder.CommonApplicationData);
|
||||
var staging = Path.Combine(programData, "ShopDB", "EndpointAgent");
|
||||
Directory.CreateDirectory(staging);
|
||||
return staging;
|
||||
}
|
||||
|
||||
private static string HashFile(string path)
|
||||
{
|
||||
using var stream = File.OpenRead(path);
|
||||
using var sha = SHA256.Create();
|
||||
var hash = sha.ComputeHash(stream);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
// Join the agent base URL to a ShopDB-relative package path, tolerating a
|
||||
// trailing slash on the base and a leading slash on the path.
|
||||
private static string CombineUrl(string baseUrl, string path)
|
||||
{
|
||||
var left = baseUrl.TrimEnd('/');
|
||||
var right = path.StartsWith('/') ? path : "/" + path;
|
||||
return left + right;
|
||||
}
|
||||
|
||||
private static Dictionary<string, object> AppDetail(
|
||||
AppDeliveryClaim? app, string? installerPath, int? exitCode)
|
||||
{
|
||||
var detail = new Dictionary<string, object> { ["kind"] = "app" };
|
||||
if (app is not null)
|
||||
{
|
||||
if (app.AppVersionId is not null) detail["appversionid"] = app.AppVersionId.Value;
|
||||
detail["appid"] = app.AppId;
|
||||
if (app.AppName is not null) detail["appname"] = app.AppName;
|
||||
if (app.Version is not null) detail["version"] = app.Version;
|
||||
}
|
||||
if (installerPath is not null) detail["installer"] = installerPath;
|
||||
if (exitCode is not null) detail["exitcode"] = exitCode;
|
||||
return detail;
|
||||
}
|
||||
|
||||
// ---- Fixed MTConnect actions ----
|
||||
|
||||
private async Task<CommandResult> MtConnectStartAsync(CancellationToken token)
|
||||
|
||||
@@ -24,6 +24,19 @@ public sealed class CommandWorker : BackgroundService
|
||||
// operator has to fix something, so re-polling fast changes nothing.
|
||||
private static readonly TimeSpan PermanentRetryDelay = TimeSpan.FromSeconds(300);
|
||||
|
||||
// A genuine idle long-poll close only happens after the server has held the
|
||||
// request open for most of its hold window (~25s). A connection that drops
|
||||
// in well under this did not idle out - it failed at connect/TLS (e.g. an
|
||||
// http:// BaseUrl hitting the TLS port), which must NOT be treated as a
|
||||
// normal close and fast re-polled. Below this threshold we take the
|
||||
// transient-failure backoff instead.
|
||||
private static readonly TimeSpan MinIdlePollDuration = TimeSpan.FromSeconds(5);
|
||||
|
||||
// Hard floor between poll starts. Even if some unforeseen path returns
|
||||
// immediately every iteration, this caps the loop at a safe request rate so
|
||||
// a misconfigured agent can never turn into a request storm.
|
||||
private static readonly TimeSpan MinPollInterval = TimeSpan.FromSeconds(2);
|
||||
|
||||
private readonly CommandClient client;
|
||||
private readonly CommandExecutor executor;
|
||||
private readonly ILogger<CommandWorker> logger;
|
||||
@@ -46,6 +59,7 @@ public sealed class CommandWorker : BackgroundService
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
var pollStarted = System.Diagnostics.Stopwatch.StartNew();
|
||||
try
|
||||
{
|
||||
var claim = await client.ClaimNextAsync(hostname, stoppingToken);
|
||||
@@ -64,18 +78,32 @@ public sealed class CommandWorker : BackgroundService
|
||||
"Permanent command-channel error; backing off");
|
||||
await SafeDelay(PermanentRetryDelay, stoppingToken);
|
||||
}
|
||||
catch (Exception error) when (IsIdlePollClose(error))
|
||||
catch (Exception error) when (IsIdlePollClose(error)
|
||||
&& pollStarted.Elapsed >= MinIdlePollDuration)
|
||||
{
|
||||
// The server (or a proxy) closed the held-open long poll without
|
||||
// a command. That is the normal end of a hold window, not a
|
||||
// failure: log quietly at debug and re-poll immediately.
|
||||
// The server (or a proxy) held the poll open for most of the
|
||||
// hold window and then closed it without a command. That is the
|
||||
// normal end of a hold window, not a failure: log quietly at
|
||||
// debug and re-poll immediately.
|
||||
logger.LogDebug(error, "Command poll connection closed; re-polling");
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
// Anything else - including a connection/TLS failure that drops
|
||||
// in under MinIdlePollDuration (e.g. an http:// BaseUrl hitting
|
||||
// the TLS port) - is a real transient failure. Back off so a
|
||||
// misconfiguration cannot spin into a request storm.
|
||||
logger.LogWarning(error, "Command poll failed; retrying shortly");
|
||||
await SafeDelay(TransientRetryDelay, stoppingToken);
|
||||
}
|
||||
|
||||
// Floor the loop rate. On the immediate re-poll paths (204 or a
|
||||
// genuine idle close) the elapsed time already exceeds this, so it
|
||||
// adds no latency; it only bites if an iteration returned unusually
|
||||
// fast, capping the worst case at a safe request rate.
|
||||
var remaining = MinPollInterval - pollStarted.Elapsed;
|
||||
if (remaining > TimeSpan.Zero)
|
||||
await SafeDelay(remaining, stoppingToken);
|
||||
}
|
||||
|
||||
logger.LogInformation("Command channel stopped");
|
||||
@@ -114,8 +142,10 @@ public sealed class CommandWorker : BackgroundService
|
||||
|
||||
// A long poll that the server or an intermediary closes without sending a
|
||||
// command surfaces as an HttpRequestException wrapping an HttpIOException
|
||||
// (ResponseEnded), or a bare IOException. That is the expected end of a hold
|
||||
// window, so it should not be treated as a transient failure.
|
||||
// (ResponseEnded), or a bare IOException. Whether that is a benign hold-
|
||||
// window close or a fast connect/TLS failure is decided by the caller using
|
||||
// how long the poll stayed open (see MinIdlePollDuration); this only matches
|
||||
// the exception shape.
|
||||
private static bool IsIdlePollClose(Exception error)
|
||||
{
|
||||
for (Exception? current = error; current is not null; current = current.InnerException)
|
||||
|
||||
@@ -29,6 +29,20 @@ public sealed class EndpointReport
|
||||
[JsonPropertyName("interfaces")] public object[]? Interfaces { get; set; }
|
||||
[JsonPropertyName("scheduledtasks")] public object[]? ScheduledTasks { get; set; }
|
||||
[JsonPropertyName("events")] public EndpointEventRecord[]? Events { get; set; }
|
||||
// Additive inventory telemetry. The server does not persist these yet; its
|
||||
// ingest logs them as unknown keys (plugins/endpoints/plugin.py) so the
|
||||
// incoming values are observable before any DB column or UI card exists.
|
||||
// Keys stay lowercase/no-underscore to match AGENT-CONTRACT.md.
|
||||
[JsonPropertyName("disks")] public object[]? Disks { get; set; }
|
||||
[JsonPropertyName("bios")] public object? Bios { get; set; }
|
||||
[JsonPropertyName("memory")] public object? Memory { get; set; }
|
||||
[JsonPropertyName("processor")] public object? Processor { get; set; }
|
||||
[JsonPropertyName("osinfo")] public object? OsInfo { get; set; }
|
||||
[JsonPropertyName("domain")] public string? Domain { get; set; }
|
||||
// Installed-software SBOM, mirroring the Assess-Orchestrator applications
|
||||
// collection: both Uninstall registry hives, {displayname, displayversion,
|
||||
// publisher}. Server logs it as an unpersisted key until the SBOM store lands.
|
||||
[JsonPropertyName("applications")] public object[]? Applications { get; set; }
|
||||
}
|
||||
|
||||
public sealed class EndpointEventRecord
|
||||
@@ -71,6 +85,49 @@ public sealed class CommandClaim
|
||||
[JsonPropertyName("action")] public string Action { get; set; } = "";
|
||||
[JsonPropertyName("target")] public string? Target { get; set; }
|
||||
[JsonPropertyName("targetkind")] public string? TargetKind { get; set; }
|
||||
|
||||
// For update_app the server resolves the pinned application version into a
|
||||
// delivery descriptor (package URL, SHA-256, silent-install switches). The
|
||||
// agent fetches the bytes itself with its collector credential; the wire
|
||||
// never carries the installer body. Null for every other action.
|
||||
[JsonPropertyName("app")] public AppDeliveryClaim? App { get; set; }
|
||||
|
||||
// For run_script the server resolves the ShopDB-hosted script into a
|
||||
// delivery descriptor (body URL, SHA-256, run context). The agent fetches
|
||||
// the body itself with its collector credential; the wire never carries the
|
||||
// script body. Null for every other action.
|
||||
[JsonPropertyName("script")] public ScriptDeliveryClaim? Script { get; set; }
|
||||
}
|
||||
|
||||
// The resolved installer-push descriptor accompanying an update_app claim.
|
||||
// InstallPath is a ShopDB-relative package URL the agent joins to its BaseUrl
|
||||
// and downloads with its collector credential; Sha256 pins the exact bytes;
|
||||
// SilentArgs are this version's unattended-install switches (may be empty for
|
||||
// an MSI, which the agent installs via msiexec /qn). AppVersionId is null when
|
||||
// the push resolves an app-level install source rather than a pinned version.
|
||||
public sealed class AppDeliveryClaim
|
||||
{
|
||||
[JsonPropertyName("appversionid")] public int? AppVersionId { get; set; }
|
||||
[JsonPropertyName("appid")] public int AppId { get; set; }
|
||||
[JsonPropertyName("appname")] public string? AppName { get; set; }
|
||||
[JsonPropertyName("version")] public string? Version { get; set; }
|
||||
[JsonPropertyName("installpath")] public string? InstallPath { get; set; }
|
||||
[JsonPropertyName("sha256")] public string? Sha256 { get; set; }
|
||||
[JsonPropertyName("silentargs")] public string? SilentArgs { get; set; }
|
||||
}
|
||||
|
||||
// The resolved script-push descriptor accompanying a run_script claim.
|
||||
// InstallPath is a ShopDB-relative body URL the agent joins to its BaseUrl and
|
||||
// downloads with its collector credential; Sha256 pins the exact bytes;
|
||||
// RunContext is 'system' (the agent's own service account) or 'user' (the
|
||||
// interactive logged-on session), decided per script in the ShopDB library.
|
||||
public sealed class ScriptDeliveryClaim
|
||||
{
|
||||
[JsonPropertyName("scriptid")] public int ScriptId { get; set; }
|
||||
[JsonPropertyName("scriptname")] public string? ScriptName { get; set; }
|
||||
[JsonPropertyName("installpath")] public string? InstallPath { get; set; }
|
||||
[JsonPropertyName("sha256")] public string? Sha256 { get; set; }
|
||||
[JsonPropertyName("runcontext")] public string? RunContext { get; set; }
|
||||
}
|
||||
|
||||
// Envelope for the claim long-poll: { status, data: { endpointcommandid, action } }.
|
||||
|
||||
@@ -50,6 +50,13 @@ public sealed class EndpointCollector : ICollector
|
||||
Services = GetMonitoredTargets(),
|
||||
Interfaces = GetInterfaces(),
|
||||
Events = GetCuratedEvents(),
|
||||
Disks = GetDisks(),
|
||||
Bios = GetBios(),
|
||||
Memory = GetMemory(),
|
||||
Processor = GetProcessor(),
|
||||
OsInfo = GetOsInfo(),
|
||||
Domain = GetDomain(),
|
||||
Applications = GetApplications(),
|
||||
};
|
||||
|
||||
if (report.LastBootTime is DateTimeOffset boot)
|
||||
@@ -460,6 +467,264 @@ public sealed class EndpointCollector : ICollector
|
||||
return records.ToArray();
|
||||
}
|
||||
|
||||
// Installed-software SBOM. Mirrors the Assess-Orchestrator applications
|
||||
// collection exactly: enumerate both Uninstall registry hives (64-bit and
|
||||
// WOW6432Node), keep only entries with a DisplayName, and emit
|
||||
// {displayname, displayversion, publisher}. This is the same registry
|
||||
// surface the machine-number/eDNC probes already read. Wire keys stay
|
||||
// lowercase/no-underscore; the server logs this as an unpersisted key until
|
||||
// the SBOM store is built. Never throws out to the caller.
|
||||
private 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 void CollectUninstallHive(string subKey, List<object> result,
|
||||
HashSet<string> seen)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var machine = RegistryKey.OpenBaseKey(
|
||||
RegistryHive.LocalMachine, 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() ?? "";
|
||||
|
||||
// De-dupe on name+version so the two hives do not double-list
|
||||
// the same package.
|
||||
if (!seen.Add(displayName + "\u0000" + displayVersion))
|
||||
continue;
|
||||
|
||||
result.Add(new
|
||||
{
|
||||
displayname = displayName,
|
||||
displayversion = displayVersion,
|
||||
publisher,
|
||||
});
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
logger.LogDebug(error, "Uninstall entry read blocked: {Entry}", name);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
logger.LogDebug(error, "Uninstall hive read blocked: {SubKey}", subKey);
|
||||
}
|
||||
}
|
||||
|
||||
// Fixed local disks with capacity/free so the server can flag a PC that is
|
||||
// running out of space. Win32_LogicalDisk DriveType 3 = local fixed disk;
|
||||
// network/removable drives are skipped. Bytes stay as longs; the server can
|
||||
// derive percentages. Defensive: a locked WMI hive yields an empty list.
|
||||
private object[] GetDisks()
|
||||
{
|
||||
var result = new List<object>();
|
||||
try
|
||||
{
|
||||
using var searcher = new ManagementObjectSearcher(
|
||||
"SELECT DeviceID, VolumeName, FileSystem, Size, FreeSpace " +
|
||||
"FROM Win32_LogicalDisk WHERE DriveType = 3");
|
||||
foreach (var mo in searcher.Get())
|
||||
{
|
||||
var size = ToLong(mo["Size"]);
|
||||
var free = ToLong(mo["FreeSpace"]);
|
||||
result.Add(new
|
||||
{
|
||||
drive = mo["DeviceID"]?.ToString()?.Trim() ?? "",
|
||||
label = mo["VolumeName"]?.ToString()?.Trim() ?? "",
|
||||
filesystem = mo["FileSystem"]?.ToString()?.Trim() ?? "",
|
||||
sizebytes = size,
|
||||
freebytes = free,
|
||||
usedbytes = size is long total && free is long available
|
||||
? total - available : (long?)null,
|
||||
});
|
||||
}
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
logger.LogDebug(error, "Disk enumeration blocked");
|
||||
}
|
||||
return result.ToArray();
|
||||
}
|
||||
|
||||
// Firmware identity from Win32_BIOS: vendor, version string, and release
|
||||
// date. Lets the server track BIOS/UEFI levels for a fleet update campaign.
|
||||
private object? GetBios()
|
||||
{
|
||||
try
|
||||
{
|
||||
using var searcher = new ManagementObjectSearcher(
|
||||
"SELECT Manufacturer, SMBIOSBIOSVersion, ReleaseDate FROM Win32_BIOS");
|
||||
foreach (var mo in searcher.Get())
|
||||
{
|
||||
return new
|
||||
{
|
||||
vendor = mo["Manufacturer"]?.ToString()?.Trim() ?? "",
|
||||
version = mo["SMBIOSBIOSVersion"]?.ToString()?.Trim() ?? "",
|
||||
releasedate = ToBiosDate(mo["ReleaseDate"]?.ToString()),
|
||||
};
|
||||
}
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
logger.LogDebug(error, "BIOS query failed");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Physical memory totals. TotalVisibleMemorySize/FreePhysicalMemory come
|
||||
// from Win32_OperatingSystem in kilobytes; normalize to bytes so the wire
|
||||
// stays in one unit with the disk figures.
|
||||
private object? GetMemory()
|
||||
{
|
||||
try
|
||||
{
|
||||
using var searcher = new ManagementObjectSearcher(
|
||||
"SELECT TotalVisibleMemorySize, FreePhysicalMemory " +
|
||||
"FROM Win32_OperatingSystem");
|
||||
foreach (var mo in searcher.Get())
|
||||
{
|
||||
var totalKb = ToLong(mo["TotalVisibleMemorySize"]);
|
||||
var freeKb = ToLong(mo["FreePhysicalMemory"]);
|
||||
return new
|
||||
{
|
||||
totalbytes = totalKb is long total ? total * 1024 : (long?)null,
|
||||
freebytes = freeKb is long free ? free * 1024 : (long?)null,
|
||||
};
|
||||
}
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
logger.LogDebug(error, "Memory query failed");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// CPU identity and core counts from Win32_Processor (first socket).
|
||||
private object? GetProcessor()
|
||||
{
|
||||
try
|
||||
{
|
||||
using var searcher = new ManagementObjectSearcher(
|
||||
"SELECT Name, NumberOfCores, NumberOfLogicalProcessors, " +
|
||||
"MaxClockSpeed FROM Win32_Processor");
|
||||
foreach (var mo in searcher.Get())
|
||||
{
|
||||
return new
|
||||
{
|
||||
name = mo["Name"]?.ToString()?.Trim() ?? "",
|
||||
cores = ToLong(mo["NumberOfCores"]),
|
||||
logicalprocessors = ToLong(mo["NumberOfLogicalProcessors"]),
|
||||
maxclockmhz = ToLong(mo["MaxClockSpeed"]),
|
||||
};
|
||||
}
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
logger.LogDebug(error, "Processor query failed");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// OS build/version detail beyond the caption already sent as osname. Lets
|
||||
// the server track patch level (build number) across the fleet.
|
||||
private object? GetOsInfo()
|
||||
{
|
||||
try
|
||||
{
|
||||
using var searcher = new ManagementObjectSearcher(
|
||||
"SELECT Version, BuildNumber, OSArchitecture, InstallDate " +
|
||||
"FROM Win32_OperatingSystem");
|
||||
foreach (var mo in searcher.Get())
|
||||
{
|
||||
return new
|
||||
{
|
||||
version = mo["Version"]?.ToString()?.Trim() ?? "",
|
||||
build = mo["BuildNumber"]?.ToString()?.Trim() ?? "",
|
||||
architecture = mo["OSArchitecture"]?.ToString()?.Trim() ?? "",
|
||||
installdate = ToBiosDate(mo["InstallDate"]?.ToString()),
|
||||
};
|
||||
}
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
logger.LogDebug(error, "OS info query failed");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// AD domain or workgroup this PC is joined to. PartOfDomain distinguishes a
|
||||
// domain-joined box from a workgroup one.
|
||||
private string? GetDomain()
|
||||
{
|
||||
try
|
||||
{
|
||||
using var searcher = new ManagementObjectSearcher(
|
||||
"SELECT Domain, PartOfDomain FROM Win32_ComputerSystem");
|
||||
foreach (var mo in searcher.Get())
|
||||
{
|
||||
var domain = mo["Domain"]?.ToString()?.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(domain))
|
||||
return domain;
|
||||
}
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
logger.LogDebug(error, "Domain query failed");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static long? ToLong(object? value)
|
||||
{
|
||||
if (value is null)
|
||||
return null;
|
||||
try { return Convert.ToInt64(value); }
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
// Win32 CIM datetimes (yyyymmddHHMMSS.ffffff+zzz) render as an ISO-8601 UTC
|
||||
// string; anything unparseable passes through untouched so nothing is lost.
|
||||
private string? ToBiosDate(string? raw)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw))
|
||||
return null;
|
||||
try
|
||||
{
|
||||
return ManagementDateTimeConverter.ToDateTime(raw)
|
||||
.ToUniversalTime().ToString("o");
|
||||
}
|
||||
catch
|
||||
{
|
||||
return raw.Trim();
|
||||
}
|
||||
}
|
||||
|
||||
private string? QueryFirst(string wql, string property)
|
||||
{
|
||||
try
|
||||
|
||||
@@ -18,7 +18,7 @@ namespace ShopDbEndpointAgent.Core;
|
||||
// Only process_start uses this. Stop/status/service actions stay as SYSTEM.
|
||||
public static class InteractiveProcessLauncher
|
||||
{
|
||||
public readonly record struct LaunchOutcome(bool Launched, string? Error);
|
||||
public readonly record struct LaunchOutcome(bool Launched, string? Error, int? ExitCode = null);
|
||||
|
||||
private const int TokenDuplicate = 0x0002;
|
||||
private const int TokenQuery = 0x0008;
|
||||
@@ -32,11 +32,28 @@ public static class InteractiveProcessLauncher
|
||||
|
||||
private const uint InvalidSessionId = 0xFFFFFFFF;
|
||||
|
||||
private const uint Infinite = 0xFFFFFFFF;
|
||||
|
||||
// Returns Launched=true when CreateProcessAsUser succeeds. Never throws;
|
||||
// failures come back as Launched=false with a descriptive Error so the
|
||||
// caller can surface a clean CommandResult.
|
||||
public static LaunchOutcome LaunchInActiveSession(
|
||||
string applicationPath, string? workingDirectory)
|
||||
=> LaunchInActiveSession(applicationPath, null, workingDirectory);
|
||||
|
||||
// Overload that also passes a command line (arguments). When commandLine is
|
||||
// supplied it is handed to CreateProcessAsUser as lpCommandLine with a null
|
||||
// lpApplicationName so the arguments are honored; the executable is resolved
|
||||
// from the command line's first token as usual.
|
||||
//
|
||||
// When waitForExit is true the launcher waits for the process to exit and
|
||||
// returns its real exit code in LaunchOutcome.ExitCode, so a caller that
|
||||
// needs the actual outcome (run_script in the user session) can report
|
||||
// completed/failed instead of fire-and-forget "launched". GUI callers
|
||||
// (process_start) leave it false and return as soon as the window is up.
|
||||
public static LaunchOutcome LaunchInActiveSession(
|
||||
string applicationPath, string? arguments, string? workingDirectory,
|
||||
bool waitForExit = false)
|
||||
{
|
||||
var sessionId = WTSGetActiveConsoleSessionId();
|
||||
if (sessionId == InvalidSessionId)
|
||||
@@ -75,10 +92,26 @@ public static class InteractiveProcessLauncher
|
||||
|
||||
var creationFlags = CreateUnicodeEnvironment | CreateNoWindow;
|
||||
|
||||
// With arguments, pass a single command line ("<exe> <args>") as
|
||||
// lpCommandLine and a null lpApplicationName so the arguments apply;
|
||||
// without arguments, keep the original applicationName form.
|
||||
string? applicationName;
|
||||
string? commandLine;
|
||||
if (string.IsNullOrWhiteSpace(arguments))
|
||||
{
|
||||
applicationName = applicationPath;
|
||||
commandLine = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
applicationName = null;
|
||||
commandLine = "\"" + applicationPath + "\" " + arguments;
|
||||
}
|
||||
|
||||
var launched = CreateProcessAsUser(
|
||||
duplicatedToken,
|
||||
applicationPath,
|
||||
null,
|
||||
applicationName,
|
||||
commandLine,
|
||||
IntPtr.Zero,
|
||||
IntPtr.Zero,
|
||||
false,
|
||||
@@ -95,12 +128,20 @@ public static class InteractiveProcessLauncher
|
||||
+ new Win32Exception(Marshal.GetLastWin32Error()).Message);
|
||||
}
|
||||
|
||||
int? exitCode = null;
|
||||
if (waitForExit && processInformation.hProcess != IntPtr.Zero)
|
||||
{
|
||||
WaitForSingleObject(processInformation.hProcess, Infinite);
|
||||
if (GetExitCodeProcess(processInformation.hProcess, out var code))
|
||||
exitCode = unchecked((int)code);
|
||||
}
|
||||
|
||||
if (processInformation.hProcess != IntPtr.Zero)
|
||||
CloseHandle(processInformation.hProcess);
|
||||
if (processInformation.hThread != IntPtr.Zero)
|
||||
CloseHandle(processInformation.hThread);
|
||||
|
||||
return new LaunchOutcome(true, null);
|
||||
return new LaunchOutcome(true, null, exitCode);
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -197,4 +238,10 @@ public static class InteractiveProcessLauncher
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
private static extern bool CloseHandle(IntPtr handle);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
private static extern uint WaitForSingleObject(IntPtr handle, uint milliseconds);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
private static extern bool GetExitCodeProcess(IntPtr process, out uint exitCode);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -46,6 +46,13 @@ if (!config.IsUsable())
|
||||
return 1;
|
||||
}
|
||||
|
||||
var baseUrlProblem = config.BaseUrlProblem();
|
||||
if (baseUrlProblem is not null)
|
||||
{
|
||||
bootstrapLogger.LogError("Config rejected: {Problem}", baseUrlProblem);
|
||||
return 1;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (runOnce)
|
||||
|
||||
@@ -7,11 +7,19 @@
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>ShopDbEndpointAgent</RootNamespace>
|
||||
<AssemblyName>shopdbagent</AssemblyName>
|
||||
<!-- 1.2.0: process_start launches via a per-image path allowlist
|
||||
<!-- 1.4.1: run_script/update_app stage into a fixed, Defender-excludable
|
||||
folder (C:\ProgramData\ShopDB\EndpointAgent) instead of the LocalSystem
|
||||
temp path, and run_script user-context waits for exit and reports the
|
||||
real exit code instead of fire-and-forget "launched".
|
||||
1.4.0: run_script fetches a ShopDB-hosted script by digest and runs it
|
||||
in the script's configured context (system or the interactive user),
|
||||
replacing the local scriptpaths allowlist; update_app resolves an app-
|
||||
level or version-level install source.
|
||||
1.2.0: process_start launches via a per-image path allowlist
|
||||
(AgentConfig.LaunchPaths, shipped defaults overridable per site).
|
||||
1.1.0: generic, data-driven service_*/process_* actions carry their
|
||||
target over the wire, so new policies/apps need no agent rebuild. -->
|
||||
<Version>1.3.0</Version>
|
||||
<Version>1.4.1</Version>
|
||||
<InvariantGlobalization>true</InvariantGlobalization>
|
||||
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||
<SelfContained>true</SelfContained>
|
||||
|
||||
Reference in New Issue
Block a user