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:
2026-08-30 19:41:15 -04:00
parent 8f4fe74dcb
commit 6680c3c5de
59 changed files with 5557 additions and 68 deletions

47
.gitignore vendored
View File

@@ -73,6 +73,10 @@ id_rsa.*
*.ppk
*.p12
*.pfx
# Local/test TLS material for the dev HTTPS listener. Self-signed, per-host,
# never shipped. The container mounts these at runtime; a site provides its own.
certs/
secrets.json
secrets.yaml
secrets.yml
@@ -92,3 +96,46 @@ devnotes/
# defeats the intentional skip in the docs publish/version tests. Keep this
# local-only guide out of version control so those tests skip as designed.
docs/CSV-IMPORT-GUIDE.md
# Local-only authored docs (executive summaries, workflow/security writeups,
# planner exports, draft decks and diagrams). Same reasoning as above: keep
# personal docs out of version control. docs/openapi.json stays tracked and
# is intentionally NOT ignored here.
docs/EXECUTIVE-SUMMARY.md
docs/DESIGN-DECISIONS-agent.md
docs/OPERATOR-WORKFLOW.md
docs/SECURITY-RISK-ASSESSMENT.md
docs/ShopDB-Planner-Tasks.csv
docs/DRAFT--ShopDB-CSV-Import-Guide.pptx
docs/diagrams/
docs/media/
# Local-only working artifacts: DB dumps, sample/test datasets, scratch
# scripts, captured command output, generated maps/reports, vulnerability
# scan output, and staged agent publish trees. None of these are product
# source; they are developer scratch that should never enter version control.
# exports/ (merge kits, decks, catalog snapshots) stays on disk for reference
# but is not tracked.
backups/
exports/
testdata/
vuln/
agent/publish-wjf/
*.sql
shopmap.pdf
shopmap.png
agent_build.txt
agent_publish.txt
backend_out.txt
backend_out2.txt
docker_build.txt
docker_up.txt
gates_out.txt
pytest_targeted.txt
alandry.py
kendry.py
# Scratch tooling under scripts/ (catalog diff/merge helpers, docx generator).
# Not product source; developer-local utilities.
scripts/.temp/
scripts/docs/

7
agent/.gitignore vendored
View File

@@ -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

View 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; }
}

View 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>

View 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>

View File

@@ -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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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 } }.

View File

@@ -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

View File

@@ -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);
}

View File

@@ -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>

View File

@@ -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)

View File

@@ -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>

View File

@@ -65,8 +65,43 @@ services:
COLLECTOR_API_KEY_ENDPOINTS: ${COLLECTOR_API_KEY_ENDPOINTS:-}
ports:
- "${API_PORT:-5001}:5001"
# Terminate TLS in gunicorn with the mounted self-signed cert so the browser
# sees a secure context. navigator.clipboard and other secure-context-only
# web APIs refuse to run over plain http on a non-localhost origin, so the
# test box (accessed by FQDN) needs real https. A site swaps in its own cert
# under ./certs or fronts this with its IIS/nginx proxy.
command:
- gunicorn
- --bind
- 0.0.0.0:5001
- --workers
- "4"
- --timeout
- "60"
- --access-logfile
- "-"
- --error-logfile
- "-"
- --certfile
- /app/certs/server.crt
- --keyfile
- /app/certs/server.key
- wsgi:app
healthcheck:
# Match the TLS listener: probe over https and accept the self-signed cert
# (-k). The Dockerfile default probes http and would stay red once gunicorn
# only speaks TLS.
test: ["CMD", "curl", "--silent", "--insecure", "https://localhost:5001/api/auth/login",
"-X", "POST", "-H", "Content-Type: application/json", "-d", "{}"]
interval: 30s
timeout: 5s
start_period: 10s
retries: 3
volumes:
- ./plugins:/app/plugins:ro
# Self-signed TLS material for the dev/test https listener. Read-only;
# generated on the host under ./certs and never baked into the image.
- ./certs:/app/certs:ro
# /app/instance is WRITTEN state, not code: plugins.json (which plugins
# this site has enabled), uploaded floor plans, branding, model and
# application images, employee photos, warranty proofs, slides,

View File

@@ -185,6 +185,15 @@
},
{
"name": "plugin-endpoints"
},
{
"name": "core-scripts"
},
{
"name": "endpoints-delivery"
},
{
"name": "endpoints-siteagent"
}
],
"paths": {
@@ -20109,7 +20118,7 @@
]
}
},
"/api/docs/": {
"/api/docs": {
"get": {
"tags": [
"core-docs"
@@ -22680,6 +22689,768 @@
}
}
}
},
"/api/scripts": {
"get": {
"tags": [
"core-scripts"
],
"summary": "List script catalog entries, active only by default",
"description": "List script catalog entries, active only by default\n\n**Auth:** jwt + require_permission applications.view\n\n**Params:** query: includeinactive (default false)\n\n**Example:**\n```\ncurl http://localhost:5001/api/scripts -H \"Authorization: Bearer $TOK\"\n```",
"security": [
{
"bearerAuth": []
}
],
"responses": {
"200": {
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
"content": {
"application/json": {
"$ref": "#/components/schemas/SuccessEnvelope"
}
}
},
"default": {
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
"content": {
"application/json": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"401": {
"description": "Missing or invalid credentials."
},
"403": {
"description": "Authenticated, but not permitted."
}
}
},
"post": {
"tags": [
"core-scripts"
],
"summary": "Create a script catalog entry; the body is uploaded separately so metadata and content version independently",
"description": "Create a script catalog entry; the body is uploaded separately so metadata and content version independently\n\n**Auth:** jwt + require_permission applications.create\n\n**Params:** body: name (required), description, runcontext (system|user, default system)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/scripts -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{\"name\":\"Restart spooler\",\"runcontext\":\"system\"}'\n```",
"security": [
{
"bearerAuth": []
}
],
"responses": {
"200": {
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
"content": {
"application/json": {
"$ref": "#/components/schemas/SuccessEnvelope"
}
}
},
"default": {
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
"content": {
"application/json": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"401": {
"description": "Missing or invalid credentials."
},
"403": {
"description": "Authenticated, but not permitted."
}
},
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"additionalProperties": true,
"description": "body: name (required), description, runcontext (system|user, default system)"
}
}
}
}
}
},
"/api/scripts/{script_id}": {
"get": {
"tags": [
"core-scripts"
],
"summary": "Get one script entry with its metadata and run context",
"description": "Get one script entry with its metadata and run context\n\n**Auth:** jwt + require_permission applications.view\n\n**Params:** path: script_id (int)\n\n**Example:**\n```\ncurl http://localhost:5001/api/scripts/3 -H \"Authorization: Bearer $TOK\"\n```",
"security": [
{
"bearerAuth": []
}
],
"responses": {
"200": {
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
"content": {
"application/json": {
"$ref": "#/components/schemas/SuccessEnvelope"
}
}
},
"default": {
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
"content": {
"application/json": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"401": {
"description": "Missing or invalid credentials."
},
"403": {
"description": "Authenticated, but not permitted."
},
"404": {
"description": "No such record."
}
},
"parameters": [
{
"name": "script_id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
]
},
"put": {
"tags": [
"core-scripts"
],
"summary": "Update a script entry's metadata; the stored body is untouched",
"description": "Update a script entry's metadata; the stored body is untouched\n\n**Auth:** jwt + require_permission applications.edit\n\n**Params:** path: script_id; body: name, description, runcontext, isactive\n\n**Example:**\n```\ncurl -X PUT http://localhost:5001/api/scripts/3 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{\"description\":\"Clears the print queue\"}'\n```",
"security": [
{
"bearerAuth": []
}
],
"responses": {
"200": {
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
"content": {
"application/json": {
"$ref": "#/components/schemas/SuccessEnvelope"
}
}
},
"default": {
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
"content": {
"application/json": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"401": {
"description": "Missing or invalid credentials."
},
"403": {
"description": "Authenticated, but not permitted."
},
"404": {
"description": "No such record."
}
},
"parameters": [
{
"name": "script_id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"additionalProperties": true,
"description": "path: script_id; body: name, description, runcontext, isactive"
}
}
}
}
},
"delete": {
"tags": [
"core-scripts"
],
"summary": "Remove a script catalog entry",
"description": "Remove a script catalog entry\n\n**Auth:** jwt + require_permission applications.delete\n\n**Params:** path: script_id\n\n**Example:**\n```\ncurl -X DELETE http://localhost:5001/api/scripts/3 -H \"Authorization: Bearer $TOK\"\n```",
"security": [
{
"bearerAuth": []
}
],
"responses": {
"200": {
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
"content": {
"application/json": {
"$ref": "#/components/schemas/SuccessEnvelope"
}
}
},
"default": {
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
"content": {
"application/json": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"401": {
"description": "Missing or invalid credentials."
},
"403": {
"description": "Authenticated, but not permitted."
},
"404": {
"description": "No such record."
}
},
"parameters": [
{
"name": "script_id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
]
}
},
"/api/scripts/{script_id}/body": {
"post": {
"tags": [
"core-scripts"
],
"summary": "Upload or replace the script body; stored on disk and hashed so a delivery can pin the exact bytes",
"description": "Upload or replace the script body; stored on disk and hashed so a delivery can pin the exact bytes\n\n**Auth:** jwt + require_permission applications.edit\n\n**Params:** path: script_id; multipart/form-data: file (the .ps1)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/scripts/3/body -H \"Authorization: Bearer $TOK\" -F 'file=@restart-spooler.ps1'\n```",
"security": [
{
"bearerAuth": []
}
],
"responses": {
"200": {
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
"content": {
"application/json": {
"$ref": "#/components/schemas/SuccessEnvelope"
}
}
},
"default": {
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
"content": {
"application/json": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"401": {
"description": "Missing or invalid credentials."
},
"403": {
"description": "Authenticated, but not permitted."
},
"404": {
"description": "No such record."
}
},
"parameters": [
{
"name": "script_id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"additionalProperties": true,
"description": "path: script_id; multipart/form-data: file (the .ps1)"
}
}
}
}
}
},
"/api/scripts/body/{filename}": {
"get": {
"tags": [
"core-scripts"
],
"summary": "Download a stored script body for review in the UI",
"description": "Download a stored script body for review in the UI\n\n**Auth:** jwt + require_permission applications.view\n\n**Params:** path: filename (as stored)\n\n**Example:**\n```\ncurl http://localhost:5001/api/scripts/body/restart-spooler.ps1 -H \"Authorization: Bearer $TOK\" -o restart-spooler.ps1\n```",
"security": [
{
"bearerAuth": []
}
],
"responses": {
"200": {
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
"content": {
"application/json": {
"$ref": "#/components/schemas/SuccessEnvelope"
}
}
},
"default": {
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
"content": {
"application/json": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"401": {
"description": "Missing or invalid credentials."
},
"403": {
"description": "Authenticated, but not permitted."
},
"404": {
"description": "No such record."
}
},
"parameters": [
{
"name": "filename",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
]
}
},
"/api/endpoints/apppackage/{appversionid}": {
"get": {
"tags": [
"endpoints-delivery"
],
"summary": "Agent-facing installer download for a pinned app version; serves the exact bytes the update_app descriptor pinned by...",
"description": "Agent-facing installer download for a pinned app version; serves the exact bytes the update_app descriptor pinned by SHA-256\n\n**Auth:** collector token (X-API-Key), endpoints scope\n\n**Params:** path: appversionid (int)\n\n**Example:**\n```\ncurl http://localhost:5001/api/endpoints/apppackage/12 -H 'X-API-Key: $COLLECTOR_KEY' -o setup.exe\n```",
"security": [
{
"apiKeyAuth": []
}
],
"responses": {
"200": {
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
"content": {
"application/json": {
"$ref": "#/components/schemas/SuccessEnvelope"
}
}
},
"default": {
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
"content": {
"application/json": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"401": {
"description": "Missing or invalid credentials."
},
"403": {
"description": "Authenticated, but not permitted."
},
"404": {
"description": "No such record."
}
},
"parameters": [
{
"name": "appversionid",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
]
}
},
"/api/endpoints/apppackage/app/{app_id}": {
"get": {
"tags": [
"endpoints-delivery"
],
"summary": "Agent-facing installer download resolved from the application rather than a specific version",
"description": "Agent-facing installer download resolved from the application rather than a specific version\n\n**Auth:** collector token (X-API-Key), endpoints scope\n\n**Params:** path: app_id (int)\n\n**Example:**\n```\ncurl http://localhost:5001/api/endpoints/apppackage/app/4 -H 'X-API-Key: $COLLECTOR_KEY' -o setup.exe\n```",
"security": [
{
"apiKeyAuth": []
}
],
"responses": {
"200": {
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
"content": {
"application/json": {
"$ref": "#/components/schemas/SuccessEnvelope"
}
}
},
"default": {
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
"content": {
"application/json": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"401": {
"description": "Missing or invalid credentials."
},
"403": {
"description": "Authenticated, but not permitted."
},
"404": {
"description": "No such record."
}
},
"parameters": [
{
"name": "app_id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
]
}
},
"/api/endpoints/apppackage/app/{app_id}/{filename}": {
"get": {
"tags": [
"endpoints-delivery"
],
"summary": "As above, naming the stored file explicitly so the agent writes it back under the expected name",
"description": "As above, naming the stored file explicitly so the agent writes it back under the expected name\n\n**Auth:** collector token (X-API-Key), endpoints scope\n\n**Params:** path: app_id (int), filename\n\n**Example:**\n```\ncurl http://localhost:5001/api/endpoints/apppackage/app/4/setup.exe -H 'X-API-Key: $COLLECTOR_KEY' -o setup.exe\n```",
"security": [
{
"apiKeyAuth": []
}
],
"responses": {
"200": {
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
"content": {
"application/json": {
"$ref": "#/components/schemas/SuccessEnvelope"
}
}
},
"default": {
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
"content": {
"application/json": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"401": {
"description": "Missing or invalid credentials."
},
"403": {
"description": "Authenticated, but not permitted."
},
"404": {
"description": "No such record."
}
},
"parameters": [
{
"name": "app_id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "filename",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
]
}
},
"/api/endpoints/scriptbody/{script_id}": {
"get": {
"tags": [
"endpoints-delivery"
],
"summary": "Agent-facing script download for a run_script delivery, on the same key as the rest of the delivery path",
"description": "Agent-facing script download for a run_script delivery, on the same key as the rest of the delivery path\n\n**Auth:** collector token (X-API-Key), endpoints scope\n\n**Params:** path: script_id (int)\n\n**Example:**\n```\ncurl http://localhost:5001/api/endpoints/scriptbody/3 -H 'X-API-Key: $COLLECTOR_KEY' -o run.ps1\n```",
"security": [
{
"apiKeyAuth": []
}
],
"responses": {
"200": {
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
"content": {
"application/json": {
"$ref": "#/components/schemas/SuccessEnvelope"
}
}
},
"default": {
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
"content": {
"application/json": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"401": {
"description": "Missing or invalid credentials."
},
"403": {
"description": "Authenticated, but not permitted."
},
"404": {
"description": "No such record."
}
},
"parameters": [
{
"name": "script_id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
]
}
},
"/api/endpoints/asset/{asset_id}/applications": {
"get": {
"tags": [
"endpoints-delivery"
],
"summary": "Installed-software snapshot the agent last reported for this endpoint, for the asset detail panel; a per-report...",
"description": "Installed-software snapshot the agent last reported for this endpoint, for the asset detail panel; a per-report snapshot rather than history\n\n**Auth:** jwt + require_permission endpoints.view\n\n**Params:** path: asset_id (int)\n\n**Example:**\n```\ncurl http://localhost:5001/api/endpoints/asset/313/applications -H \"Authorization: Bearer $TOK\"\n```",
"security": [
{
"bearerAuth": []
}
],
"responses": {
"200": {
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
"content": {
"application/json": {
"$ref": "#/components/schemas/SuccessEnvelope"
}
}
},
"default": {
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
"content": {
"application/json": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"401": {
"description": "Missing or invalid credentials."
},
"403": {
"description": "Authenticated, but not permitted."
},
"404": {
"description": "No such record."
}
},
"parameters": [
{
"name": "asset_id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
]
}
},
"/api/endpoints/siteagent": {
"get": {
"tags": [
"endpoints-siteagent"
],
"summary": "Report whether a prebuilt agent binary is staged and can therefore be packaged",
"description": "Report whether a prebuilt agent binary is staged and can therefore be packaged\n\n**Auth:** jwt + require_role admin\n\n**Params:** none\n\n**Example:**\n```\ncurl http://localhost:5001/api/endpoints/siteagent -H \"Authorization: Bearer $TOK\"\n```",
"security": [
{
"bearerAuth": []
}
],
"responses": {
"200": {
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
"content": {
"application/json": {
"$ref": "#/components/schemas/SuccessEnvelope"
}
}
},
"default": {
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
"content": {
"application/json": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"401": {
"description": "Missing or invalid credentials."
},
"403": {
"description": "Authenticated, but not permitted."
}
}
}
},
"/api/endpoints/siteagent/package": {
"post": {
"tags": [
"endpoints-siteagent"
],
"summary": "Bundle the agent as a downloadable ZIP with a generated install.ps1 that provisions credentials, so a bay install needs...",
"description": "Bundle the agent as a downloadable ZIP with a generated install.ps1 that provisions credentials, so a bay install needs no hand editing\n\n**Auth:** jwt + require_role admin\n\n**Params:** body: baseurl, collectorkey, cadenceseconds, and the site TLS cert when one is readable\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/endpoints/siteagent/package -H \"Authorization: Bearer $TOK\" -o shopdb-agent.zip\n```",
"security": [
{
"bearerAuth": []
}
],
"responses": {
"200": {
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
"content": {
"application/json": {
"$ref": "#/components/schemas/SuccessEnvelope"
}
}
},
"default": {
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
"content": {
"application/json": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"401": {
"description": "Missing or invalid credentials."
},
"403": {
"description": "Authenticated, but not permitted."
}
},
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"additionalProperties": true,
"description": "body: baseurl, collectorkey, cadenceseconds, and the site TLS cert when one is readable"
}
}
}
}
}
},
"/api/endpoints/adoptiontool/package": {
"post": {
"tags": [
"endpoints-siteagent"
],
"summary": "Bundle the agentless adoption tool, for taking an inventory of a machine that will not run the service",
"description": "Bundle the agentless adoption tool, for taking an inventory of a machine that will not run the service\n\n**Auth:** jwt + require_role admin\n\n**Params:** body: as for the site agent package\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/endpoints/adoptiontool/package -H \"Authorization: Bearer $TOK\" -o shopdb-adoption.zip\n```",
"security": [
{
"bearerAuth": []
}
],
"responses": {
"200": {
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
"content": {
"application/json": {
"$ref": "#/components/schemas/SuccessEnvelope"
}
}
},
"default": {
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
"content": {
"application/json": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"401": {
"description": "Missing or invalid credentials."
},
"403": {
"description": "Authenticated, but not permitted."
}
},
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"additionalProperties": true,
"description": "body: as for the site agent package"
}
}
}
}
}
}
}
}

View File

@@ -687,6 +687,43 @@ export const applicationsApi = {
}
}
// ShopDB-hosted script library. Backs run_script delivery: the agent fetches a
// script body by digest with its collector credential and runs it in the
// script's configured run context (system or user).
export const scriptsApi = {
list(params = {}) {
return api.get('/scripts', { params })
},
listAll(params = {}) {
return fetchAllPages('/scripts', params)
},
get(id) {
return api.get(`/scripts/${id}`)
},
create(data) {
return api.post('/scripts', data)
},
update(id, data) {
return api.put(`/scripts/${id}`, data)
},
delete(id) {
return api.delete(`/scripts/${id}`)
},
// script-body upload; backend sets filename/sha256 to the stored payload.
uploadBody(id, file, onProgress) {
const form = new FormData()
form.append('file', file)
return api.post(`/scripts/${id}/body`, form, {
headers: { 'Content-Type': 'multipart/form-data' },
onUploadProgress: event => {
if (onProgress && event.total) {
onProgress(Math.round((event.loaded * 100) / event.total))
}
}
})
}
}
// Support Teams API (teams + nested contacts)
export const supportteamsApi = {
list(params = {}) {

View File

@@ -69,14 +69,24 @@
<span class="assets-v">{{ stats.totalassets || 0 }}</span>
</div>
<!-- Status, on one line, and explicitly labelled as status. In
repair is muted at zero rather than given a card of its own to
announce that nothing is wrong. -->
<p class="assets-status">
<span class="assets-inuse">{{ stats.activeassets || 0 }}</span> in use
<span aria-hidden="true"> / </span>
<span :class="{ 'assets-zero': !stats.inrepair }">
{{ stats.inrepair || 0 }} in repair
<!-- Every status the total contains, not just In Use. These were
counted server-side for the rollup and then thrown away, so a
reader could see one number and had no way to learn where the
rest of the total went. Retired assets in particular ARE in
the total, which is not guessable. -->
<div v-for="row in statusrows" :key="row.name" class="assets-row assets-sub">
<span class="assets-k assets-subk">{{ row.name }}</span>
<span class="assets-v assets-subv">{{ row.count }}</span>
</div>
<!-- What the total covers. Asked twice what the number meant, which
is the signal a label is underspecified. -->
<p class="assets-caption">
Active records only<span v-if="stats.dualpathcollapsed">, dual-bay
pairs counted once</span>.
<span v-if="stats.inactiveexcluded">
{{ stats.inactiveexcluded }} deactivated record{{ stats.inactiveexcluded === 1 ? '' : 's' }}
excluded.
</span>
</p>
</section>
@@ -133,6 +143,17 @@ const summaryloaded = ref(false)
// hold - and to what the guest role reaches, when nobody is signed in.
const navitems = ref([])
// Every status in the total, biggest first. Sorted rather than fixed-order
// because the statuses are seed data a site can change - hardcoding "In Use,
// In Repair, Retired" would silently omit a status somebody added.
const statusrows = computed(() => {
const statuses = stats.value.statuses || {}
return Object.keys(statuses)
.map((name) => ({ name, count: statuses[name] }))
.filter((row) => row.count > 0)
.sort((a, b) => b.count - a.count)
})
const openable = computed(
// The dashboard itself is not somewhere to go from the dashboard.
() => navitems.value.filter((item) => item.route && item.route !== '/'))
@@ -224,11 +245,18 @@ span.assets-k { color: var(--text); font-weight: 600; }
border-bottom: none;
}
.assets-status {
margin: 0.5rem 0 0;
font-size: 0.85rem;
/* Status rows sit UNDER the total, indented and quieter, because they break
the total down rather than adding to it. Same weight as the type rows would
read as more things to add up. */
.assets-sub { border-bottom: none; padding: 0.15rem 0 0.15rem 0.75rem; }
.assets-subk { color: var(--text-light); font-size: 0.85rem; }
a.assets-subk:hover { text-decoration: none; }
.assets-subv { font-weight: 500; font-size: 0.85rem; color: var(--text-light); }
.assets-caption {
margin: 0.6rem 0 0;
font-size: 0.78rem;
line-height: 1.45;
color: var(--text-light);
}
.assets-inuse { color: var(--success); font-weight: 600; font-variant-numeric: tabular-nums; }
.assets-zero { color: var(--text-light); }
</style>

View File

@@ -0,0 +1,46 @@
"""Add appversions.sha256 and appversions.silentargs (installer delivery metadata)
The endpoint installer push resolves a specific AppVersion: sha256 pins the
exact bytes the agent must fetch, and silentargs carries this version's
unattended-install switches (homegrown EXEs vary by developer; MSI is uniform
via msiexec). Both nullable. Idempotent guards and real downgrade.
Revision ID: 7d35_appversion_delivery_metadata
Revises: 7d34_singular_relationship_types
Create Date: 2026-07-24
"""
from alembic import op
import sqlalchemy as sa
revision = '7d35_appversion_delivery_metadata'
down_revision = '7d34_singular_relationship_types'
branch_labels = None
depends_on = None
def _has_col(insp, name):
return any(c['name'] == name for c in insp.get_columns('appversions'))
def upgrade():
bind = op.get_bind()
insp = sa.inspect(bind)
if 'appversions' not in insp.get_table_names():
return
if not _has_col(insp, 'sha256'):
op.add_column('appversions', sa.Column('sha256', sa.String(length=64), nullable=True))
if not _has_col(insp, 'silentargs'):
op.add_column('appversions', sa.Column('silentargs', sa.String(length=255), nullable=True))
def downgrade():
bind = op.get_bind()
insp = sa.inspect(bind)
if 'appversions' not in insp.get_table_names():
return
if _has_col(insp, 'silentargs'):
op.drop_column('appversions', 'silentargs')
if _has_col(insp, 'sha256'):
op.drop_column('appversions', 'sha256')

View File

@@ -0,0 +1,71 @@
"""Add applications.installersha256 and the scripts table (script-library delivery)
Two additive, idempotent changes for the endpoint app/script delivery path:
1. applications.installersha256 - digest of the app-level uploaded installer so
an update_app push can resolve an install source by app alone (no AppVersion
row required). Nullable; null when installpath is a site share path.
2. scripts - the ShopDB-hosted script library. The endpoint agent fetches a
script body by digest on push and runs it in the configured runcontext
(system or user). Mirrors the application installer delivery model.
Idempotent guards and a real downgrade throughout; strict MySQL 8 clean.
Revision ID: 7d36_scriptlibrary_and_app_installer_digest
Revises: 7d35_appversion_delivery_metadata
Create Date: 2026-08-29
"""
from alembic import op
import sqlalchemy as sa
revision = '7d36_scriptlibrary_and_app_installer_digest'
down_revision = '7d35_appversion_delivery_metadata'
branch_labels = None
depends_on = None
def _has_col(insp, table, name):
return any(c['name'] == name for c in insp.get_columns(table))
def upgrade():
bind = op.get_bind()
insp = sa.inspect(bind)
if 'applications' in insp.get_table_names() \
and not _has_col(insp, 'applications', 'installersha256'):
op.add_column(
'applications',
sa.Column('installersha256', sa.String(length=64), nullable=True))
if 'scripts' not in insp.get_table_names():
op.create_table(
'scripts',
sa.Column('scriptid', sa.Integer(), primary_key=True),
sa.Column('scriptname', sa.String(length=100), nullable=False),
sa.Column('scriptdescription', sa.String(length=255), nullable=True),
sa.Column('filename', sa.String(length=255), nullable=True),
sa.Column('sha256', sa.String(length=64), nullable=True),
sa.Column('runcontext', sa.String(length=16),
nullable=False, server_default='system'),
sa.Column('createddate', sa.DateTime(), nullable=False),
sa.Column('modifieddate', sa.DateTime(), nullable=False),
sa.Column('isactive', sa.Boolean(), nullable=False,
server_default=sa.text('1')),
sa.UniqueConstraint('scriptname', name='uq_scripts_scriptname'),
)
def downgrade():
bind = op.get_bind()
insp = sa.inspect(bind)
if 'scripts' in insp.get_table_names():
op.drop_table('scripts')
if 'applications' in insp.get_table_names() \
and _has_col(insp, 'applications', 'installersha256'):
op.drop_column('applications', 'installersha256')

View 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).

View File

@@ -0,0 +1,112 @@
# Delivery testing notes (update_app + run_script)
Temporary field notes for exercising the endpoint delivery path end to end and
for handing the UI work to Cameron. ASCII only, no em-dashes, no emojis, per
repo style. Remove the dev test bench (view, route, nav entry) once the
production delivery UI lands.
## What exists today (the contract Cameron builds against)
Server allowlist (`plugins/endpoints/commands.py`):
- `run_script` - targetkind `script`. `target` is a script NAME the agent
already holds in its local `scriptpaths` allowlist. No body or path crosses
the wire.
- `update_app` - targetkind `app`. `target` is an `appversionid` that resolves
to a ShopDB-hosted installer package. The server pins the package by SHA-256
and passes this version's `silentargs`.
Both actions require `endpoints.deploy` ON TOP OF `endpoints.command`. Queueing
one is the actual authority (audited with the requesting user). The agent claims
it with its collector credential.
Enqueue call (same route as every other endpoint command):
```
POST /api/endpoints/<assetid>/commands
{ "action": "run_script", "target": "remove-network-drives" }
{ "action": "update_app", "target": "<appversionid>" }
```
Rules the UI must respect:
- At most one pending/running command per endpoint. A second enqueue returns
409 (treat as "already in flight", not a failure).
- `update_app` is validated at enqueue: an appversionid with no installer
package or unknown id is rejected 400 before it is queued.
- A version with no `sha256` has no uploaded package yet. Do not offer it for
`update_app`; the agent's integrity check will fail closed.
Read the result back:
```
GET /api/endpoints/<assetid>/commands
```
Each row carries `status` (pending/running/succeeded/failed), `resultmessage`
(short summary) and `resultjson` (structured detail: for run_script the exit
code and script path; for update_app the installer path, exit code and digest
check).
Agent-facing package download (collector-authenticated, not user JWT):
```
GET /api/endpoints/apppackage/<appversionid> (X-API-Key collector token)
```
## The dev test bench
Route: `/endpoints/delivery-test` (sidebar "Delivery test bench", gated on
`endpoints.deploy`). It is deliberately minimal: pick an endpoint, push a script
by name or push an app version, then watch the command log pane. There is no
script-library component yet, so `run_script` takes a free-text name; the agent
runs it only if that name is in its `scriptpaths`.
## Preparing the test endpoint
1. Copy the dev build to the test box:
- `agent\dev-bin\agent\shopdbagent.exe`
- `agent\dev-bin\agent\appsettings.json`
Stage them the same way the site installer does (service exe + appsettings).
2. Provision the collector credential and base URL. Per repo policy these are
NOT in appsettings.json; set the registry values under
`HKLM\SOFTWARE\GE\ShopDB` (`CollectorKey`, `BaseUrl`). The shipped
appsettings has `baseurl` empty and `apikey` = REPLACE_WITH_COLLECTOR_KEY so
the agent fails closed until provisioned.
3. Stage the sample script where appsettings points:
`C:\ProgramData\ShopDB\EndpointAgent\scripts\remove-network-drives.ps1`
(source: `agent\dev-bin\scripts\remove-network-drives.ps1`). The
`scriptpaths` entry in appsettings.json maps the name `remove-network-drives`
to that path.
4. For an `update_app` test, create the application in the Applications module,
upload its installer package (this stamps the version's `sha256`), and note
the version's `appversionid`.
## Collecting logs
Endpoint side (the agent):
- Rolling diagnostics log: `%ProgramData%\ShopDB\EndpointAgent\logs`
(override with `logpath` in appsettings). This is where run_script and
update_app record what they resolved, the digest check, and the installer or
script exit code.
- Windows Event Log source `ShopDB Endpoint Agent` for service faults.
Web UI / ShopDB side:
- The command log pane on the bench (or `GET /api/endpoints/<assetid>/commands`)
shows `status`, `resultmessage` and `resultjson` reported by the agent.
- Ingest observability: unknown/unpersisted telemetry keys are logged by the
endpoints plugin on the API container, so incoming agent data is visible in
the app logs even before UI persistence catches up.
- Audit log records each enqueue (`created EndpointCommand`, action + target +
asset) with the requesting user.
## Notes to capture during testing (for Cameron)
- Round-trip latency from enqueue to a terminal status, at the agent cadence.
- Whether `resultmessage` alone is enough for the operator, or the UI needs to
surface `resultjson` (exit codes, digest mismatches).
- How a 409 (already in flight) should read in the real UI.
- What a failed digest check or non-zero installer exit looks like end to end,
so the production UI can present it clearly.

View File

@@ -1,5 +1,9 @@
"""Endpoints plugin API."""
from .routes import endpoints_bp
# Attaches the adoption-tool packaging route to endpoints_bp (spike).
from . import adoption # noqa: F401
# Attaches the site agent generator packaging routes to endpoints_bp.
from . import siteagent # noqa: F401
__all__ = ['endpoints_bp']

View 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_permission
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_permission('endpoints.agentpackage')
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')

View File

@@ -6,7 +6,9 @@ service token, and are handled by EndpointsPlugin.apply_collector_payload.
These routes are the human-facing read surface, gated by user JWT + RBAC.
"""
import hashlib
import json
import os
import time
from datetime import datetime, timedelta, timezone
@@ -19,9 +21,10 @@ from shopdb.api import (
require_role, caller_holds, mint_service_token, list_service_tokens,
revoke_service_tokens,
AuditLog, collector_authorized, User,
Application, AppVersion, Script,
)
from ..models import EndpointState, EndpointEvent, EndpointCommand
from ..models import EndpointState, EndpointEvent, EndpointCommand, EndpointApplication
from ..commands import COMMAND_ALLOWLIST, is_allowed_action, ALLOWED_ACTIONS_FOR, row_actions_for, requires_target, action_targetkind, elevated_permission_for
from ..eventcatalog import CRITICAL_EVENT_IDS, event_severity, event_title
@@ -674,6 +677,32 @@ def panel_interfaces(asset_id: int):
return success_response(rows)
@endpoints_bp.route('/asset/<int:asset_id>/applications', methods=['GET'])
@require_permission('endpoints.view')
def panel_applications(asset_id: int):
"""Table panel: the installed-software (SBOM) snapshot for this endpoint.
Reads the endpointapplications rows the collector last wrote for this asset
(a per-report snapshot, not history) and returns them sorted by name. This
is the raw discovered inventory: appid is carried through but not yet linked
to the applications catalog ("Managed Applications"), so no managed status
is surfaced here. An endpoint with no reported SBOM yields no rows and the
panel hides itself.
"""
rows = (EndpointApplication.query
.filter_by(assetid=asset_id)
.order_by(db.func.lower(EndpointApplication.displayname))
.all())
return success_response([
{
'displayname': row.displayname,
'displayversion': row.displayversion or '-',
'publisher': row.publisher or '-',
}
for row in rows
])
# =============================================================================
# Dashboard
# =============================================================================
@@ -912,6 +941,41 @@ _CLAIM_HOLD_SECONDS = 25
_CLAIM_POLL_INTERVAL = 1.0
def _reap_stale_commands(asset_id):
"""Expire in-flight commands an agent will never finish for this endpoint.
The queue is at-most-one-in-flight: a single PENDING or RUNNING row blocks
every new command. If the agent is killed, uninstalled, or goes offline
after a command is claimed (RUNNING) or before it claims one (PENDING), that
row would pin the queue forever and every operator action returns 409. Reap
rows past their staleness ceiling to STATUS_EXPIRED so the queue self-heals.
Caller commits (this runs inside the enqueue transaction).
"""
now = _naive_utcnow()
running_cutoff = now - timedelta(seconds=EndpointCommand.RUNNING_STALE_SECONDS)
pending_cutoff = now - timedelta(seconds=EndpointCommand.PENDING_STALE_SECONDS)
stale = (EndpointCommand.query
.filter(EndpointCommand.assetid == asset_id,
db.or_(
db.and_(
EndpointCommand.status == EndpointCommand.STATUS_RUNNING,
EndpointCommand.claimedat.isnot(None),
EndpointCommand.claimedat < running_cutoff),
db.and_(
EndpointCommand.status == EndpointCommand.STATUS_PENDING,
EndpointCommand.createdat < pending_cutoff)))
.all())
for command in stale:
command.status = EndpointCommand.STATUS_EXPIRED
command.completedat = now
if not command.resultmessage:
command.resultmessage = (
'Expired: agent did not report a result before the staleness '
'ceiling (agent offline, killed, or reinstalled mid-command).')
return stale
def _collector_auth_or_error():
"""Authorize an agent-facing command route on the endpoints collector token.
@@ -931,6 +995,184 @@ def _resolve_asset_for_hostname(hostname):
return computer.assetid if computer is not None else None
_HOSTED_PACKAGE_PREFIX = '/api/applications/package/'
def _hosted_package_filename(application):
"""Return the stored filename for a ShopDB-hosted installer, or None.
installpath is the core package URL (/api/applications/package/<filename>);
a site-typed share path (not our upload) has no servable file here.
"""
installpath = application.installpath or ''
if not installpath.startswith(_HOSTED_PACKAGE_PREFIX):
return None
return installpath[len(_HOSTED_PACKAGE_PREFIX):]
def _backfill_app_installer_sha256(application):
"""Compute and persist installersha256 for a pre-migration package.
Packages uploaded before the 7d36 migration have a null installersha256,
which used to force a re-upload (Bug B). When the hosted package file is
present on disk, hash it once, stamp the app, and commit so future resolves
hit the stored value. Returns the digest, or None when no servable file.
"""
filename = _hosted_package_filename(application)
if not filename:
return None
package_dir = os.path.join(current_app.instance_path, 'applicationpackages')
package_path = os.path.join(package_dir, filename)
if not os.path.isfile(package_path):
return None
digest = hashlib.sha256()
with open(package_path, 'rb') as package_file:
for chunk in iter(lambda: package_file.read(65536), b''):
digest.update(chunk)
application.installersha256 = digest.hexdigest()
db.session.commit()
return application.installersha256
def _resolve_update_app(target):
"""Resolve an update_app target to a delivery descriptor.
Accepts either an app-level or a version-level source, so a push works
whether or not the site tracks AppVersion rows (the applications UI stores a
single app-level installer; versioning is optional):
- "app:<appid>" - the app's own uploaded installer (installersha256)
- "version:<id>" - a specific AppVersion's pinned package
- bare "<int>" - legacy: treated as an appversionid
Returns (descriptor, error_message). The descriptor carries what the agent
needs to fetch and run the pinned installer: a collector-authenticated
download URL, its SHA-256, and the silent-install switches. The agent
fetches the bytes with its existing collector credential; no bytes here.
"""
kind, _sep, raw = str(target or '').partition(':')
if not raw:
# No prefix: legacy bare appversionid.
kind, raw = 'version', kind
try:
ident = int(raw)
except (TypeError, ValueError):
return None, 'update_app target must be app:<id>, version:<id>, or an appversionid'
if kind == 'app':
application = db.session.get(Application, ident)
if application is None:
return None, f'Unknown appid: {ident}'
if not application.installpath:
return None, (f'Application {ident} has no uploaded installer package')
# Backfill installersha256 for packages uploaded before the 7d36 migration
# so app-level delivery resolves without a re-upload (Bug B).
sha256 = application.installersha256 or _backfill_app_installer_sha256(application)
if not sha256:
return None, (f'Application {ident} has no uploaded installer package')
# Carry the real installer filename (with extension) on the delivery URL so
# the agent's Path.GetExtension routes MSI through msiexec (Bug A rc2). The
# trailing filename is cosmetic for the agent; the app_id drives the lookup.
filename = _hosted_package_filename(application)
installpath = '/api/endpoints/apppackage/app/{}'.format(application.appid)
if filename:
installpath = '{}/{}'.format(installpath, filename)
return {
'source': 'app',
'appid': application.appid,
'appversionid': None,
'appname': application.appname,
'version': None,
'installpath': installpath,
'sha256': sha256,
'silentargs': None,
}, None
if kind == 'version':
version = db.session.get(AppVersion, ident)
if version is None:
return None, f'Unknown appversionid: {ident}'
application = db.session.get(Application, version.appid)
if application is None or not application.installpath:
return None, ('Application for appversionid {} has no installer package'
.format(ident))
# PIN THE FILE THAT IS ACTUALLY SERVED.
#
# /api/endpoints/apppackage/<appversionid> resolves the version to its
# APPLICATION and serves application.installpath - there is no
# version-specific artifact behind that route. So version.sha256 pinned
# a digest that need not describe the bytes delivered, and where it was
# null the descriptor carried no digest at all and the agent had nothing
# to verify against. Deriving it from the application, exactly as the
# app branch does, makes the pin describe the delivery.
sha256 = (application.installersha256
or _backfill_app_installer_sha256(application))
if not sha256:
return None, ('Application for appversionid {} has an installer '
'package with no recorded SHA-256, so the delivery '
'cannot be verified'.format(ident))
# Carry the real filename so the agent's Path.GetExtension routes an MSI
# through msiexec. Without it every MSI push failed, because this branch
# ended the URL at the id and the extension was simply absent.
filename = _hosted_package_filename(application)
installpath = '/api/endpoints/apppackage/{}'.format(version.appversionid)
if filename:
installpath = '/api/endpoints/apppackage/app/{}/{}'.format(
application.appid, filename)
return {
'source': 'version',
'appversionid': version.appversionid,
'appid': version.appid,
'appname': application.appname,
'version': version.version,
'installpath': installpath,
'sha256': sha256,
'silentargs': version.silentargs,
}, None
return None, f'Unknown update_app target kind: {kind!r}'
def _resolve_run_script(target):
"""Resolve a run_script target to a script delivery descriptor.
Accepts either "script:<scriptid>" or a bare script name. The descriptor
carries a collector-authenticated download URL, the body's SHA-256, and the
per-script run context (system or user). The agent fetches the body with
its collector credential and runs it; the body never crosses the wire.
"""
kind, _sep, raw = str(target or '').partition(':')
script = None
if kind == 'script' and raw:
try:
script = db.session.get(Script, int(raw))
except (TypeError, ValueError):
return None, 'run_script target script:<id> must be numeric'
else:
# Bare name (the whole target, prefix included when there was no colon).
name = (target or '').strip()
if name:
script = Script.query.filter_by(scriptname=name).first()
if script is None:
return None, f'Unknown script: {target!r}'
if not script.filename or not script.sha256:
return None, f'Script {script.scriptname!r} has no uploaded body'
if not script.isactive:
return None, f'Script {script.scriptname!r} is inactive'
return {
'scriptid': script.scriptid,
'scriptname': script.scriptname,
'installpath': '/api/endpoints/scriptbody/{}'.format(script.scriptid),
'sha256': script.sha256,
'runcontext': script.runcontext,
}, None
@endpoints_bp.route('/<int:asset_id>/commands', methods=['GET'])
@require_permission('endpoints.view')
def list_endpoint_commands(asset_id: int):
@@ -989,12 +1231,33 @@ def enqueue_endpoint_command(asset_id: int):
ErrorCodes.VALIDATION_ERROR,
f'Action {action!r} does not accept a target')
# update_app pins a specific application version; verify it resolves to a
# real installer now so a broken push is rejected at enqueue rather than
# failing opaquely on the endpoint minutes later.
if action == 'update_app':
_descriptor, resolve_error = _resolve_update_app(target)
if resolve_error is not None:
return error_response(ErrorCodes.VALIDATION_ERROR, resolve_error)
# run_script pins a ShopDB-hosted script; verify it resolves to a real
# uploaded body now so a broken push is rejected at enqueue rather than
# failing opaquely on the endpoint later.
if action == 'run_script':
_script, script_error = _resolve_run_script(target)
if script_error is not None:
return error_response(ErrorCodes.VALIDATION_ERROR, script_error)
state = EndpointState.query.filter_by(assetid=asset_id).first()
if state is None:
return error_response(ErrorCodes.NOT_FOUND,
f'No endpoint state for asset {asset_id}',
http_code=404)
# Self-heal a queue wedged by a command the agent will never finish (killed,
# uninstalled, or offline mid-flight) before enforcing at-most-one-in-flight,
# so a dead in-flight row cannot permanently 409 every operator action.
_reap_stale_commands(asset_id)
pending = (EndpointCommand.query
.filter(EndpointCommand.assetid == asset_id,
EndpointCommand.status.in_((
@@ -1060,12 +1323,33 @@ def claim_next_command():
command.status = EndpointCommand.STATUS_RUNNING
command.claimedat = _naive_utcnow()
db.session.commit()
return success_response({
claim = {
'endpointcommandid': command.endpointcommandid,
'action': command.action,
'target': command.target,
'targetkind': command.targetkind,
})
}
# update_app rides a resolved delivery descriptor: the agent gets
# the package URL, its pinned SHA-256, and the version's silent-
# install switches, and fetches the bytes with its collector
# credential. If the version was deleted between enqueue and claim,
# send the command through with no descriptor and let the agent
# fail closed rather than 500 the poll.
if command.action == 'update_app':
descriptor, _resolve_error = _resolve_update_app(command.target)
if descriptor is not None:
claim['app'] = descriptor
# run_script rides a resolved script descriptor: the agent gets a
# collector-authenticated body URL, the pinned SHA-256, and the
# per-script run context. Fetch happens agent-side with the
# collector credential. If the script vanished between enqueue and
# claim, send the command through with no descriptor and let the
# agent fail closed rather than 500 the poll.
if command.action == 'run_script':
script_descriptor, _script_error = _resolve_run_script(command.target)
if script_descriptor is not None:
claim['script'] = script_descriptor
return success_response(claim)
# Nothing queued: release the session so a concurrent enqueue is visible
# on the next poll, wait a beat, then re-check until the hold expires.
@@ -1114,3 +1398,106 @@ def report_command_result(command_id: int):
db.session.commit()
return success_response(command.to_dict(), message='Result recorded')
@endpoints_bp.route('/apppackage/<int:appversionid>', methods=['GET'])
def download_app_package(appversionid: int):
"""Agent-facing installer download (collector-token auth).
The core /api/applications/package route is gated on applications.view, a
permission the endpoint agent's collector token does not hold. This route
authorizes on the same endpoints collector token the agent already uses for
telemetry and command polling, so the whole app-delivery path rides one key.
Serves the exact bytes the update_app descriptor pinned by SHA-256.
"""
from shopdb.api import send_upload
error = _collector_auth_or_error()
if error is not None:
return error
version = db.session.get(AppVersion, appversionid)
if version is None:
return error_response(ErrorCodes.NOT_FOUND,
f'Unknown appversionid: {appversionid}', http_code=404)
application = db.session.get(Application, version.appid)
if application is None or not application.installpath:
return error_response(ErrorCodes.NOT_FOUND,
'No installer package for this application',
http_code=404)
# installpath is the core package URL (/api/applications/package/<filename>);
# take the stored filename and serve it from the core package directory. A
# site-typed share path (not our upload) has no servable file here.
prefix = '/api/applications/package/'
if not application.installpath.startswith(prefix):
return error_response(ErrorCodes.NOT_FOUND,
'Application installer is not a ShopDB-hosted package',
http_code=404)
filename = application.installpath[len(prefix):]
package_dir = os.path.join(current_app.instance_path, 'applicationpackages')
return send_upload(package_dir, filename, as_attachment=True)
@endpoints_bp.route('/apppackage/app/<int:app_id>', methods=['GET'])
@endpoints_bp.route('/apppackage/app/<int:app_id>/<path:filename>', methods=['GET'])
def download_app_package_by_app(app_id: int, filename: str = None):
"""Agent-facing installer download by APP (collector-token auth).
The app-level counterpart to download_app_package: serves the application's
own uploaded installer (installpath + installersha256) so update_app can
resolve an install source by app alone, with no AppVersion row. Same
collector-token auth and same package directory as the per-version route.
The optional trailing <filename> segment lets the delivery URL carry the
real installer extension (so the agent routes MSI through msiexec); it is
validated against the stored filename and does not drive the lookup.
"""
from shopdb.api import send_upload
error = _collector_auth_or_error()
if error is not None:
return error
application = db.session.get(Application, app_id)
if application is None or not application.installpath:
return error_response(ErrorCodes.NOT_FOUND,
'No installer package for this application',
http_code=404)
prefix = '/api/applications/package/'
if not application.installpath.startswith(prefix):
return error_response(ErrorCodes.NOT_FOUND,
'Application installer is not a ShopDB-hosted package',
http_code=404)
stored_filename = application.installpath[len(prefix):]
if filename is not None and filename != stored_filename:
return error_response(ErrorCodes.NOT_FOUND,
'Requested installer filename does not match this application',
http_code=404)
package_dir = os.path.join(current_app.instance_path, 'applicationpackages')
return send_upload(package_dir, stored_filename, as_attachment=True)
@endpoints_bp.route('/scriptbody/<int:script_id>', methods=['GET'])
def download_script_body(script_id: int):
"""Agent-facing script body download (collector-token auth).
The core /api/scripts/body route is gated on applications.view, which the
endpoint agent's collector token does not hold. This route authorizes on
the same endpoints collector token the agent already uses, keeping the
whole script-delivery path on one key. Serves the exact bytes the
run_script descriptor pinned by SHA-256.
"""
from shopdb.api import send_upload
error = _collector_auth_or_error()
if error is not None:
return error
script = db.session.get(Script, script_id)
if script is None or not script.filename:
return error_response(ErrorCodes.NOT_FOUND,
'No body for this script', http_code=404)
script_dir = os.path.join(current_app.instance_path, 'scripts')
return send_upload(script_dir, script.filename, as_attachment=True)

View File

@@ -0,0 +1,440 @@
"""Site agent generator packaging.
Builds a deployable, instance-locked endpoint-agent package for this site. An
admin supplies the server base URL, the managed endpoint agent key (minted in
the key generator above), and the reporting cadence; this route returns a ZIP
carrying the prebuilt shopdbagent.exe, a non-secret appsettings.json, an
install.ps1 that provisions the credentials, and a README.
This module is deliberately self-contained so it can be maintained alongside
the endpoint agent without editing routes.py. It attaches two admin-gated
routes to the existing endpoints blueprint.
The container has no .NET SDK, so - exactly like the adoption tool - the agent
exe is built out of band and staged under instance/siteagent/. When a prebuilt
binary is present it ships in the ZIP; otherwise the ZIP still carries the
config and installer so an operator can drop the exe in beside them.
Per repo policy the live CollectorKey and BaseUrl are provisioned into the
Windows registry (HKLM\\SOFTWARE\\GE\\ShopDB), never baked into appsettings.json.
The generated install.ps1 writes those two registry values from the operator's
inputs; appsettings.json ships with an empty baseurl and a placeholder apikey so
the agent fails closed until provisioned. The collector key is embedded only in
the install script's default parameter and is never stored or logged here.
"""
import io
import json
import os
import ssl
import zipfile
from flask import request, current_app, send_file
from shopdb.api import (success_response, error_response, ErrorCodes,
require_permission)
from .routes import endpoints_bp
# Prebuilt, staged agent binaries. Built out of band and dropped under
# instance/siteagent/ (mirrors the adoption tool's STAGED_EXE_RELPATH model).
STAGED_AGENT_RELPATH = os.path.join('siteagent', 'shopdbagent.exe')
STAGED_TRAY_RELPATH = os.path.join('siteagent', 'shopdb-endpoint-tray.exe')
# Public TLS certificate the agent must trust when this deployment terminates
# TLS itself with a self-signed or otherwise untrusted certificate. We can only
# auto-bundle when THIS app owns the cert file (the dev topology, where gunicorn
# terminates TLS with certs/server.crt). When a site fronts the app with its own
# reverse proxy the app cannot see the proxy's cert, so bundling is opt-in and
# only offered when a readable cert file is actually found here. Resolution order:
# the SHOPDB_TLS_CERT env var, then the mounted container path, then the repo
# path. Only a PEM public certificate is ever read - never a private key.
_CERT_CANDIDATES = (
os.environ.get('SHOPDB_TLS_CERT') or '',
'/app/certs/server.crt',
os.path.join(os.getcwd(), 'certs', 'server.crt'),
)
def _server_cert_path():
"""Path to a readable public TLS cert to bundle, or None if none exists."""
for candidate in _CERT_CANDIDATES:
if candidate and os.path.isfile(candidate):
return candidate
return None
def _validate_pem_cert(raw):
"""Return normalized PEM text for a valid X.509 certificate, else None.
Accepts operator-uploaded .crt/.pem bytes. Parses every PEM certificate
block through the stdlib ssl module (which round-trips it to DER, so junk
or a non-certificate PEM such as a private key is rejected). The returned
text is the concatenation of the re-encoded blocks, so a leaf-plus-chain
upload is preserved while any trailing garbage is dropped.
"""
try:
text = raw.decode('ascii')
except (UnicodeDecodeError, AttributeError):
return None
marker = '-----BEGIN CERTIFICATE-----'
if marker not in text:
return None
blocks = []
start = text.find(marker)
while start != -1:
end = text.find('-----END CERTIFICATE-----', start)
if end == -1:
return None
end += len('-----END CERTIFICATE-----')
block = text[start:end] + '\n'
try:
ssl.PEM_cert_to_DER_cert(block)
except (ssl.SSLError, ValueError):
return None
blocks.append(block)
start = text.find(marker, end)
if not blocks:
return None
return ''.join(blocks)
# The non-secret runtime config shipped in the package. baseurl and apikey stay
# empty/placeholder so the agent fails closed until the registry is provisioned
# by install.ps1. Cadence is stamped from the operator's input at build time.
_BASE_APPSETTINGS = {
'baseurl': '',
'apikey': 'REPLACE_WITH_COLLECTOR_KEY',
'cadenceseconds': 900,
'jitterseconds': 60,
'adoptonfirstrun': True,
'utilitiespath': '',
'logpath': '',
'monitors': {
'processes': [
{'name': 'dncMain.exe', 'app': 'eDNC'},
{'name': 'udc.exe', 'app': 'Universal Data Collector'},
{'name': 'agent.exe', 'app': 'MTConnect'},
],
'services': [
{'name': 'MTConnect UDC Agent', 'app': 'MTConnect'},
],
},
'launchpaths': {
'DncMain.exe': 'C:\\Program Files (x86)\\Dnc\\bin\\DncMain.exe',
'UDC.exe': 'C:\\Program Files\\UDC\\UDC.exe',
'agent.exe': 'C:\\ProgramData\\udc\\mtconnect_udc\\agent\\run.bat',
},
}
_README = """ShopDB endpoint agent (site build)
WHAT THIS IS
An instance-locked endpoint agent for this site. It installs as a Windows
service, reports identity, hardware, and installed-software inventory on a
fixed cadence, and executes delivery commands (scripts, app updates) issued
from this ShopDB server. Unlike the adoption tool it stays resident.
FILES
shopdbagent.exe The agent service host (present only if the server
had a prebuilt copy staged).
shopdb-endpoint-tray.exe Optional interactive tray app (present when staged).
appsettings.json Non-secret runtime config (cadence, monitors,
launch paths). No credentials.
install.ps1 Provisions the credentials and installs the service.
server.crt Server TLS certificate (present only for self-signed
or internal-CA deployments, either auto-detected or
uploaded by the operator). install.ps1 imports it
into the machine Trusted Root store so the agent can
validate its outbound TLS connection to the server.
USAGE
1. Copy this whole folder to the target endpoint.
2. Right-click install.ps1 and Run with PowerShell as administrator. It
writes the server URL and collector key to HKLM\\SOFTWARE\\GE\\ShopDB,
copies the agent into Program Files, and starts the service.
SECURITY
install.ps1 carries a live collector key in its default -CollectorKey value.
Treat this package as a secret. The key is not recoverable from the ShopDB
server after it was generated; regenerate the key in the UI to revoke it.
"""
def _staged_path(relpath):
"""Absolute path to a staged binary under the instance directory."""
return os.path.join(current_app.instance_path, relpath)
def _install_script(baseurl, collectorkey, cadenceseconds, certfilename=None):
"""The install.ps1 that provisions credentials and installs the service.
The operator's server URL and collector key are stamped as the default
parameter values so a double-click install needs no arguments. The key is
written only to the registry (HKLM\\SOFTWARE\\GE\\ShopDB), never into
appsettings.json, honoring the registry-provisioning contract. When a server
certificate is bundled, certfilename names it and the script imports it into
the machine Trusted Root store so the agent's outbound TLS to a self-signed
or internal-CA server validates.
"""
baseurl_literal = json.dumps(baseurl)
collectorkey_literal = json.dumps(collectorkey)
certfile_literal = json.dumps(certfilename or '')
return """# install.ps1
# ShopDB endpoint agent installer. Run from an ELEVATED (admin) PowerShell on
# the endpoint. Provisions the registry credentials, copies the agent to
# Program Files, and installs and starts the Windows service. The server URL and
# collector key are stamped as defaults by the site agent generator.
[CmdletBinding()]
param(
[string] $BaseUrl = {baseurl},
[string] $CollectorKey = {collectorkey},
[string] $InstallDir = "C:\\Program Files\\ShopDB\\EndpointAgent",
[string] $ServiceName = "ShopDbEndpointAgent",
[string] $TrayExeName = "shopdb-endpoint-tray.exe",
[string] $CertFileName = {certfile},
[switch] $NoTray
)
$ErrorActionPreference = "Stop"
$sourceDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
$principal = New-Object Security.Principal.WindowsPrincipal($identity)
if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {{
throw "This script must be run from an elevated (Run as administrator) PowerShell."
}}
if ([string]::IsNullOrWhiteSpace($BaseUrl)) {{
throw "BaseUrl is not set. Pass -BaseUrl 'http://<host>:<port>' when running the script."
}}
if ([string]::IsNullOrWhiteSpace($CollectorKey)) {{
throw "CollectorKey is not set. Regenerate the key in the endpoints UI and rebuild."
}}
# Write HKLM values to BOTH the 64-bit and 32-bit (WOW6432Node) registry views.
# The agent reads Registry64 first then Registry32 and uses the first view that
# yields a value, so a stale WOW6432Node copy from a prior install (for example
# an old http:// BaseUrl) would silently win if we only wrote the 64-bit view.
# Writing both keeps the two views in lockstep so re-provisioning cannot be
# shadowed by a leftover value.
function Set-HklmValueBothViews {{
param(
[Parameter(Mandatory)] [string] $SubKey,
[Parameter(Mandatory)] [string] $Name,
[Parameter(Mandatory)] [string] $Value
)
foreach ($view in @([Microsoft.Win32.RegistryView]::Registry64,
[Microsoft.Win32.RegistryView]::Registry32)) {{
$base = [Microsoft.Win32.RegistryKey]::OpenBaseKey(
[Microsoft.Win32.RegistryHive]::LocalMachine, $view)
try {{
$key = $base.CreateSubKey($SubKey)
try {{ $key.SetValue($Name, $Value, [Microsoft.Win32.RegistryValueKind]::String) }}
finally {{ $key.Dispose() }}
}}
finally {{ $base.Dispose() }}
}}
}}
Write-Host "== ShopDB endpoint agent install ==" -ForegroundColor Cyan
Write-Host "BaseUrl : $BaseUrl"
Write-Host "InstallDir : $InstallDir"
Write-Host "ServiceName : $ServiceName"
# 0. Stop and remove any previous install so the file copy is a clean overwrite.
$existing = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue
if ($existing) {{
Write-Host "[0/4] Removing existing service $ServiceName ..."
if ($existing.Status -ne "Stopped") {{
try {{ Stop-Service -Name $ServiceName -Force -ErrorAction Stop }}
catch {{ Write-Warning "could not stop service: $($_.Exception.Message)" }}
}}
sc.exe delete $ServiceName | Out-Null
Start-Sleep -Seconds 2
}}
$trayBase = [System.IO.Path]::GetFileNameWithoutExtension($TrayExeName)
foreach ($name in @("shopdbagent", $trayBase)) {{
foreach ($p in (Get-Process -Name $name -ErrorAction SilentlyContinue)) {{
try {{ Stop-Process -Id $p.Id -Force -ErrorAction Stop }} catch {{ }}
}}
}}
# 1. Provision registry credentials (both 64-bit and 32-bit views).
Write-Host "[1/4] Provisioning registry HKLM:\\SOFTWARE\\GE\\ShopDB (Registry64 + Registry32) ..."
Set-HklmValueBothViews -SubKey "SOFTWARE\\GE\\ShopDB" -Name "BaseUrl" -Value $BaseUrl
Set-HklmValueBothViews -SubKey "SOFTWARE\\GE\\ShopDB" -Name "CollectorKey" -Value $CollectorKey
# 1a. Trust the bundled server certificate, if one shipped. The agent's outbound
# TLS to a self-signed or internal-CA server fails closed until the machine
# trusts the cert. Importing into LocalMachine\\Root covers the SYSTEM service.
# No-op when the deployment uses a publicly trusted (or already-trusted internal)
# certificate, in which case no cert is bundled.
if (-not [string]::IsNullOrWhiteSpace($CertFileName)) {{
$certPath = Join-Path $sourceDir $CertFileName
if (Test-Path $certPath) {{
Write-Host " trusting bundled server certificate $CertFileName ..."
try {{
Import-Certificate -FilePath $certPath -CertStoreLocation Cert:\\LocalMachine\\Root | Out-Null
}}
catch {{ Write-Warning "could not import server certificate: $($_.Exception.Message)" }}
}}
else {{
Write-Warning "server certificate $CertFileName named but not found beside install.ps1."
}}
}}
# 2. Copy agent files to the install directory.
Write-Host "[2/4] Copying agent files to $InstallDir ..."
New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null
Get-ChildItem -Path $sourceDir -File | ForEach-Object {{
try {{ Unblock-File -Path $_.FullName -ErrorAction Stop }} catch {{ }}
}}
$agentSource = Join-Path $sourceDir "shopdbagent.exe"
if (-not (Test-Path $agentSource)) {{
throw "shopdbagent.exe not found in $sourceDir. This package did not ship a prebuilt agent; stage the exe beside install.ps1 and rerun."
}}
Copy-Item -Path $agentSource -Destination $InstallDir -Force
$appsettings = Join-Path $sourceDir "appsettings.json"
if (Test-Path $appsettings) {{
Copy-Item -Path $appsettings -Destination $InstallDir -Force
}}
if (-not $NoTray) {{
$traySource = Join-Path $sourceDir $TrayExeName
if (Test-Path $traySource) {{
Copy-Item -Path $traySource -Destination $InstallDir -Force
}}
}}
# 3. Install and start the service. The agent supports its own service install.
Write-Host "[3/4] Installing and starting the service ..."
$installedAgent = Join-Path $InstallDir "shopdbagent.exe"
$binPath = '"' + $installedAgent + '"'
sc.exe create $ServiceName binPath= $binPath start= auto DisplayName= "ShopDB Endpoint Agent" | Out-Null
sc.exe start $ServiceName | Out-Null
Write-Host "[4/4] Done." -ForegroundColor Green
""".format(
baseurl=baseurl_literal,
collectorkey=collectorkey_literal,
certfile=certfile_literal,
)
def _siteagent_status_payload():
"""Whether a prebuilt agent binary is staged for packaging."""
agent_staged = os.path.isfile(_staged_path(STAGED_AGENT_RELPATH))
tray_staged = os.path.isfile(_staged_path(STAGED_TRAY_RELPATH))
return {
'agentstaged': agent_staged,
'traystaged': tray_staged,
'certavailable': _server_cert_path() is not None,
}
@endpoints_bp.route('/siteagent', methods=['GET'])
@require_permission('endpoints.agentpackage')
def get_siteagent():
"""Report whether the site agent build has a prebuilt binary staged."""
return success_response(_siteagent_status_payload())
@endpoints_bp.route('/siteagent/package', methods=['POST'])
@require_permission('endpoints.agentpackage')
def package_siteagent():
"""Bundle the site agent as a downloadable ZIP.
Accepts either JSON or multipart/form-data with fields baseurl,
collectorkey, cadenceseconds, and bundlecert. A multipart request may also
carry a certfile upload: an operator-supplied .crt/.pem that is validated as
X.509 and bundled so the installer trusts it as a root on the endpoint. This
is the reverse-proxy path where the app cannot see its own TLS cert. When no
cert is uploaded but bundlecert is set and this app terminates TLS with a
readable certificate, that auto-detected cert ships instead.
Returns an in-memory ZIP with the staged shopdbagent.exe (and tray when
present), a non-secret appsettings.json, install.ps1, and README.txt. The
collector key is embedded only in install.ps1 as its default parameter and
is never stored or logged.
"""
if request.form or request.files:
source = request.form
certupload = request.files.get('certfile')
else:
source = request.get_json(silent=True) or {}
certupload = None
baseurl = (source.get('baseurl') or '').strip()
collectorkey = (source.get('collectorkey') or '').strip()
cadenceseconds = source.get('cadenceseconds', 900)
bundlecert = str(source.get('bundlecert', '')).lower() in ('1', 'true', 'yes', 'on')
if not baseurl or not collectorkey:
return error_response(
ErrorCodes.VALIDATION_ERROR,
'baseurl and collectorkey are required',
http_code=400)
try:
cadenceseconds = int(cadenceseconds)
except (TypeError, ValueError):
return error_response(
ErrorCodes.VALIDATION_ERROR,
'cadenceseconds must be an integer',
http_code=400)
if cadenceseconds < 60:
return error_response(
ErrorCodes.VALIDATION_ERROR,
'cadenceseconds must be at least 60',
http_code=400)
appsettings = dict(_BASE_APPSETTINGS)
appsettings['cadenceseconds'] = cadenceseconds
# An operator-uploaded cert wins over the auto-detected server cert: it is
# the explicit reverse-proxy answer where the app cannot see its own TLS
# cert. Validate it as X.509 and bundle the normalized PEM. Fall back to the
# app-owned cert only when bundlecert is set and no upload was provided.
cert_pem = None
certpath = None
if certupload is not None and certupload.filename:
cert_pem = _validate_pem_cert(certupload.read())
if cert_pem is None:
return error_response(
ErrorCodes.VALIDATION_ERROR,
'certfile is not a valid PEM X.509 certificate',
http_code=400)
elif bundlecert:
certpath = _server_cert_path()
certfilename = 'server.crt' if (cert_pem or certpath) else None
buffer = io.BytesIO()
with zipfile.ZipFile(buffer, 'w', zipfile.ZIP_DEFLATED) as archive:
archive.writestr('appsettings.json',
json.dumps(appsettings, indent=2) + '\n')
archive.writestr('install.ps1',
_install_script(baseurl.rstrip('/'), collectorkey,
cadenceseconds, certfilename))
archive.writestr('README.txt', _README)
agent = _staged_path(STAGED_AGENT_RELPATH)
if os.path.isfile(agent):
archive.write(agent, 'shopdbagent.exe')
tray = _staged_path(STAGED_TRAY_RELPATH)
if os.path.isfile(tray):
archive.write(tray, 'shopdb-endpoint-tray.exe')
if cert_pem:
archive.writestr('server.crt', cert_pem)
elif certpath:
archive.write(certpath, 'server.crt')
buffer.seek(0)
return send_file(
buffer,
mimetype='application/zip',
as_attachment=True,
download_name='shopdb-endpoint-agent.zip')

View File

@@ -77,6 +77,22 @@ COMMAND_ALLOWLIST = {
'label': 'Collect now',
'description': 'Force the endpoint agent to report fresh telemetry off-cadence.',
},
'run_script': {
'label': 'Run script',
'description': 'Run a ShopDB-hosted script on the endpoint. The agent '
'fetches the script body by digest using its collector '
'credential and runs it in the script\'s configured '
'context (system or the logged-on user).',
'targetkind': 'script',
},
'update_app': {
'label': 'Install/update application',
'description': 'Push a ShopDB-hosted installer for an application (by app '
'or by a specific version). The agent fetches the pinned '
'package by digest using its collector credential and runs '
'it silently.',
'targetkind': 'app',
},
}
@@ -85,6 +101,8 @@ COMMAND_ALLOWLIST = {
# destructive action (a remote reboot) is a heavier grant than a routine toggle.
ELEVATED_ACTION_PERMISSIONS = {
'system_restart': 'endpoints.restart',
'run_script': 'endpoints.deploy',
'update_app': 'endpoints.deploy',
}

View File

@@ -17,6 +17,16 @@ export default [
name: 'endpoints-agentkey-settings',
component: () => import('./views/AgentKeySettings.vue'),
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'endpoints' }
},
// TEMPORARY / DEV ONLY. A manual bench to exercise the update_app and
// run_script delivery path end to end while Cameron builds the real UI.
// Gated on endpoints.deploy inside the view; remove once the production
// delivery UI lands.
{
path: 'endpoints/delivery-test',
name: 'endpoints-delivery-test',
component: () => import('./views/DeliveryTestBench.vue'),
meta: { requiresAuth: true, plugin: 'endpoints' }
}
]

View File

@@ -14,6 +14,16 @@
================================================================= -->
<section class="settings-section">
<h3>Key management</h3>
<p class="key-warning">
STORE YOUR KEY SECURELY. IT IS SHOWN ONLY ONCE AND CANNOT BE RECOVERED.
</p>
<p class="field-hint">
Before you generate a key, open the <strong>ShopDB Secure Send</strong>
tool in a second window. As soon as the key appears, paste it into
Secure Send, generate a secure link, and send that link to yourself in
Teams so the site leader has a one-time, self-destructing backup. Never
paste the raw key into a chat message or a plain file.
</p>
<p class="field-hint">
The endpoint agent reports with a single managed key scoped to endpoint
telemetry only. It is separate from every other collector, so rotating
@@ -75,8 +85,19 @@
<div v-if="secret" class="card form-card secret-card">
<h4>Save this key now</h4>
<p class="field-hint">
It will not be shown again. Configure it as the agent
<code>X-API-Key</code> header value.
Anyone with this key can report as an endpoint agent for this site.
Treat it like a password. If it is lost you must regenerate, which
revokes the old key and forces every agent to be reconfigured.
</p>
<p class="field-hint">
<strong>Recommended:</strong> before you leave this page, open the
<strong>ShopDB Secure Send</strong> tool in a second window, paste
this key, generate a secure link, and send that link to yourself in
Teams. That gives the site leader a one-time, self-destructing copy to
fall back on instead of pasting the raw key into a chat or a file.
</p>
<p class="field-hint">
Configure it as the agent <code>X-API-Key</code> header value.
</p>
<p class="mono secret-value">{{ secret }}</p>
<button class="btn btn-secondary" @click="copySecret">
@@ -138,6 +159,57 @@
</div>
</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)
================================================================= -->
@@ -145,18 +217,92 @@
<h3>Build site agent</h3>
<div class="card form-card">
<p class="field-hint">
Produce an instance-locked agent executable stamped with this server's
Produce an instance-locked agent package stamped with this server's
URL, the endpoint agent key, and the reporting cadence, so it deploys
with no manual configuration on the client.
with no manual configuration on the client. The download includes the
agent, a non-secret <code>appsettings.json</code>, and an
<code>install.ps1</code> that writes the credentials to the registry
(<code>HKLM\SOFTWARE\GE\ShopDB</code>) - the key never lands in
appsettings.
</p>
<p v-if="!agentstaged" class="field-hint">
<strong>No agent binary is staged on this server.</strong> The package
will still build with config and installer, but you must drop
<code>shopdbagent.exe</code> beside them before deploying. Stage a
prebuilt agent under <code>instance/siteagent/</code> to have it ship
in the download.
</p>
<label class="field-label" for="agent-baseurl">Server base URL</label>
<input
id="agent-baseurl"
v-model="agentbaseurl"
class="form-control"
type="text"
placeholder="https://shopdb.example"
/>
<label class="field-label" for="agent-key">Endpoint agent key</label>
<input
id="agent-key"
v-model="agentcollectorkey"
class="form-control mono"
type="text"
placeholder="Paste the endpoint agent key generated above"
/>
<label class="field-label" for="agent-cadence">
Reporting cadence (seconds)
</label>
<input
id="agent-cadence"
v-model.number="agentcadenceseconds"
class="form-control"
type="number"
min="60"
/>
<label v-if="certavailable" class="checkbox-row">
<input v-model="agentbundlecert" type="checkbox" />
<span>
Bundle this server's TLS certificate and trust it on the endpoint.
Enable only for self-signed or internal-CA servers; leave off when
the server uses a publicly trusted certificate.
</span>
</label>
<label class="field-label" for="agent-cert">
Server TLS certificate (optional)
</label>
<input
id="agent-cert"
ref="agentcertinput"
class="form-control"
type="file"
accept=".crt,.pem,.cer"
@change="onAgentCertChange"
/>
<p class="field-hint">
<strong>Coming soon.</strong> The build pipeline is not wired up yet -
the C# agent runtime is still under development. Generate a key above
and configure it manually as the agent <code>X-API-Key</code> for now.
For a reverse-proxy or internal-CA server this app cannot see, upload
the server's public certificate (or issuing CA) in PEM format. It is
validated, bundled with the package, and imported into the endpoint's
Trusted Root store by <code>install.ps1</code>. Leave empty when the
server uses a publicly trusted certificate. An upload takes precedence
over the bundled server certificate above.
</p>
<button class="btn btn-secondary" type="button" disabled>
Build agent (coming soon)
</button>
<div class="button-row">
<button
class="btn btn-primary"
type="button"
:disabled="agentbusy || !agentbaseurl || !agentcollectorkey"
@click="buildSiteAgent"
>
{{ agentbusy ? 'Building...' : 'Build site agent' }}
</button>
</div>
<p v-if="agentmessage" class="field-hint">{{ agentmessage }}</p>
<p v-if="agenterror" class="field-error">{{ agenterror }}</p>
</div>
</section>
@@ -197,6 +343,26 @@ const copied = ref(false)
const message = 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('')
// Site agent generator: build an instance-locked, resident endpoint agent.
const agentbaseurl = ref('')
const agentcollectorkey = ref('')
const agentcadenceseconds = ref(900)
const agentstaged = ref(false)
const certavailable = ref(false)
const agentbundlecert = ref(false)
const agentcertinput = ref(null)
const agentcertfile = ref(null)
const agentbusy = ref(false)
const agentmessage = ref('')
const agenterror = ref('')
function formatDate(value) {
if (!value) return ''
const parsed = new Date(value)
@@ -209,6 +375,8 @@ function applyStatus(data) {
}
onMounted(async () => {
adoptbaseurl.value = window.location.origin
agentbaseurl.value = window.location.origin
try {
const response = await api.get('/endpoints/agentkey')
applyStatus(response.data.data || {})
@@ -220,6 +388,15 @@ onMounted(async () => {
} finally {
loading.value = false
}
try {
const response = await api.get('/endpoints/siteagent')
const status = response.data.data || {}
agentstaged.value = !!status.agentstaged
certavailable.value = !!status.certavailable
agentbundlecert.value = !!status.certavailable
} catch (statusError) {
console.error(statusError)
}
})
async function regenerate() {
@@ -238,6 +415,10 @@ async function regenerate() {
const data = response.data.data || {}
applyStatus(data)
secret.value = data.secret || ''
if (data.secret) {
adoptcollectorkey.value = data.secret
agentcollectorkey.value = data.secret
}
message.value = 'Endpoint agent key generated.'
} catch (regenError) {
error.value = regenError.response?.data?.data?.error?.message
@@ -276,6 +457,89 @@ async function copySecret() {
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
}
}
function onAgentCertChange(event) {
const files = event.target.files
agentcertfile.value = files && files.length ? files[0] : null
}
async function readBlobError(blob) {
try {
const parsed = JSON.parse(await blob.text())
return parsed.data?.error?.message || parsed.message
|| 'Could not build the site agent'
} catch (parseError) {
return 'Could not build the site agent'
}
}
async function buildSiteAgent() {
agentbusy.value = true
agentmessage.value = ''
agenterror.value = ''
try {
const form = new FormData()
form.append('baseurl', agentbaseurl.value)
form.append('collectorkey', agentcollectorkey.value)
form.append('cadenceseconds', agentcadenceseconds.value)
form.append('bundlecert', agentbundlecert.value ? 'true' : 'false')
if (agentcertfile.value) {
form.append('certfile', agentcertfile.value)
}
const response = await api.post(
'/endpoints/siteagent/package',
form,
{ responseType: 'blob' })
const url = window.URL.createObjectURL(response.data)
const link = document.createElement('a')
link.href = url
link.download = 'shopdb-endpoint-agent.zip'
document.body.appendChild(link)
link.click()
link.remove()
window.URL.revokeObjectURL(url)
agentmessage.value = 'Site agent built.'
} catch (buildError) {
if (buildError.response?.status === 400 && buildError.response?.data) {
agenterror.value = await readBlobError(buildError.response.data)
} else {
agenterror.value = buildError.response?.status === 403
? 'Only an administrator can build the site agent'
: 'Could not build the site agent'
}
console.error(buildError)
} finally {
agentbusy.value = false
}
}
</script>
<style scoped>
@@ -284,7 +548,27 @@ async function copySecret() {
.form-card h4 { margin-top: 1rem; margin-bottom: 0.25rem; }
.form-card h4:first-child { margin-top: 0; }
.button-row { display: flex; gap: 0.5rem; }
.checkbox-row {
display: flex;
gap: 0.5rem;
align-items: flex-start;
margin: 0.75rem 0;
font-size: 0.9rem;
}
.checkbox-row input { margin-top: 0.2rem; }
.secret-card { border: 1px solid var(--color-warning, #b8860b); }
.key-warning {
margin: 0 0 0.75rem;
padding: 0.6rem 0.75rem;
border: 2px solid #ff0000;
border-radius: 4px;
background: rgba(255, 0, 0, 0.12);
color: #ff2d2d;
font-size: 1.25rem;
font-weight: 800;
letter-spacing: 0.02em;
text-align: center;
}
.secret-value { padding: 0.5rem; background: var(--color-surface-2, #1e1e1e); }
.settings-section { margin-bottom: 2rem; }
.settings-section > h3 { margin-bottom: 0.25rem; }

View File

@@ -0,0 +1,327 @@
<template>
<div>
<div class="page-header">
<h2>Delivery Test Bench</h2>
</div>
<!--
TEMPORARY / DEV ONLY. This view exists to exercise the update_app and
run_script delivery path end to end and to take notes for the real UI
(owned by Cameron). It is gated on endpoints.deploy. Remove it once the
production delivery UI lands. Nothing here is a supported operator surface.
-->
<div class="card dev-note">
<strong>Development test vehicle.</strong>
This bench pushes an allowlisted delivery command to one endpoint and reads
back the result. The agent only runs a script name it already holds in its
local allowlist, and only installs a ShopDB-hosted package pinned by digest.
Both actions require the <code>endpoints.deploy</code> permission.
</div>
<div v-if="!candeploy" class="card load-error">
Your account lacks the <code>endpoints.deploy</code> permission, so no
delivery command can be queued. Ask an administrator to grant it.
</div>
<template v-else>
<!-- 1. Pick the target endpoint -->
<div class="card bench-section">
<h3>1. Target endpoint</h3>
<div class="bench-row">
<input
v-model="endpointSearch"
type="text"
class="form-control"
placeholder="Search hostname or asset..."
@keyup.enter="loadEndpoints"
/>
<button class="btn btn-secondary btn-sm" :disabled="busy" @click="loadEndpoints">Search</button>
</div>
<select v-model="selectedAssetId" class="form-control" :disabled="busy">
<option value="">Select an endpoint...</option>
<option v-for="row in endpoints" :key="row.assetid" :value="row.assetid">
{{ row.hostname }} (asset {{ row.assetid }})
</option>
</select>
</div>
<!-- 2. Run a ShopDB-hosted script from the library -->
<div class="card bench-section">
<h3>2. Run script</h3>
<p class="bench-hint">
Pick a script from the ShopDB script library. The agent fetches the
script body by digest with its collector credential and runs it in the
script's configured run context (system or the interactive user).
</p>
<div class="bench-row">
<select v-model="selectedScriptId" class="form-control" :disabled="busy">
<option value="">Select script...</option>
<option v-for="script in scripts" :key="script.id" :value="script.id">
{{ script.scriptname }} ({{ script.runcontext }})
<template v-if="!script.sha256"> (no body - upload first)</template>
</option>
</select>
<button
class="btn btn-warning btn-sm"
:disabled="busy || !selectedAssetId || !selectedScriptId"
@click="pushScript"
>
Push run_script
</button>
</div>
</div>
<!-- 3. Install/update an application -->
<div class="card bench-section">
<h3>3. Install/update application</h3>
<p class="bench-hint">
Pick an application, then the app-level install source (the installer
uploaded on the application itself). The agent fetches the pinned
package by digest with its collector credential and runs it silently.
</p>
<div class="bench-row">
<select v-model="selectedAppId" class="form-control" :disabled="busy" @change="loadSources">
<option value="">Select application...</option>
<option v-for="app in applications" :key="app.appid" :value="app.appid">
{{ app.appname }}
</option>
</select>
<select v-model="selectedSource" class="form-control" :disabled="busy || !selectedAppId">
<option value="">Select install source...</option>
<option v-for="source in installSources" :key="source.value" :value="source.value">
{{ source.label }}
</option>
</select>
<button
class="btn btn-warning btn-sm"
:disabled="busy || !selectedAssetId || !selectedSource"
@click="pushUpdateApp"
>
Push update_app
</button>
</div>
</div>
<!-- 4. Command results (the Web UI log pane) -->
<div class="card bench-section">
<div class="bench-row bench-row-between">
<h3>4. Command log (Web UI side)</h3>
<button class="btn btn-secondary btn-sm" :disabled="busy || !selectedAssetId" @click="loadCommands">
Refresh
</button>
</div>
<p v-if="!selectedAssetId" class="bench-hint">Select an endpoint to see its recent commands.</p>
<table v-else-if="commands.length" class="table bench-table">
<thead>
<tr>
<th>When</th>
<th>Action</th>
<th>Target</th>
<th>Status</th>
<th>Result</th>
</tr>
</thead>
<tbody>
<tr v-for="command in commands" :key="command.commandid">
<td>{{ formatWhen(command.createdat) }}</td>
<td>{{ command.action }}</td>
<td>{{ command.target || '-' }}</td>
<td><span class="status-badge" :class="statusClass(command.status)">{{ command.status }}</span></td>
<td class="result-cell">{{ command.resultmessage || '-' }}</td>
</tr>
</tbody>
</table>
<p v-else class="bench-hint">No commands recorded for this endpoint yet.</p>
</div>
</template>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { endpointsApi, applicationsApi, scriptsApi } from '@/api'
import { useAuthStore } from '@/stores/auth'
import { useToast } from '@/composables/toast'
import { apiError } from '@/utils/apiError'
const authStore = useAuthStore()
const toast = useToast()
// Gated in the UI for convenience only; every enqueue is re-checked server-side.
const candeploy = computed(() => authStore.hasPermission('endpoints.deploy'))
const busy = ref(false)
const endpointSearch = ref('')
const endpoints = ref([])
const selectedAssetId = ref('')
const scripts = ref([])
const selectedScriptId = ref('')
const applications = ref([])
const selectedAppId = ref('')
const installSources = ref([])
const selectedSource = ref('')
const commands = ref([])
onMounted(() => {
if (!candeploy.value) return
loadEndpoints()
loadApplications()
loadScripts()
})
async function loadEndpoints() {
busy.value = true
try {
const params = { perpage: 100 }
if (endpointSearch.value) params.search = endpointSearch.value
const response = await endpointsApi.list(params)
endpoints.value = response.data.data || []
} catch (error) {
toast.error(apiError(error, 'Could not load endpoints'))
endpoints.value = []
} finally {
busy.value = false
}
}
async function loadApplications() {
try {
applications.value = await applicationsApi.listAll()
} catch (error) {
applications.value = []
}
}
async function loadScripts() {
try {
scripts.value = await scriptsApi.listAll()
} catch (error) {
scripts.value = []
}
}
// Build the install-source options for the chosen application: the app-level
// source (the installer uploaded on the application itself). The wire target is
// 'app:<id>', which the server resolves into a pinned delivery descriptor.
async function loadSources() {
selectedSource.value = ''
installSources.value = []
if (!selectedAppId.value) return
const sources = []
const app = applications.value.find(row => String(row.appid) === String(selectedAppId.value))
if (app && app.installpath) {
sources.push({
value: `app:${app.appid}`,
label: app.installersha256
? 'App-level installer'
: 'App-level installer (no digest - upload package first)',
})
}
installSources.value = sources
}
async function pushScript() {
await enqueue('run_script', `script:${selectedScriptId.value}`, 'run_script')
}
async function pushUpdateApp() {
await enqueue('update_app', selectedSource.value, 'update_app')
}
async function enqueue(action, target, label) {
if (!selectedAssetId.value) return
busy.value = true
try {
await endpointsApi.enqueueCommand(selectedAssetId.value, action, target)
toast.success(`${label} queued`)
await loadCommands()
} catch (error) {
// 409 = a command is already in flight for this endpoint (at-most-one queue).
const status = error.response?.status
if (status === 409) {
toast.info('A command is already in flight for this endpoint')
} else {
toast.error(apiError(error, `${label} failed`))
}
} finally {
busy.value = false
}
}
async function loadCommands() {
if (!selectedAssetId.value) return
try {
const response = await endpointsApi.listCommands(selectedAssetId.value)
commands.value = response.data.data || []
} catch (error) {
commands.value = []
}
}
function formatWhen(iso) {
if (!iso) return '-'
const date = new Date(iso)
return Number.isNaN(date.getTime()) ? iso : date.toLocaleString()
}
function statusClass(status) {
if (status === 'succeeded') return 'ok'
if (status === 'failed') return 'bad'
if (status === 'expired') return 'bad'
if (status === 'running') return 'warn'
return ''
}
</script>
<style scoped>
.dev-note {
border-left: 3px solid var(--warning, #d6a012);
}
.bench-section h3 {
margin-top: 0;
}
.bench-row {
display: flex;
gap: 0.5rem;
margin-bottom: 0.5rem;
align-items: center;
}
.bench-row-between {
justify-content: space-between;
}
.bench-hint {
color: var(--text-light);
font-size: 0.9rem;
}
.bench-table {
width: 100%;
}
.result-cell {
max-width: 32rem;
white-space: pre-wrap;
word-break: break-word;
}
.status-badge {
padding: 0.1rem 0.5rem;
border-radius: 0.25rem;
font-size: 0.85rem;
}
.status-badge.ok {
color: var(--success, #2e7d32);
}
.status-badge.bad {
color: var(--danger, #c62828);
}
.status-badge.warn {
color: var(--warning, #d6a012);
}
.load-error {
color: var(--danger, #c62828);
}
</style>

View File

@@ -0,0 +1,94 @@
"""endpoint installed-software (SBOM) snapshot table.
Adds endpointapplications: one row per installed application discovered on an
endpoint, reported by the agent as {displayname, displayversion, publisher}.
Stored as a per-asset snapshot (replaced on each report), the same way the
services/ports inventories are kept as the newest list rather than history.
appid is groundwork for a future link to the core applications catalog (the
"Managed Applications" concept): a nullable FK with ON DELETE SET NULL so
retiring a managed Application does not delete discovered rows. No
discovery-to-catalog matcher exists yet; the column is laid down now so the
association can be added without a second migration.
Idempotent: creation checks the live schema first so a db.create_all() site
(which already built the table) re-runs clean on strict MySQL 8.
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'endpoints0004apps'
down_revision = 'endpoints0003cmdtarget'
branch_labels = None
depends_on = None
def _has_table(table_name):
from sqlalchemy import inspect
return inspect(op.get_bind()).has_table(table_name)
def upgrade():
if _has_table('endpointapplications'):
return
op.create_table(
'endpointapplications',
# BigInteger, not Integer. This is a SNAPSHOT table: every check-in
# deletes the endpoint's rows and reinserts them, so the surrogate key
# burns through values at (endpoints x installed apps) per report and
# never reuses them. A 32-bit key exhausts on MySQL - a few hundred
# endpoints reporting a few hundred apps every 15 minutes reaches 2.1
# billion inside a couple of years, and the failure is an insert that
# stops working forever. SQLite has no such ceiling, so no test would
# have caught it. Changed while the table is still unreleased.
sa.Column('endpointapplicationid',
sa.BigInteger().with_variant(sa.Integer, 'sqlite'),
nullable=False),
sa.Column('assetid', sa.Integer(), nullable=False),
sa.Column('displayname', sa.String(length=255), nullable=False),
sa.Column('displayversion', sa.String(length=128), nullable=True),
sa.Column('publisher', sa.String(length=255), nullable=True),
sa.Column('appid', sa.Integer(), nullable=True),
sa.Column('recordedat', sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint('endpointapplicationid'),
sa.ForeignKeyConstraint(
['assetid'], ['assets.assetid'],
name='fk_endpointapplications_assetid',
ondelete='CASCADE'),
sa.ForeignKeyConstraint(
['appid'], ['applications.appid'],
name='fk_endpointapplications_appid',
ondelete='SET NULL'),
)
op.create_index(
'idx_endpointapplications_assetid',
'endpointapplications', ['assetid'])
op.create_index(
'idx_endpointapplications_appid',
'endpointapplications', ['appid'])
op.create_index(
'idx_endpointapplications_displayname',
'endpointapplications', ['displayname'])
op.create_index(
'idx_endpointapplications_assetname',
'endpointapplications', ['assetid', 'displayname'])
def downgrade():
if not _has_table('endpointapplications'):
return
op.drop_index('idx_endpointapplications_assetname',
table_name='endpointapplications')
op.drop_index('idx_endpointapplications_displayname',
table_name='endpointapplications')
op.drop_index('idx_endpointapplications_appid',
table_name='endpointapplications')
op.drop_index('idx_endpointapplications_assetid',
table_name='endpointapplications')
op.drop_table('endpointapplications')

View File

@@ -1,5 +1,9 @@
"""Endpoints plugin models."""
from .endpoint import EndpointState, EndpointEvent, EndpointCommand
from .endpoint import (
EndpointState, EndpointEvent, EndpointCommand, EndpointApplication,
)
__all__ = ['EndpointState', 'EndpointEvent', 'EndpointCommand']
__all__ = [
'EndpointState', 'EndpointEvent', 'EndpointCommand', 'EndpointApplication',
]

View File

@@ -124,11 +124,24 @@ class EndpointCommand(db.Model):
# Lifecycle: pending -> running -> succeeded|failed. A command is claimed
# (pending -> running) by exactly one agent poll, then finalized by its
# result post.
# result post. A command that is claimed but whose agent never reports back
# (agent killed/uninstalled/offline mid-flight) would otherwise pin the
# at-most-one guard forever; the enqueue path reaps such rows to STATUS_EXPIRED
# so a dead in-flight command cannot wedge the endpoint's command queue.
STATUS_PENDING = 'pending'
STATUS_RUNNING = 'running'
STATUS_SUCCEEDED = 'succeeded'
STATUS_FAILED = 'failed'
STATUS_EXPIRED = 'expired'
# In-flight staleness ceilings. A RUNNING command is expected to finish in
# seconds to a couple of minutes; the generous ceiling tolerates a slow
# app install while still freeing the queue when the agent is gone. A
# PENDING command is claimed within one poll (~25s) by a live agent, so a
# long-unclaimed row means the agent is offline and the queue should not
# stay wedged behind it.
RUNNING_STALE_SECONDS = 15 * 60
PENDING_STALE_SECONDS = 60 * 60
endpointcommandid = db.Column(db.Integer, primary_key=True)
@@ -246,3 +259,89 @@ class EndpointEvent(db.Model):
'eventmessage': self.eventmessage,
'recordedat': _utciso(self.recordedat),
}
class EndpointApplication(db.Model):
"""One installed-software (SBOM) entry reported for an endpoint.
The agent enumerates both Windows Uninstall registry hives and emits a
per-item {displayname, displayversion, publisher}, de-duped on
name+version. The server stores that as a per-asset SNAPSHOT: on each
report the existing rows for the asset are replaced with the reported set,
mirroring how the services/ports inventories are kept as the newest list
rather than an append-only history. History of installs is not what
operators ask for; the current inventory is.
appid is groundwork for a future link to the core applications catalog
(the "Managed Applications" concept): a nullable, unenforced-by-match FK
that a later matcher can populate to associate a discovered install with a
managed Application row. No matching logic exists yet - the column and the
relationship are laid down here so the association can be added without a
second migration.
"""
__tablename__ = 'endpointapplications'
# BigInteger on MySQL, Integer on SQLite. This table is rewritten wholesale
# on every check-in, so the key never stops climbing and a 32-bit column
# eventually stops accepting inserts - see the migration for the arithmetic.
#
# The variant is not decoration: SQLite only autoincrements a column typed
# exactly INTEGER PRIMARY KEY, so a plain BigInteger makes every insert fail
# with a NOT NULL violation on the test database. Nothing caught that,
# because no test inserts into this table.
endpointapplicationid = db.Column(
db.BigInteger().with_variant(db.Integer, 'sqlite'), primary_key=True)
# The asset this install belongs to. Not unique: many applications per
# endpoint. CASCADE so removing the asset clears its SBOM snapshot.
assetid = db.Column(
db.Integer,
db.ForeignKey('assets.assetid', ondelete='CASCADE'),
nullable=False,
index=True,
)
asset = db.relationship('Asset', lazy='joined', viewonly=True)
# Wire fields, stored under the same lowercase/no-underscore names the agent
# emits. displayname is always present (the agent skips entries without it);
# displayversion and publisher default to '' on the agent side.
displayname = db.Column(db.String(255), nullable=False, index=True)
displayversion = db.Column(db.String(128), nullable=True)
publisher = db.Column(db.String(255), nullable=True)
# Groundwork link to the core applications catalog ("Managed Applications").
# Nullable and unpopulated for now: no discovery-to-catalog matcher is wired
# yet. SET NULL so retiring a managed Application does not delete discovered
# SBOM rows - the install is still on the machine, it is just no longer
# managed.
appid = db.Column(
db.Integer,
db.ForeignKey('applications.appid', ondelete='SET NULL'),
nullable=True,
index=True,
)
application = db.relationship('Application', lazy='select', viewonly=True)
# When this snapshot row was written (the report that produced it).
recordedat = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
__table_args__ = (
db.Index('idx_endpointapplications_assetname', 'assetid', 'displayname'),
)
def __repr__(self):
return f"<EndpointApplication {self.displayname!r} asset={self.assetid}>"
def to_dict(self):
return {
'endpointapplicationid': self.endpointapplicationid,
'assetid': self.assetid,
'displayname': self.displayname,
'displayversion': self.displayversion,
'publisher': self.publisher,
'appid': self.appid,
'recordedat': _utciso(self.recordedat),
}

View File

@@ -26,7 +26,9 @@ from shopdb.api import db
from .api import endpoints_bp
from .eventcatalog import EVENT_CATALOG
from .models import EndpointState, EndpointEvent, EndpointCommand
from .models import (
EndpointState, EndpointEvent, EndpointCommand, EndpointApplication,
)
logger = logging.getLogger(__name__)
@@ -37,6 +39,21 @@ logger = logging.getLogger(__name__)
# of a raw event firehose.
CURATED_EVENT_IDS = frozenset(EVENT_CATALOG)
# Top-level payload keys the ingest path knows about today - either persisted on
# EndpointState, appended as events, or relayed to the computers collector.
# Anything the agent sends outside this set is not stored yet; it is logged (see
# apply_collector_payload) so new agent telemetry - disks, bios, memory, etc. -
# is observable in the logs before the server grows columns for it. Keep this in
# lockstep with the keys consumed in apply_collector_payload and the computers
# handoff subset.
KNOWN_PAYLOAD_KEYS = frozenset({
'hostname', 'adopt', 'loggedinuser', 'serialnumber', 'pctype',
'machinenumber', 'vendorname', 'modelnumber', 'osname', 'ipaddress',
'agentversion', 'lastboottime', 'uptimeseconds', 'idleseconds',
'cpupercent', 'memorypercent', 'services', 'ports', 'interfaces',
'scheduledtasks', 'applications', 'events',
})
def _parse_dt(value):
"""Best-effort parse of an ISO-8601 string to naive UTC, or None."""
@@ -61,6 +78,20 @@ def _as_json(value):
return json.dumps(value, separators=(',', ':'), sort_keys=True)
def _clamp(value, length):
"""A string bounded to its column width, or None.
Ingest writes whatever an endpoint reports. A registry value longer than its
column is not a data-quality nuisance on MySQL: in strict mode it raises
Data too long and fails the whole check-in transaction, so one machine with
a malformed Uninstall entry stops reporting telemetry and events entirely.
"""
if value is None:
return None
text = str(value).strip()
return text[:length] or None
class EndpointsPlugin(BasePlugin):
"""Endpoint RMM telemetry ingest plus read/dashboard surface."""
@@ -91,7 +122,8 @@ class EndpointsPlugin(BasePlugin):
return endpoints_bp
def get_models(self) -> List[Type]:
return [EndpointState, EndpointEvent, EndpointCommand]
return [EndpointState, EndpointEvent, EndpointCommand,
EndpointApplication]
def get_permissions(self) -> List:
"""RBAC for the human-facing read routes.
@@ -129,6 +161,23 @@ class EndpointsPlugin(BasePlugin):
# remote reboot is a strictly heavier grant than a routine toggle.
('endpoints.restart',
'Restart endpoint computers (destructive)', 'endpoints'),
# Elevated grant for software delivery (run_script, update_app).
# Required IN ADDITION to endpoints.command by the enqueue route, so
# running a staged script or pushing an installer is a strictly
# heavier grant than a routine service/process toggle.
('endpoints.deploy',
'Run staged scripts and push application installers to endpoints', 'endpoints'),
# Packaging the agent is a heavier grant than deploying to one, and
# it is not the same job either: the generated ZIP carries a live
# collector key stamped into its install script, so whoever holds
# this can mint a distributable artefact containing the fleet-wide
# command-channel credential. It was require_role('admin'), which
# this codebase otherwise reserves for the few genuinely
# role-shaped checks - and a role cannot be granted to one person
# for one job the way a permission can.
('endpoints.agentpackage',
'Generate and download an agent or adoption-tool package '
'(contains a live collector key)', 'endpoints'),
# Collector service scope for the endpoint agent. A managed token
# scoped to ONLY this authorizes the endpoints collector and nothing
# else, so rotating/revoking it stops endpoint-agent traffic without
@@ -208,6 +257,19 @@ class EndpointsPlugin(BasePlugin):
'permission': 'endpoints.fleet',
'section': 'assets',
},
# TEMPORARY / DEV ONLY. A manual bench to exercise the update_app and
# run_script delivery path while Cameron builds the production UI.
# Gated on endpoints.deploy so only delivery operators see it. Remove
# this entry (and the DeliveryTestBench view/route) once the real
# delivery UI ships.
{
'name': 'Delivery test bench',
'icon': 'send',
'route': '/endpoints/delivery-test',
'position': 17,
'permission': 'endpoints.deploy',
'section': 'assets',
},
]
def init_app(self, app: Flask, db_instance) -> None:
@@ -374,6 +436,26 @@ class EndpointsPlugin(BasePlugin):
'empty': 'No network interfaces reported.',
'position': 16,
},
{
# Installed-software (SBOM) inventory. Low-priority reference
# data, not something looked at on every visit, so it starts
# collapsed and sits near the bottom. Raw discovered list for
# now: no managed-application linkage is surfaced yet.
'id': 'endpoints-applications',
'title': 'Installed software',
'assettypes': ['computer'],
'endpoint': '/api/endpoints/asset/{assetid}/applications',
'render': 'table',
'collapsible': True,
'collapsedDefault': True,
'columns': [
{'key': 'displayname', 'label': 'Name'},
{'key': 'displayversion', 'label': 'Version'},
{'key': 'publisher', 'label': 'Publisher'},
],
'empty': 'No installed software reported.',
'position': 30,
},
]
def get_dashboard_widgets(self) -> List[Dict]:
@@ -468,6 +550,24 @@ class EndpointsPlugin(BasePlugin):
'type': 'array',
'description': 'Scheduled task inventory + last run.',
},
'applications': {
'type': 'array',
'description': 'Installed-software SBOM from both '
'Windows Uninstall registry hives. Each '
'item is {displayname, displayversion, '
'publisher}, de-duped on name+version. '
'Stored as a per-asset snapshot, '
'replaced on every report.',
'items': {
'type': 'object',
'required': ['displayname'],
'properties': {
'displayname': {'type': 'string'},
'displayversion': {'type': 'string'},
'publisher': {'type': 'string'},
},
},
},
'events': {
'type': 'array',
'description': 'Curated Windows Event Log records. Only '
@@ -540,6 +640,48 @@ class EndpointsPlugin(BasePlugin):
db.session.flush()
warnings.append('adopted new computer for hostname: {!r}'.format(hostname))
def _replace_applications(self, assetid: int, applications,
warnings: List[str]) -> int:
"""Replace an asset's SBOM snapshot with the reported set.
The agent reports the full installed-software list each cycle, so the
current inventory is the newest report, not an accumulation. Delete the
asset's existing rows and insert the reported ones in the same
transaction the caller flushes. appid is left null: no
discovery-to-catalog ("Managed Applications") matcher is wired yet.
Malformed items (no displayname) are skipped, not fatal.
"""
EndpointApplication.query.filter_by(assetid=assetid).delete(
synchronize_session=False)
if not applications:
return 0
stored = 0
for item in applications:
if not isinstance(item, dict):
warnings.append('skipped non-object application entry')
continue
displayname = (item.get('displayname') or '').strip()
if not displayname:
warnings.append('skipped application with no displayname')
continue
# TRUNCATE EVERY STRING, not just the name. On MySQL in strict
# mode an over-long value raises Data too long and takes down the
# WHOLE check-in transaction, so one machine with a malformed
# Uninstall registry entry stops reporting entirely - telemetry,
# events and all - until somebody uninstalls the offender. SQLite
# does not enforce declared lengths, so no test here would ever see
# it. displayname was already clamped; these two were not.
db.session.add(EndpointApplication(
assetid=assetid,
displayname=displayname[:255],
displayversion=_clamp(item.get('displayversion'), 128),
publisher=_clamp(item.get('publisher'), 255),
))
stored += 1
return stored
def apply_collector_payload(self, payload: Dict) -> Dict:
"""Idempotently upsert one endpoint's state and append its events.
@@ -616,8 +758,36 @@ class EndpointsPlugin(BasePlugin):
if 'scheduledtasks' in payload:
state.scheduledtasksjson = _as_json(payload.get('scheduledtasks'))
# Installed-software SBOM: stored as a per-asset SNAPSHOT in its own
# table (endpointapplications), not a JSON blob on the state, so it can
# later join the applications catalog ("Managed Applications"). Only
# replace when the key is present: a report that omits applications must
# not wipe the last-known inventory. The delete + re-insert happens in
# this same transaction (the dispatcher owns the commit), so a reader
# never sees a half-written list. displayname is required; malformed
# rows are skipped rather than failing the whole report.
if 'applications' in payload:
self._replace_applications(assetid, payload.get('applications'),
warnings)
state.lastreportedat = datetime.utcnow()
# Log any keys the agent sent that the ingest path does not consume yet.
# New agent telemetry (disks, bios, memory, ...) lands here first: it is
# not persisted until the server grows columns for it, but logging the
# keys and a compact preview makes incoming telemetry visible in the
# logs so we can confirm the agent is sending it before wiring the UI.
unknown_keys = [key for key in payload if key not in KNOWN_PAYLOAD_KEYS]
if unknown_keys:
preview = {key: payload.get(key) for key in sorted(unknown_keys)}
try:
preview_text = json.dumps(preview, default=str)[:2000]
except (TypeError, ValueError):
preview_text = repr(preview)[:2000]
logger.info(
'endpoint %s sent %d unpersisted telemetry key(s): %s | preview=%s',
hostname, len(unknown_keys), sorted(unknown_keys), preview_text)
stored_events = 0
for record in (payload.get('events') or []):
try:

View File

@@ -59,7 +59,13 @@ def _product_version():
def _norm_path(p):
# Flask <int:id> / <id> -> OpenAPI {id}
return re.sub(r'<(?:[^:>]+:)?([^>]+)>', r'{\1}', p or '')
normalised = re.sub(r'<(?:[^:>]+:)?([^>]+)>', r'{\1}', p or '')
# A trailing slash in a published path is a distinct resource to a strict
# proxy (IIS), which answers the unslashed URL with a redirect a client
# behind a subpath mount cannot follow. Emit the canonical, slashless form.
if len(normalised) > 1:
normalised = normalised.rstrip('/')
return normalised
def _security(auth):

View File

@@ -0,0 +1,189 @@
# uninstall-shopdbagent.ps1
# ShopDB Endpoint Agent uninstaller. Run from an ELEVATED (admin) PowerShell on
# the endpoint. Reverses everything install-shopdbagent.ps1 and the site agent
# generator's install.ps1 create:
# - stops and deletes the Windows service
# - kills any running agent/tray processes
# - removes the tray logon Run key (64-bit and 32-bit views)
# - removes the credential hive HKLM\SOFTWARE\GE\ShopDB (both views)
# - removes the Defender path exclusion for the install directory
# - deletes the install directory and the ProgramData data/log tree
#
# Every step is best-effort and idempotent: a missing service, key, process, or
# folder is a no-op, so the script is safe to re-run and safe on a partially
# installed host. Nothing here fails the run just because an artifact is already
# gone; only an unexpected error on a present artifact surfaces as a warning.
[CmdletBinding()]
param(
[string] $InstallDir = "C:\Program Files\ShopDB\EndpointAgent",
[string] $ServiceName = "ShopDbEndpointAgent",
[string] $TrayExeName = "shopdb-endpoint-tray.exe",
# Also remove any server TLS certificate the site-agent installer imported
# into the machine Trusted Root store. Off by default because a root cert is
# shared trust material: only pull it when you are sure no other service on
# this host relies on it. Match is by subject CN substring.
[string] $RemoveRootCertMatching = "",
# Keep the ProgramData logs (C:\ProgramData\ShopDB) for post-mortem instead
# of deleting them with everything else.
[switch] $KeepLogs
)
$ErrorActionPreference = "Stop"
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
$principal = New-Object Security.Principal.WindowsPrincipal($identity)
if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
throw "This script must be run from an elevated (Run as administrator) PowerShell."
}
# Delete an HKLM value or subkey from BOTH the 64-bit and 32-bit registry views.
# The installer writes both views (a stale WOW6432Node copy would otherwise be
# read first by the agent), so a clean uninstall has to clear both.
function Remove-HklmBothViews {
param(
[Parameter(Mandatory)] [string] $SubKey,
[string] $Name
)
foreach ($view in @([Microsoft.Win32.RegistryView]::Registry64,
[Microsoft.Win32.RegistryView]::Registry32)) {
$base = [Microsoft.Win32.RegistryKey]::OpenBaseKey(
[Microsoft.Win32.RegistryHive]::LocalMachine, $view)
try {
if ($Name) {
$key = $base.OpenSubKey($SubKey, $true)
if ($key) {
try {
if ($null -ne $key.GetValue($Name)) { $key.DeleteValue($Name, $false) }
}
finally { $key.Dispose() }
}
}
else {
if ($base.OpenSubKey($SubKey)) {
$base.DeleteSubKeyTree($SubKey, $false)
}
}
}
catch {
Write-Warning " registry cleanup ($view $SubKey $Name): $($_.Exception.Message)"
}
finally { $base.Dispose() }
}
}
Write-Host "== ShopDB endpoint agent uninstall ==" -ForegroundColor Cyan
Write-Host "InstallDir : $InstallDir"
Write-Host "ServiceName : $ServiceName"
# 1. Stop and delete the Windows service.
$service = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue
if ($service) {
Write-Host "[1/6] Stopping and deleting service $ServiceName ..."
if ($service.Status -ne "Stopped") {
try { Stop-Service -Name $ServiceName -Force -ErrorAction Stop }
catch { Write-Warning " could not stop service: $($_.Exception.Message)" }
}
sc.exe delete $ServiceName | Out-Null
Start-Sleep -Seconds 2
}
else {
Write-Host "[1/6] Service $ServiceName not present; skipping."
}
# 2. Kill any lingering agent/tray processes (service host, tray, and anything
# still running out of the install directory holding files open).
Write-Host "[2/6] Stopping agent and tray processes ..."
$trayBase = [System.IO.Path]::GetFileNameWithoutExtension($TrayExeName)
foreach ($name in @("shopdbagent", $trayBase)) {
foreach ($proc in (Get-Process -Name $name -ErrorAction SilentlyContinue)) {
try { Stop-Process -Id $proc.Id -Force -ErrorAction Stop; Write-Host " killed $name (pid $($proc.Id))" }
catch { Write-Warning " could not stop $name (pid $($proc.Id)): $($_.Exception.Message)" }
}
}
foreach ($proc in (Get-Process -ErrorAction SilentlyContinue |
Where-Object { $_.Path -and $_.Path.StartsWith($InstallDir, [StringComparison]::OrdinalIgnoreCase) })) {
try { Stop-Process -Id $proc.Id -Force -ErrorAction Stop; Write-Host " killed $($proc.ProcessName) from install dir (pid $($proc.Id))" }
catch { Write-Warning " could not stop pid $($proc.Id): $($_.Exception.Message)" }
}
Start-Sleep -Seconds 1
# 3. Remove the tray logon Run key entry (both views) and the credential hive.
Write-Host "[3/6] Removing registry entries ..."
Remove-HklmBothViews -SubKey "SOFTWARE\Microsoft\Windows\CurrentVersion\Run" -Name "ShopDbEndpointTray"
Remove-HklmBothViews -SubKey "SOFTWARE\GE\ShopDB"
# Drop the parent GE key only if we emptied it (some sites nest siblings under
# GE such as SFLD; never tree-delete a key we do not own).
foreach ($view in @([Microsoft.Win32.RegistryView]::Registry64,
[Microsoft.Win32.RegistryView]::Registry32)) {
$base = [Microsoft.Win32.RegistryKey]::OpenBaseKey(
[Microsoft.Win32.RegistryHive]::LocalMachine, $view)
try {
$ge = $base.OpenSubKey("SOFTWARE\GE")
if ($ge -and $ge.SubKeyCount -eq 0 -and $ge.ValueCount -eq 0) {
$ge.Dispose()
$base.DeleteSubKey("SOFTWARE\GE", $false)
Write-Host " removed empty SOFTWARE\GE ($view)"
}
elseif ($ge) { $ge.Dispose() }
}
catch { Write-Warning " GE key cleanup ($view): $($_.Exception.Message)" }
finally { $base.Dispose() }
}
# 4. Remove the Defender path exclusion the installer added for the install dir.
Write-Host "[4/6] Removing Defender exclusion ..."
if (Get-Command Remove-MpPreference -ErrorAction SilentlyContinue) {
try {
Remove-MpPreference -ExclusionPath $InstallDir -ErrorAction Stop
Write-Host " removed Defender exclusion for $InstallDir"
}
catch { Write-Warning " could not remove Defender exclusion (tamper protection or policy?): $($_.Exception.Message)" }
}
else {
Write-Host " Defender cmdlets not present; skipping."
}
# 5. Optionally remove an imported server root certificate.
if (-not [string]::IsNullOrWhiteSpace($RemoveRootCertMatching)) {
Write-Host "[5/6] Removing Trusted Root certs matching '$RemoveRootCertMatching' ..."
$matches = Get-ChildItem Cert:\LocalMachine\Root -ErrorAction SilentlyContinue |
Where-Object { $_.Subject -like "*$RemoveRootCertMatching*" }
foreach ($cert in $matches) {
try {
Remove-Item -Path $cert.PSPath -Force -ErrorAction Stop
Write-Host " removed $($cert.Subject) [$($cert.Thumbprint)]"
}
catch { Write-Warning " could not remove $($cert.Thumbprint): $($_.Exception.Message)" }
}
if (-not $matches) { Write-Host " no matching root certs found." }
}
else {
Write-Host "[5/6] Skipping root-cert removal (pass -RemoveRootCertMatching to enable)."
}
# 6. Delete the install directory and the ProgramData data/log tree.
Write-Host "[6/6] Removing files ..."
if (Test-Path $InstallDir) {
try { Remove-Item -Path $InstallDir -Recurse -Force -ErrorAction Stop; Write-Host " removed $InstallDir" }
catch { Write-Warning " could not remove $InstallDir (a file may still be locked): $($_.Exception.Message)" }
}
else { Write-Host " $InstallDir not present." }
$dataDir = Join-Path $env:ProgramData "ShopDB"
if (-not $KeepLogs -and (Test-Path $dataDir)) {
try { Remove-Item -Path $dataDir -Recurse -Force -ErrorAction Stop; Write-Host " removed $dataDir" }
catch { Write-Warning " could not remove $dataDir : $($_.Exception.Message)" }
}
elseif ($KeepLogs) { Write-Host " keeping $dataDir (-KeepLogs)." }
# Also prune an empty C:\Program Files\ShopDB parent left behind.
$parent = Split-Path -Parent $InstallDir
if ((Test-Path $parent) -and -not (Get-ChildItem -Path $parent -Force -ErrorAction SilentlyContinue)) {
try { Remove-Item -Path $parent -Force -ErrorAction Stop; Write-Host " removed empty $parent" } catch { }
}
Write-Host "`nUninstall complete." -ForegroundColor Green
Write-Host "Verify with: Get-Service $ServiceName ; Get-Process shopdbagent,$trayBase ; reg query 'HKLM\SOFTWARE\GE\ShopDB'"

View File

@@ -93,7 +93,12 @@ from .plugins import plugin_manager
# as a heading - "Endpoints with crashes or shutdowns" - which as a row badge
# ran wider than the hostname it qualified. Optional, defaults to the title, so
# a plugin that does not declare one is unaffected. Additive field, minor bump.
__contract_version__ = '0.26.0'
# 0.27.0: added Script to shopdb.api so a plugin can read the ShopDB-hosted
# script library that backs endpoint run_script delivery without importing core
# model internals. The endpoints plugin resolves a delivered script by name and
# serves its body by digest to the agent. Additive name on the surface, minor
# bump.
__contract_version__ = '0.27.0'
# Product release version (see ADR-007). The product version and the
# plugin-contract version above are distinct series with independent
@@ -261,6 +266,7 @@ CORE_BLUEPRINT_NAMES = (
'dashboard',
'dashboarddefaults',
'applications',
'scripts',
'supportteams',
'search',
'reports',

View File

@@ -41,6 +41,7 @@ from shopdb.core.models import (
AuditLog,
Application,
AppVersion,
Script,
OperatingSystem,
AssetRelationship,
RelationshipType,
@@ -313,6 +314,7 @@ __all__ = [
'AuditLog',
'Application',
'AppVersion',
'Script',
'OperatingSystem',
'AssetRelationship',
'RelationshipType',

View File

@@ -14,6 +14,7 @@ from .operatingsystems import operatingsystems_bp
from .dashboard import dashboard_bp
from .dashboarddefaults import dashboarddefaults_bp
from .applications import applications_bp
from .scripts import scripts_bp
from .supportteams import supportteams_bp
from .search import search_bp
from .reports import reports_bp
@@ -42,6 +43,7 @@ __all__ = [
'dashboard_bp',
'dashboarddefaults_bp',
'applications_bp',
'scripts_bp',
'supportteams_bp',
'search_bp',
'reports_bp',

View File

@@ -1,6 +1,7 @@
"""Applications API endpoints."""
import glob
import hashlib
import os
from flask import Blueprint, request, current_app
@@ -47,6 +48,20 @@ def _apppackage_dir():
return os.path.join(current_app.instance_path, 'applicationpackages')
def _sha256_of(stream):
"""Hex SHA-256 of an upload stream, read in chunks, cursor left rewound.
The agent pins a version to exact bytes; computing the digest here means
the server is the one source of truth for what those bytes are.
"""
digest = hashlib.sha256()
stream.seek(0)
for chunk in iter(lambda: stream.read(1048576), b''):
digest.update(chunk)
stream.seek(0)
return digest.hexdigest()
def _replace_upload(directory, stem, ext, upload):
"""Save one file per application, replacing any prior extension.
@@ -322,7 +337,9 @@ def create_version(app_id: int):
appid=app_id,
version=data['version'],
releasedate=data.get('releasedate'),
notes=data.get('notes')
notes=data.get('notes'),
sha256=data.get('sha256'),
silentargs=data.get('silentargs')
)
db.session.add(version)
@@ -604,16 +621,37 @@ def upload_application_package(app_id: int):
filename = _replace_upload(_apppackage_dir(), f'application-{app_id}', ext, upload)
app.installpath = '{}{}'.format(APP_PACKAGE_URL_PREFIX, filename)
# The endpoint installer push pins bytes by digest; compute it once here so
# the server, not the agent, decides what the correct payload is. Stamp it
# on the app itself so an update_app push can resolve an install source by
# app alone (no AppVersion row). If the caller also names a version, stamp
# it there too; otherwise return it so the version-edit flow can persist it.
sha256 = _sha256_of(upload.stream)
app.installersha256 = sha256
stamped_version = None
appversionid = request.form.get('appversionid', type=int)
if appversionid:
stamped_version = db.session.get(AppVersion, appversionid)
if not stamped_version or stamped_version.appid != app_id:
return error_response(
ErrorCodes.VALIDATION_ERROR,
'appversionid {} does not belong to this application'.format(appversionid))
stamped_version.sha256 = sha256
db.session.commit()
AuditLog.log('updated', 'Application', entityid=app_id,
entityname=app.appname,
changes={'installpath': {'new': app.installpath},
'uploadedfilename': {'new': upload.filename},
'sha256': {'new': sha256},
'bytes': {'new': size}})
db.session.commit()
return success_response(
{**app.to_dict(), 'uploadedfilename': upload.filename, 'bytes': size},
{**app.to_dict(), 'uploadedfilename': upload.filename, 'bytes': size,
'sha256': sha256,
'appversionid': stamped_version.appversionid if stamped_version else None},
message='Installer uploaded')

View File

@@ -38,6 +38,19 @@ def _count_by_type(assettype):
return query.count()
def _inactive_excluded():
"""Counted-type assets held back by isactive, i.e. soft-deleted.
Reported so the page can account for itself. A total that silently drops
rows is the reason somebody compares it with a list page and finds a
difference they cannot explain.
"""
return (db.session.query(Asset).join(AssetType)
.filter(Asset.isactive == False,
AssetType.assettype.in_(COUNTEDTYPES))
.count())
COUNTEDTYPES = ('machine', 'computer', 'network_device', 'printer',
'measuring_tool')
@@ -69,6 +82,10 @@ def _countedassets():
@require_permission('assets.view')
def get_dashboard():
"""Get dashboard summary data (asset-based)."""
# Imported here, as in the helpers above: the dualpath service reaches back
# into models, so a module-level import is circular.
from shopdb.core.services.dualpath import dualpath_single_machine_enabled
machine_count = _count_by_type('machine')
pc_count = _count_by_type('computer')
network_count = _count_by_type('network_device')
@@ -99,6 +116,22 @@ def get_dashboard():
'totalmeasuringtool': measuringtool_count,
'activeassets': status_dict.get('In Use', 0),
'inrepair': status_dict.get('In Repair', 0),
# What the totals actually cover, so the page can say so rather than
# leaving a reader to work it out from a number that surprised them.
# Every count here filters isactive, and the dual-path collapse is a
# per-site toggle - asserting it unconditionally would be false at a
# site that has it off.
'countsactiveonly': True,
'dualpathcollapsed': bool(dualpath_single_machine_enabled()),
# Every status, not just the two that had callers. The counts were
# already built for the rollup above and then thrown away, so a reader
# could see "In Use" and had no way to learn where the rest went.
'statuses': status_dict,
# A DIFFERENT thing to a status, and the one that makes a total
# disagree with a list page. isactive is the soft-delete flag: these
# rows are excluded from every count above, so they are in no status
# bucket and cannot be found by adding the buckets up.
'inactiveexcluded': _inactive_excluded(),
# Structured data
'counts': {
'machines': machine_count,

206
shopdb/core/api/scripts.py Normal file
View File

@@ -0,0 +1,206 @@
"""Script library API endpoints.
The ShopDB-hosted script library backs the endpoint run_script delivery path: a
PowerShell script is authored/uploaded once here, and the endpoint agent fetches
its body by digest on push (the same model as application installer delivery).
The body never crosses the command wire and is never pre-staged on the endpoint;
the agent downloads it with its collector credential and verifies sha256.
Gated on the applications.* permissions - scripts are software delivery like
apps, so they ride the same operator grant rather than a separate permission.
"""
import os
from flask import Blueprint, request, current_app
from shopdb.extensions import db
from shopdb.core.models import Script, RUNCONTEXTS, RUNCONTEXT_SYSTEM, AuditLog
from shopdb.utils.responses import (
success_response,
error_response,
ErrorCodes,
)
from shopdb.utils.authz import require_permission
from shopdb.utils.uploads import send_upload
from shopdb.core.api.applications import _sha256_of, _replace_upload
# Scripts are PowerShell for the endpoint agent. The guard is a sanity check on
# the upload, not a security boundary - the download route always sends as an
# attachment and the agent runs it explicitly.
SCRIPT_EXTENSIONS = {'.ps1'}
SCRIPT_URL_PREFIX = '/api/scripts/body/'
# A delivery script is small; cap well under the installer limit.
MAX_SCRIPT_BYTES = 1 * 1024 * 1024
def _script_dir():
return os.path.join(current_app.instance_path, 'scripts')
scripts_bp = Blueprint('scripts', __name__)
@scripts_bp.route('', methods=['GET'])
@require_permission('applications.view')
def list_scripts():
"""List scripts (active by default)."""
query = Script.query
if request.args.get('active', 'true').lower() != 'false':
query = query.filter(Script.isactive == True) # noqa: E712
if search := request.args.get('search'):
query = query.filter(Script.scriptname.ilike(f'%{search}%'))
rows = query.order_by(Script.scriptname).all()
return success_response([row.to_dict() for row in rows])
@scripts_bp.route('/<int:script_id>', methods=['GET'])
@require_permission('applications.view')
def get_script(script_id: int):
script = db.session.get(Script, script_id)
if not script:
return error_response(ErrorCodes.NOT_FOUND, 'Script not found', http_code=404)
return success_response(script.to_dict())
def _validate_runcontext(value):
"""Normalize/validate a runcontext, defaulting to system when absent."""
if value is None:
return RUNCONTEXT_SYSTEM, None
value = str(value).strip().lower()
if value not in RUNCONTEXTS:
return None, 'runcontext must be one of: {}'.format(', '.join(sorted(RUNCONTEXTS)))
return value, None
@scripts_bp.route('', methods=['POST'])
@require_permission('applications.create')
def create_script():
"""Create a script catalog entry (body uploaded separately)."""
data = request.get_json(silent=True) or {}
name = (data.get('scriptname') or '').strip()
if not name:
return error_response(ErrorCodes.VALIDATION_ERROR, 'scriptname is required')
if Script.query.filter_by(scriptname=name).first():
return error_response(ErrorCodes.VALIDATION_ERROR,
f'A script named {name!r} already exists')
runcontext, err = _validate_runcontext(data.get('runcontext'))
if err:
return error_response(ErrorCodes.VALIDATION_ERROR, err)
script = Script(
scriptname=name,
scriptdescription=data.get('scriptdescription'),
runcontext=runcontext,
)
db.session.add(script)
db.session.commit()
AuditLog.log('created', 'Script', entityid=script.scriptid,
entityname=name,
changes={'runcontext': {'new': runcontext}})
db.session.commit()
return success_response(script.to_dict(), message='Script created', http_code=201)
@scripts_bp.route('/<int:script_id>', methods=['PUT'])
@require_permission('applications.edit')
def update_script(script_id: int):
script = db.session.get(Script, script_id)
if not script:
return error_response(ErrorCodes.NOT_FOUND, 'Script not found', http_code=404)
data = request.get_json(silent=True) or {}
if 'scriptname' in data:
name = (data.get('scriptname') or '').strip()
if not name:
return error_response(ErrorCodes.VALIDATION_ERROR, 'scriptname cannot be empty')
clash = Script.query.filter(Script.scriptname == name,
Script.scriptid != script_id).first()
if clash:
return error_response(ErrorCodes.VALIDATION_ERROR,
f'A script named {name!r} already exists')
script.scriptname = name
if 'scriptdescription' in data:
script.scriptdescription = data.get('scriptdescription')
if 'runcontext' in data:
runcontext, err = _validate_runcontext(data.get('runcontext'))
if err:
return error_response(ErrorCodes.VALIDATION_ERROR, err)
script.runcontext = runcontext
if 'isactive' in data:
script.isactive = bool(data.get('isactive'))
db.session.commit()
return success_response(script.to_dict(), message='Script updated')
@scripts_bp.route('/<int:script_id>', methods=['DELETE'])
@require_permission('applications.delete')
def delete_script(script_id: int):
script = db.session.get(Script, script_id)
if not script:
return error_response(ErrorCodes.NOT_FOUND, 'Script not found', http_code=404)
name = script.scriptname
db.session.delete(script)
db.session.commit()
AuditLog.log('deleted', 'Script', entityid=script_id, entityname=name)
db.session.commit()
return success_response(None, message='Script deleted')
@scripts_bp.route('/<int:script_id>/body', methods=['POST'])
@require_permission('applications.edit')
def upload_script_body(script_id: int):
"""Upload (or replace) the .ps1 body for a script.
multipart/form-data: file=<script.ps1>. Stored as script-<id>.ps1 and the
digest is stamped so the endpoint delivery path can pin exact bytes.
"""
script = db.session.get(Script, script_id)
if not script:
return error_response(ErrorCodes.NOT_FOUND, 'Script not found', http_code=404)
upload = request.files.get('file')
if not upload or not upload.filename:
return error_response(ErrorCodes.VALIDATION_ERROR, 'No file provided')
ext = os.path.splitext(upload.filename)[1].lower()
if ext not in SCRIPT_EXTENSIONS:
return error_response(
ErrorCodes.VALIDATION_ERROR,
'Unsupported script type {}. Allowed: {}'.format(
ext, ', '.join(sorted(SCRIPT_EXTENSIONS))))
upload.stream.seek(0, os.SEEK_END)
size = upload.stream.tell()
upload.stream.seek(0)
if size > MAX_SCRIPT_BYTES:
return error_response(
ErrorCodes.VALIDATION_ERROR,
'Script is {:.0f}KB; the limit is {:.0f}KB.'.format(
size / 1024, MAX_SCRIPT_BYTES / 1024))
filename = _replace_upload(_script_dir(), f'script-{script_id}', ext, upload)
script.filename = filename
script.sha256 = _sha256_of(upload.stream)
db.session.commit()
AuditLog.log('updated', 'Script', entityid=script_id,
entityname=script.scriptname,
changes={'filename': {'new': filename},
'sha256': {'new': script.sha256},
'bytes': {'new': size}})
db.session.commit()
return success_response(
{**script.to_dict(), 'uploadedfilename': upload.filename, 'bytes': size},
message='Script body uploaded')
@scripts_bp.route('/body/<path:filename>', methods=['GET'])
@require_permission('applications.view')
def download_script_body(filename: str):
"""Operator-facing script body download (JWT/RBAC).
The endpoint agent uses the collector-authenticated plugin route instead;
this one is for humans reviewing what a script does.
"""
return send_upload(_script_dir(), filename, as_attachment=True)

View File

@@ -16,6 +16,8 @@ from .communication import Communication, CommunicationType
from .user import (User, Role, Permission, full_permission_catalog,
guest_grantable_permissions)
from .application import Application, AppVersion
from .script_library import (Script, RUNCONTEXTS, RUNCONTEXT_SYSTEM,
RUNCONTEXT_USER)
from .supportteam import SupportTeam, SupportTeamContact
from .setting import Setting
from .auditlog import AuditLog
@@ -62,6 +64,11 @@ __all__ = [
# Applications
'Application',
'AppVersion',
# Script library (endpoint run_script delivery)
'Script',
'RUNCONTEXTS',
'RUNCONTEXT_SYSTEM',
'RUNCONTEXT_USER',
# Support teams
'SupportTeam',
'SupportTeamContact',

View File

@@ -34,6 +34,11 @@ class Application(BaseModel):
comment='Required on all PCs (drives the software-compliance report)'
)
image = db.Column(db.String(255))
# Digest of the app-level uploaded installer (installpath). Lets the
# endpoint update_app push resolve an install source by app alone, with no
# AppVersion row: the agent fetches the pinned bytes and verifies this hash.
# Stamped on package upload; null when installpath is a site share path.
installersha256 = db.Column(db.String(64))
# Relationships
supportteam = db.relationship('SupportTeam', back_populates='applications')
@@ -70,6 +75,12 @@ class AppVersion(db.Model):
notes = db.Column(db.String(255))
dateadded = db.Column(db.DateTime, default=_utcnow)
isactive = db.Column(db.Boolean, default=True)
# Delivery metadata for the endpoint installer push. sha256 pins the exact
# bytes the agent must fetch (warn-not-block for now; hardened later);
# silentargs carries this version's unattended-install switches because
# homegrown EXEs vary by developer (MSI is uniform via msiexec).
sha256 = db.Column(db.String(64))
silentargs = db.Column(db.String(255))
# Relationships
application = db.relationship('Application', back_populates='versions')
@@ -88,7 +99,9 @@ class AppVersion(db.Model):
'releasedate': self.releasedate.isoformat() if self.releasedate else None,
'notes': self.notes,
'dateadded': self.dateadded.isoformat() + 'Z' if self.dateadded else None,
'isactive': self.isactive
'isactive': self.isactive,
'sha256': self.sha256,
'silentargs': self.silentargs
}
def __repr__(self):

View File

@@ -0,0 +1,43 @@
"""ShopDB-hosted script library for endpoint run_script delivery.
A script is authored/uploaded once here, and the endpoint agent fetches its
body by digest on push (mirroring the application installer delivery path). The
body never lives on the endpoint ahead of time and never crosses the command
wire; the agent downloads it with its collector credential and verifies sha256.
"""
from shopdb.extensions import db
from .base import BaseModel
# Execution context for a delivered script. SYSTEM is the service account the
# agent already runs as (no user session, cannot see mapped drives); USER runs
# in the interactive logged-on user's session. Default SYSTEM stays fail-safe.
RUNCONTEXT_SYSTEM = 'system'
RUNCONTEXT_USER = 'user'
RUNCONTEXTS = (RUNCONTEXT_SYSTEM, RUNCONTEXT_USER)
class Script(BaseModel):
"""ShopDB-hosted PowerShell script the endpoint agent runs on push."""
__tablename__ = 'scripts'
scriptid = db.Column(db.Integer, primary_key=True)
scriptname = db.Column(db.String(100), unique=True, nullable=False)
scriptdescription = db.Column(db.String(255))
# Stored file name under the scripts upload directory (script-<id>.ps1). The
# body is served by the collector-authenticated download route.
filename = db.Column(db.String(255))
# Digest of the stored body; the agent verifies this before running.
sha256 = db.Column(db.String(64))
# 'system' or 'user' (see RUNCONTEXTS). Per-script so a drive-mapping script
# can target the user session while a machine-wide script runs as SYSTEM.
runcontext = db.Column(db.String(16), default=RUNCONTEXT_SYSTEM, nullable=False)
def to_dict(self):
result = super().to_dict()
# hasbody lets the UI disable pushing a script with no uploaded file.
result['hasbody'] = bool(self.filename and self.sha256)
return result
def __repr__(self):
return f"<Script {self.scriptname}>"

View File

@@ -53,7 +53,7 @@ PLUGIN_TABLE_OWNERS: dict[str, Iterable[str]] = {
'configurationpolicies', 'configurationpolicyactions',
'businesspolicies', 'businesspolicyitems'),
'employees': ('directoryemployees',),
'endpoints': ('endpointstates', 'endpointevents', 'endpointcommands'),
'endpoints': ('endpointstates', 'endpointevents', 'endpointcommands', 'endpointapplications'),
'geenforce': ('manifestscopes', 'manifestentries', 'manifestentrypctypes',
'manifestentryhostnames', 'manifestentrymachinenumbers',
'manifestinusechecks', 'manifestinusecheckprocesses',

View File

@@ -0,0 +1,42 @@
"""AppVersion installer-delivery metadata: sha256 + silentargs.
These two per-version fields feed the endpoint installer push: sha256 pins the
exact bytes the agent fetches, and silentargs carries the version's unattended-
install switches. The API must accept and echo both, and the model must
serialize them.
"""
def _create_app(client, auth_headers, appname='DeliveryApp'):
resp = client.post('/api/applications',
json={'appname': appname, 'isinstallable': True},
headers=auth_headers)
return resp.get_json()['data']['appid']
def test_create_version_accepts_and_echoes_delivery_metadata(client, db, auth_headers):
"""POST versions with sha256 + silentargs; the response carries both back."""
appid = _create_app(client, auth_headers)
digest = 'a' * 64
resp = client.post(f'/api/applications/{appid}/versions',
json={'version': '1.2.3',
'sha256': digest,
'silentargs': '/S /v/qn'},
headers=auth_headers)
assert resp.status_code == 201
data = resp.get_json()['data']
assert data['sha256'] == digest
assert data['silentargs'] == '/S /v/qn'
def test_version_delivery_metadata_defaults_to_null(client, db, auth_headers):
"""A version created without delivery metadata serializes both as null,
so a version that predates the push feature reads cleanly."""
appid = _create_app(client, auth_headers, appname='NoMetaApp')
resp = client.post(f'/api/applications/{appid}/versions',
json={'version': '0.9.0'},
headers=auth_headers)
assert resp.status_code == 201
data = resp.get_json()['data']
assert data['sha256'] is None
assert data['silentargs'] is None

View File

@@ -83,6 +83,15 @@ PUBLIC_ENDPOINTS = {
# requesting user. Same reasoning as endpoints.report_command_result in
# tests/test_core/test_authz.py.
'endpoints.claim_next_command',
# The endpoint agent downloads an installer package for an update_app
# command. Authenticated inside the handler by the endpoints collector API
# key - the same shape as claim_next_command - because a bay has no user
# session. Queueing the update_app command is the actual authority and stays
# gated on endpoints.deploy, audited with the requesting user.
'endpoints.download_app_package',
'endpoints.download_app_package_by_app',
'endpoints.download_script_body',
}
# Reads that need a LOGIN but no named permission, because the handler decides

View File

@@ -28,9 +28,15 @@ DOCS = REPO / 'docs'
# test_there_are_docs_to_check exists to catch. Distinguishing the two matters:
# collapsing them either breaks CI on the published mirror (this module failed
# there for exactly this reason) or quietly disables the guard everywhere.
#
# The test is docs/*.md PROSE, not docs/ existing. docs/openapi.json is tracked
# so the app can serve it at runtime, which means docs/ exists even on the
# published mirror - keying off the directory made this module run there against
# a prose-less tree and fail. A published checkout is one with no authored
# prose, matching the canonical definition in scripts/check-gates-ran.sh.
pytestmark = pytest.mark.skipif(
not DOCS.is_dir(),
reason='no docs/ in this checkout - it is excluded from publication and lives in the wiki')
not any(DOCS.glob('*.md')),
reason='no docs/ prose in this checkout - it is excluded from publication and lives in the wiki')
# Kept in step with the scrub list in tools/export-github.sh. Two mechanisms for
# one rule is not ideal, but the export scrubs a tree it is about to commit while

View File

@@ -23,9 +23,14 @@ import pytest
REPO = Path(__file__).resolve().parents[1]
DOCS = REPO / 'docs'
# docs/openapi.json is tracked so the app can serve it at runtime, so docs/
# exists even on the published mirror where the authored prose is not. Keying
# the skip off the directory made this module run on the mirror against a
# prose-less tree and fail. A published checkout is one with no docs/*.md prose,
# matching the canonical definition in scripts/check-gates-ran.sh.
pytestmark = pytest.mark.skipif(
not DOCS.is_dir(),
reason='no docs/ in this checkout - it is excluded from publication')
not any(DOCS.glob('*.md')),
reason='no docs/ prose in this checkout - it is excluded from publication')
# The map is generated and the changelog records history; both are allowed to
# carry literal versions. CONTRACT-STABILITY's table is a history of what each

View File

@@ -0,0 +1,69 @@
"""Regression tests for the endpoint command-queue staleness reaper.
A claimed command whose agent never reports back (agent killed, uninstalled, or
offline mid-flight) would otherwise pin the at-most-one-in-flight queue forever,
so every operator action against that endpoint returns 409 "already in flight".
The enqueue path reaps such rows to STATUS_EXPIRED so the queue self-heals.
"""
from datetime import timedelta
def test_reap_stale_commands_frees_a_wedged_queue(db):
from plugins.endpoints.models import EndpointCommand
from plugins.endpoints.api.routes import _reap_stale_commands, _naive_utcnow
now = _naive_utcnow()
asset_id = 4242
stale_running = EndpointCommand(
assetid=asset_id, action='mtconnect_start',
status=EndpointCommand.STATUS_RUNNING,
createdat=now - timedelta(hours=2),
claimedat=now - timedelta(hours=2))
stale_pending = EndpointCommand(
assetid=asset_id, action='mtconnect_stop',
status=EndpointCommand.STATUS_PENDING,
createdat=now - timedelta(hours=2))
fresh_running = EndpointCommand(
assetid=asset_id + 1, action='mtconnect_start',
status=EndpointCommand.STATUS_RUNNING,
createdat=now, claimedat=now)
db.session.add_all([stale_running, stale_pending, fresh_running])
db.session.commit()
reaped = _reap_stale_commands(asset_id)
db.session.commit()
reaped_ids = {command.endpointcommandid for command in reaped}
assert stale_running.endpointcommandid in reaped_ids
assert stale_pending.endpointcommandid in reaped_ids
assert stale_running.status == EndpointCommand.STATUS_EXPIRED
assert stale_pending.status == EndpointCommand.STATUS_EXPIRED
assert stale_running.completedat is not None
assert stale_running.resultmessage
# A live, recently claimed command on another endpoint is untouched.
assert fresh_running.status == EndpointCommand.STATUS_RUNNING
def test_reap_leaves_a_recently_claimed_running_command_in_flight(db):
from plugins.endpoints.models import EndpointCommand
from plugins.endpoints.api.routes import _reap_stale_commands, _naive_utcnow
now = _naive_utcnow()
asset_id = 777
# Claimed a minute ago: a slow-but-alive command must not be reaped.
running = EndpointCommand(
assetid=asset_id, action='mtconnect_start',
status=EndpointCommand.STATUS_RUNNING,
createdat=now - timedelta(minutes=1),
claimedat=now - timedelta(minutes=1))
db.session.add(running)
db.session.commit()
reaped = _reap_stale_commands(asset_id)
db.session.commit()
assert reaped == []
assert running.status == EndpointCommand.STATUS_RUNNING

View File

@@ -0,0 +1,50 @@
"""No test file may sit where pytest will not collect it.
pytest.ini sets `testpaths = tests`, so a test file anywhere else is never run
by `pytest` and never run in CI. It still passes review, still gets maintained,
and still reports nothing.
That is not hypothetical. plugins/endpoints/tests/test_plugin.py arrived with a
merge carrying 55 lines of delivery assertions and was collected ZERO times; run
directly, five of its tests failed. plugins/printedparts/tests/test_plugin.py had
been sitting uncollected for longer. Both are now under tests/.
The failure mode is the worst kind: the suite is green, the coverage is imaginary,
and the authorisation on a route can be deleted without anything going red.
"""
from pathlib import Path
REPO = Path(__file__).resolve().parents[1]
# Where pytest actually looks (pytest.ini: testpaths = tests).
COLLECTED = REPO / 'tests'
# Trees that legitimately hold test-shaped files pytest must not collect:
# vendored dependencies, build output, and the frontend's own vitest specs.
IGNORED = ('node_modules', 'venv', '.git', 'dist', '__pycache__',
'site-packages', '.plugins-staged', 'frontend')
# The Windows installer stages a whole copy of the app under here. It is build
# output, not source, and the export excludes it - caught by this test on its
# first run, which is the sort of thing the ignore list exists for.
IGNORED_TREES = ('deploy/windows/installer/bundle',)
def test_every_test_file_lives_where_pytest_looks():
stray = []
for path in REPO.rglob('test_*.py'):
if any(part in IGNORED for part in path.parts):
continue
relative = path.relative_to(REPO).as_posix()
if any(relative.startswith(tree) for tree in IGNORED_TREES):
continue
if COLLECTED in path.parents:
continue
stray.append(path.relative_to(REPO))
assert not stray, (
'These test files are never collected, because pytest.ini limits\n'
'collection to tests/. They pass review and report nothing:\n '
+ '\n '.join(str(p) for p in sorted(stray))
+ '\n\nMove them under tests/ (tests/test_plugins/ for plugin tests).'
)

View File

@@ -91,9 +91,10 @@ EXPECTED_HEAD_REVISION['notifications'] = 'notifications0005boardorder'
EXPECTED_HEAD_REVISION['warranty'] = 'warranty0002proof'
# endpoints is a NEW post-cutover plugin: its 0001 genuinely creates the
# endpointstates + endpointevents tables the core chain never owned, 0002
# adds the endpointcommands queue plus the MTConnect state columns, and 0003
# adds the generic command target/targetkind columns.
EXPECTED_HEAD_REVISION['endpoints'] = 'endpoints0003cmdtarget'
# adds the endpointcommands queue plus the MTConnect state columns, 0003
# adds the generic command target/targetkind columns, and 0004 adds the
# endpointapplications SBOM snapshot table.
EXPECTED_HEAD_REVISION['endpoints'] = 'endpoints0004apps'
# Plugins built after the cutover: their 0001 baseline really creates tables the
# core chain never owned.

View File

@@ -0,0 +1,87 @@
"""The installed-software snapshot table: it must actually accept inserts.
This table is rewritten wholesale on every endpoint check-in - delete the
asset's rows, insert the current inventory - so its surrogate key climbs at
(endpoints x installed apps) per report and never reuses a value. On MySQL a
32-bit key exhausts and inserts stop working permanently.
Widening it to BigInteger broke every insert on SQLite, because SQLite only
autoincrements a column typed exactly INTEGER PRIMARY KEY. Neither fault was
caught by the suite, for the same reason: nothing inserted into this table at
all. This does.
"""
from shopdb.api import db
from plugins.endpoints.models.endpoint import EndpointApplication
def test_rows_insert_and_the_key_autoincrements(app):
with app.app_context():
db.create_all()
first = EndpointApplication(assetid=1, displayname='Probe', displayversion='1.0')
second = EndpointApplication(assetid=1, displayname='Probe 2', displayversion='2.0')
db.session.add_all([first, second])
db.session.commit()
assert first.endpointapplicationid is not None
assert second.endpointapplicationid is not None
assert second.endpointapplicationid != first.endpointapplicationid
def test_the_key_is_wide_enough_for_a_table_that_is_rewritten_every_cycle(app):
"""BIGINT on MySQL, INTEGER on SQLite - both halves matter.
Dropping the variant breaks SQLite inserts; dropping the BigInteger breaks
MySQL years later, which is worse because it is silent until it is not.
"""
from sqlalchemy.dialects import mysql, sqlite
with app.app_context():
column = EndpointApplication.__table__.c.endpointapplicationid
assert 'BIGINT' in str(column.type.compile(dialect=mysql.dialect()))
assert 'INTEGER' in str(column.type.compile(dialect=sqlite.dialect()))
def test_over_long_registry_values_are_clamped_not_fatal(app):
"""One malformed Uninstall entry must not cost the whole check-in.
On MySQL in strict mode an over-long value raises Data too long and fails the
transaction, taking telemetry and events down with it - so that endpoint goes
silent until somebody uninstalls the offending program. SQLite does not
enforce declared lengths, so the suite would never have shown it.
"""
from plugins.endpoints.plugin import EndpointsPlugin
with app.app_context():
db.create_all()
warnings = []
stored = EndpointsPlugin()._replace_applications(
assetid=4242,
applications=[{
'displayname': 'A' * 400,
'displayversion': 'V' * 400,
'publisher': 'P' * 400,
}],
warnings=warnings,
)
db.session.commit()
assert stored == 1
row = EndpointApplication.query.filter_by(assetid=4242).one()
assert len(row.displayname) == 255
assert len(row.displayversion) == 128
assert len(row.publisher) == 255
def test_a_blank_value_becomes_null_rather_than_an_empty_string(app):
from plugins.endpoints.plugin import EndpointsPlugin
with app.app_context():
db.create_all()
EndpointsPlugin()._replace_applications(
assetid=4243,
applications=[{'displayname': 'Thing', 'publisher': ' '}],
warnings=[],
)
db.session.commit()
row = EndpointApplication.query.filter_by(assetid=4243).one()
assert row.publisher is None

View File

@@ -0,0 +1,81 @@
"""An update_app delivery must pin the bytes it actually delivers.
The agent verifies the downloaded file against the descriptor's sha256 and fails
closed on a mismatch - which is worthless if the descriptor carries no digest,
and misleading if it carries a digest for a different file.
Both were true of the version: branch. It pinned AppVersion.sha256, but
/api/endpoints/apppackage/<appversionid> resolves the version to its application
and serves application.installpath, so the digest described an artifact that
route never sends; where the version had no sha256 the descriptor carried none
at all and the agent had nothing to check.
It also ended the URL at the id, so an MSI arrived with no extension and the
agent's Path.GetExtension could not route it through msiexec.
"""
import pytest
from shopdb.api import db
from shopdb.core.models import Application, AppVersion
@pytest.fixture
def hosted_app(app, request):
# Unique per test: appname is unique-constrained and the database outlives
# a single test here, so a fixed name collides on the second use.
with app.app_context():
db.create_all()
application = Application(
appname='Widget-%s' % request.node.name[-24:],
installpath='/api/applications/package/widget-setup.msi',
installersha256='a' * 64,
)
db.session.add(application)
db.session.commit()
version = AppVersion(appid=application.appid, version='2.1', sha256=None)
db.session.add(version)
db.session.commit()
yield application.appid, version.appversionid
def test_a_version_delivery_carries_a_digest_even_when_the_version_has_none(app, hosted_app):
from plugins.endpoints.api.routes import _resolve_update_app
appid, appversionid = hosted_app
with app.app_context():
descriptor, error = _resolve_update_app(f'version:{appversionid}')
assert error is None, error
# Not None: this is the whole point. A blank pin makes the agent fail open.
assert descriptor['sha256'] == 'a' * 64
def test_a_version_delivery_keeps_the_installer_extension(app, hosted_app):
"""Without the filename an MSI is not routed through msiexec."""
from plugins.endpoints.api.routes import _resolve_update_app
appid, appversionid = hosted_app
with app.app_context():
descriptor, _ = _resolve_update_app(f'version:{appversionid}')
assert descriptor['installpath'].endswith('.msi')
def test_a_package_with_no_recorded_digest_is_refused_not_delivered_unverified(app):
"""Refusing to enqueue beats delivering something nothing can verify."""
from plugins.endpoints.api.routes import _resolve_update_app
with app.app_context():
db.create_all()
application = Application(appname='Unhashed-%s' % id(app),
installpath='/api/applications/package/x.exe')
db.session.add(application)
db.session.commit()
version = AppVersion(appid=application.appid, version='1.0', sha256=None)
db.session.add(version)
db.session.commit()
descriptor, error = _resolve_update_app(f'version:{version.appversionid}')
assert descriptor is None
assert 'SHA-256' in error

View File

@@ -69,7 +69,7 @@ def test_endpoints_asset_panels_are_computer_scoped():
"""Every asset panel targets computer assets only, with an endpoint."""
plugin = EndpointsPlugin()
panels = plugin.get_asset_panels()
assert len(panels) == 5
assert len(panels) == 6
for panel in panels:
assert panel['assettypes'] == ['computer']
assert panel['endpoint'].startswith('/api/endpoints/asset/{assetid}')
@@ -96,14 +96,28 @@ def test_services_and_processes_are_split_into_two_filtered_cards():
assert processes['permission'] == 'endpoints.command'
def test_recent_events_card_is_last_and_starts_collapsed():
"""Events is the lowest-priority card: forced to the bottom of the column
and collapsed by default."""
def test_activity_card_starts_collapsed_and_sits_below_live_state():
"""The activity feed is history, so it must not push live state down.
Renamed from the events card when events and commands merged into one feed.
It is no longer the LAST panel - installed software sits below it - so the
assertion is that it ranks below every live-state panel rather than being
the maximum, which would break again the next time a reference panel is
added underneath.
"""
plugin = EndpointsPlugin()
panels = plugin.get_asset_panels()
events = next(p for p in panels if p['id'] == 'endpoints-events')
assert events['collapsedDefault'] is True
assert events['position'] == max(p['position'] for p in panels)
activity = next(p for p in panels if p['id'] == 'endpoints-activity')
# NOT collapsed by default, unlike the events card it replaced. The feed
# surfaces critical events, and a card that hides the one thing worth seeing
# is the reason the health dot and the visible rows disagreed. Collapsible,
# so a reader can put it away.
assert activity.get('collapsible') is True
assert activity.get('collapsedDefault') is not True
live = [p['position'] for p in panels
if p['id'] in ('endpoints-status', 'endpoints-tracked-services',
'endpoints-tracked-processes', 'endpoints-interfaces')]
assert activity['position'] > max(live)
def test_endpoints_dashboard_widgets_declared():
@@ -115,7 +129,9 @@ def test_endpoints_dashboard_widgets_declared():
for widget in widgets:
assert widget['render'] == 'exceptions'
assert widget['empty'] == 'hide'
assert widget['permission'] == 'endpoints.view'
# The fleet-wide cards read the whole roster, so they are gated on
# endpoints.fleet, not the per-endpoint endpoints.view a guest may hold.
assert widget['permission'] == 'endpoints.fleet'
def test_endpoints_navigation_item_points_at_fleet_view():
@@ -160,12 +176,76 @@ def test_collect_now_is_allowlisted_targetless_and_routine():
assert elevated_permission_for('collect_now') is None
def test_run_script_and_update_app_are_allowlisted_delivery_actions():
"""The two software-delivery actions are allowlisted with a targetkind so
the enqueue route accepts them and carries their subject on the wire:
run_script carries a script name, update_app carries an appversionid."""
for action in ('run_script', 'update_app'):
assert is_allowed_action(action)
assert action in COMMAND_ALLOWLIST
assert requires_target(action)
assert COMMAND_ALLOWLIST['run_script']['targetkind'] == 'script'
assert COMMAND_ALLOWLIST['update_app']['targetkind'] == 'app'
def test_delivery_actions_require_the_elevated_deploy_permission():
"""run_script and update_app both demand endpoints.deploy ON TOP OF
endpoints.command, so pushing software is a strictly heavier grant than a
routine service/process toggle."""
assert elevated_permission_for('run_script') == 'endpoints.deploy'
assert elevated_permission_for('update_app') == 'endpoints.deploy'
def test_endpoints_declares_the_deploy_permission():
"""The dedicated software-delivery permission is declared for role seeding."""
plugin = EndpointsPlugin()
permissions = {name for name, *_ in plugin.get_permissions()}
assert 'endpoints.deploy' in permissions
def test_known_payload_keys_cover_persisted_and_relayed_fields():
"""The ingest allow-set must list every key the handler consumes today, so
those keys are never mislogged as 'unpersisted'. Anything the agent sends
outside this set is logged for observability before columns exist."""
from plugins.endpoints.plugin import KNOWN_PAYLOAD_KEYS
persisted_or_relayed = {
'hostname', 'adopt', 'loggedinuser', 'serialnumber', 'pctype',
'machinenumber', 'vendorname', 'modelnumber', 'osname', 'ipaddress',
'agentversion', 'lastboottime', 'uptimeseconds', 'idleseconds',
'cpupercent', 'memorypercent', 'services', 'ports', 'interfaces',
'scheduledtasks', 'events',
}
assert persisted_or_relayed <= KNOWN_PAYLOAD_KEYS
def test_new_hardware_telemetry_keys_are_logged_not_silently_known():
"""Disk/bios/memory inventory is collected at the agent before the server
persists it. Those keys must be OUTSIDE the known set so ingest logs them
rather than dropping them silently."""
from plugins.endpoints.plugin import KNOWN_PAYLOAD_KEYS
for key in ('disks', 'bios', 'memory', 'processor', 'osinfo', 'domain'):
assert key not in KNOWN_PAYLOAD_KEYS
# 'applications' USED to be in this list. It is persisted now - that is what
# the installed-software work added - so it belongs in the known set, and
# asserting its absence would fail the moment the feature it tests works.
assert 'applications' in KNOWN_PAYLOAD_KEYS
def test_telemetrymeta_route_is_registered_for_freshness_gating():
"""The WebUI gates panel refreshes on lastreportedat advancing, so the
lightweight freshness probe must be exposed on the plugin blueprint."""
from flask import Flask
application = Flask(__name__)
application.register_blueprint(EndpointsPlugin().get_blueprint())
# A url_prefix is REQUIRED here: the fleet list registers as route(''), which
# Flask rejects as a bare rule and accepts under a prefix. That is deliberate
# - CONTRIBUTING says API routes carry no trailing slash - so the test has to
# mount the blueprint the way the app does.
application.register_blueprint(EndpointsPlugin().get_blueprint(),
url_prefix='/api/endpoints')
rules = {rule.rule for rule in application.url_map.iter_rules()}
assert any(rule.endswith('/asset/<int:asset_id>/telemetrymeta') for rule in rules)

View File

@@ -38,6 +38,13 @@ ALLOWED_UNDECLARED = {
'pip',
'setuptools',
'pkg_resources',
# python-docx, used ONLY by scripts/docs/generate_docx.py to turn local
# authored Markdown into DOCX deliverables in exports/. That script is a
# developer convenience: the app never imports it, no deployed site runs it,
# and the Markdown it reads is itself gitignored. Declaring it in the
# production requirements would ship a doc-generation library to every
# customer venv for no runtime purpose.
'docx',
}