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:
47
.gitignore
vendored
47
.gitignore
vendored
@@ -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
7
agent/.gitignore
vendored
@@ -3,5 +3,10 @@ bin/
|
||||
obj/
|
||||
publish/
|
||||
|
||||
# Local publish scratch and command-output captures.
|
||||
# Application-delivery development builds. Held in reserve for extensive
|
||||
# testing; never shipped to a site publish folder or tracked.
|
||||
dev-bin/
|
||||
|
||||
# Local publish/staging scratch and command-output captures.
|
||||
_stage/
|
||||
*.user
|
||||
|
||||
358
agent/ShopDbAdoptionTool/Program.cs
Normal file
358
agent/ShopDbAdoptionTool/Program.cs
Normal file
@@ -0,0 +1,358 @@
|
||||
using System.Management;
|
||||
using System.Net.Http.Json;
|
||||
using System.Net.NetworkInformation;
|
||||
using System.Runtime.Versioning;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace ShopDbAdoptionTool;
|
||||
|
||||
// ShopDB adoption tool (SPIKE). A one-shot, agentless enrollment client: it uses
|
||||
// only the endpoints collector key to create a ShopDB PC (Computer) + Endpoint
|
||||
// entry via the existing collector adopt path, then exits. It is NOT the endpoint
|
||||
// agent - it installs nothing, runs no service, and reports no ongoing telemetry.
|
||||
// For sites that want an inventory entry without running the full agent.
|
||||
//
|
||||
// Configuration comes from adopt.config.json beside the exe, or from
|
||||
// --baseurl / --collectorkey / --hostname arguments (arguments win). The
|
||||
// collector key is never written to disk by this tool; it is read from the
|
||||
// config the operator generated and placed next to the exe.
|
||||
[SupportedOSPlatform("windows")]
|
||||
internal static class Program
|
||||
{
|
||||
private const string ReportPath = "/api/collector/endpoints";
|
||||
|
||||
private static async Task<int> Main(string[] args)
|
||||
{
|
||||
try
|
||||
{
|
||||
var config = LoadConfig(args);
|
||||
if (string.IsNullOrWhiteSpace(config.BaseUrl) ||
|
||||
string.IsNullOrWhiteSpace(config.CollectorKey))
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
"Missing baseurl or collectorkey. Provide adopt.config.json next to the "
|
||||
+ "exe or pass --baseurl and --collectorkey.");
|
||||
return 2;
|
||||
}
|
||||
|
||||
// The app manifest forces UAC elevation, so this is a belt-and-braces
|
||||
// guard for edge cases (for example a stripped manifest). Full WMI and
|
||||
// the machine Uninstall hives need admin, or collection is partial.
|
||||
if (!IsElevated())
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
"This tool must run as administrator so hardware and installed-"
|
||||
+ "software collection succeeds. Right-click and Run as administrator.");
|
||||
return 3;
|
||||
}
|
||||
|
||||
var payload = BuildPayload(config);
|
||||
Console.WriteLine($"Adopting '{payload.Hostname}' into {config.BaseUrl} ...");
|
||||
Console.WriteLine(
|
||||
$"Collected {payload.Applications?.Length ?? 0} installed applications.");
|
||||
|
||||
using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(60) };
|
||||
client.DefaultRequestHeaders.Add("X-API-Key", config.CollectorKey);
|
||||
|
||||
var url = config.BaseUrl!.TrimEnd('/') + ReportPath;
|
||||
using var response = await client.PostAsJsonAsync(url, payload);
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"Adoption failed: HTTP {(int)response.StatusCode}. {body}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
Console.WriteLine("Adoption succeeded.");
|
||||
Console.WriteLine(body);
|
||||
return 0;
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
Console.Error.WriteLine($"Adoption error: {error.Message}");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
private static AdoptConfig LoadConfig(string[] args)
|
||||
{
|
||||
var config = new AdoptConfig();
|
||||
|
||||
var configPath = Path.Combine(AppContext.BaseDirectory, "adopt.config.json");
|
||||
if (File.Exists(configPath))
|
||||
{
|
||||
var fromFile = JsonSerializer.Deserialize<AdoptConfig>(
|
||||
File.ReadAllText(configPath),
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||
if (fromFile is not null)
|
||||
config = fromFile;
|
||||
}
|
||||
|
||||
for (var i = 0; i < args.Length - 1; i++)
|
||||
{
|
||||
var value = args[i + 1];
|
||||
switch (args[i].ToLowerInvariant())
|
||||
{
|
||||
case "--baseurl":
|
||||
config.BaseUrl = value;
|
||||
break;
|
||||
case "--collectorkey":
|
||||
config.CollectorKey = value;
|
||||
break;
|
||||
case "--hostname":
|
||||
config.Hostname = value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
private static AdoptPayload BuildPayload(AdoptConfig config)
|
||||
{
|
||||
var hostname = !string.IsNullOrWhiteSpace(config.Hostname)
|
||||
? config.Hostname!
|
||||
: Environment.MachineName;
|
||||
|
||||
return new AdoptPayload
|
||||
{
|
||||
Hostname = hostname,
|
||||
Adopt = true,
|
||||
LoggedInUser = GetLoggedInUser(),
|
||||
SerialNumber = WmiFirst("SELECT SerialNumber FROM Win32_BIOS", "SerialNumber"),
|
||||
PcType = WmiFirst("SELECT Model FROM Win32_ComputerSystem", "Model"),
|
||||
VendorName = WmiFirst("SELECT Manufacturer FROM Win32_ComputerSystem", "Manufacturer"),
|
||||
ModelNumber = GetModelNumber(),
|
||||
OsName = WmiFirst("SELECT Caption FROM Win32_OperatingSystem", "Caption"),
|
||||
IpAddress = ResolveIpAddress(),
|
||||
LastBootTime = GetLastBootTime(),
|
||||
Applications = GetApplications(),
|
||||
AgentVersion = "adopt-0.1.0",
|
||||
};
|
||||
}
|
||||
|
||||
// Mirrors the endpoint agent: prefer the WMI logged-on user, fall back to
|
||||
// the process identity so the field is never blank.
|
||||
private static string GetLoggedInUser()
|
||||
{
|
||||
var user = WmiFirst("SELECT UserName FROM Win32_ComputerSystem", "UserName");
|
||||
return string.IsNullOrWhiteSpace(user)
|
||||
? $"{Environment.UserDomainName}\\{Environment.UserName}"
|
||||
: user;
|
||||
}
|
||||
|
||||
// Human-readable model first (what pctype classifies on); SystemSKUNumber is
|
||||
// an OEM SKU code, used only when Model is blank. Matches EndpointCollector.
|
||||
private static string? GetModelNumber()
|
||||
{
|
||||
var model = WmiFirst("SELECT Model FROM Win32_ComputerSystem", "Model");
|
||||
if (!string.IsNullOrWhiteSpace(model))
|
||||
return model;
|
||||
return WmiFirst("SELECT SystemSKUNumber FROM Win32_ComputerSystem",
|
||||
"SystemSKUNumber");
|
||||
}
|
||||
|
||||
private static DateTimeOffset? GetLastBootTime()
|
||||
{
|
||||
var raw = WmiFirst("SELECT LastBootUpTime FROM Win32_OperatingSystem",
|
||||
"LastBootUpTime");
|
||||
if (string.IsNullOrWhiteSpace(raw))
|
||||
return null;
|
||||
try
|
||||
{
|
||||
return ManagementDateTimeConverter.ToDateTime(raw).ToUniversalTime();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Run one WMI query and return the first row's column as a trimmed string,
|
||||
// or null. Best-effort: WMI failures never abort adoption.
|
||||
private static string? WmiFirst(string query, string column)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var searcher = new ManagementObjectSearcher(query);
|
||||
foreach (var item in searcher.Get())
|
||||
{
|
||||
var value = item[column]?.ToString()?.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(value))
|
||||
return value;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best-effort: the server treats every hardware field as optional.
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Installed-software SBOM. Mirrors the endpoint agent exactly: enumerate both
|
||||
// Uninstall registry hives (64-bit and WOW6432Node), keep entries with a
|
||||
// DisplayName, de-dupe on name+version, and emit {displayname, displayversion,
|
||||
// publisher}. Requires admin to read the machine hives reliably (see the
|
||||
// app manifest). Never throws out to the caller.
|
||||
private static object[] GetApplications()
|
||||
{
|
||||
const string uninstall64 =
|
||||
@"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall";
|
||||
const string uninstall32 =
|
||||
@"SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall";
|
||||
|
||||
var result = new List<object>();
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
CollectUninstallHive(uninstall64, result, seen);
|
||||
CollectUninstallHive(uninstall32, result, seen);
|
||||
return result.ToArray();
|
||||
}
|
||||
|
||||
private static void CollectUninstallHive(string subKey, List<object> result,
|
||||
HashSet<string> seen)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var machine = Microsoft.Win32.RegistryKey.OpenBaseKey(
|
||||
Microsoft.Win32.RegistryHive.LocalMachine,
|
||||
Microsoft.Win32.RegistryView.Default);
|
||||
using var uninstall = machine.OpenSubKey(subKey);
|
||||
if (uninstall is null)
|
||||
return;
|
||||
|
||||
foreach (var name in uninstall.GetSubKeyNames())
|
||||
{
|
||||
try
|
||||
{
|
||||
using var entry = uninstall.OpenSubKey(name);
|
||||
var displayName = entry?.GetValue("DisplayName")?.ToString()?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(displayName))
|
||||
continue;
|
||||
|
||||
var displayVersion =
|
||||
entry?.GetValue("DisplayVersion")?.ToString()?.Trim() ?? "";
|
||||
var publisher =
|
||||
entry?.GetValue("Publisher")?.ToString()?.Trim() ?? "";
|
||||
|
||||
if (!seen.Add(displayName + "\u0000" + displayVersion))
|
||||
continue;
|
||||
|
||||
result.Add(new
|
||||
{
|
||||
displayname = displayName,
|
||||
displayversion = displayVersion,
|
||||
publisher,
|
||||
});
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Skip unreadable entries; keep collecting the rest.
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Skip an unreadable hive; the other hive may still return data.
|
||||
}
|
||||
}
|
||||
|
||||
private static string? ResolveIpAddress()
|
||||
{
|
||||
try
|
||||
{
|
||||
foreach (var network in NetworkInterface.GetAllNetworkInterfaces())
|
||||
{
|
||||
if (network.OperationalStatus != OperationalStatus.Up)
|
||||
continue;
|
||||
if (network.NetworkInterfaceType == NetworkInterfaceType.Loopback)
|
||||
continue;
|
||||
foreach (var address in network.GetIPProperties().UnicastAddresses)
|
||||
{
|
||||
if (address.Address.AddressFamily ==
|
||||
System.Net.Sockets.AddressFamily.InterNetwork)
|
||||
return address.Address.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best-effort: the server treats ipaddress as optional.
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// True when the process runs with the Administrators group in its token.
|
||||
private static bool IsElevated()
|
||||
{
|
||||
try
|
||||
{
|
||||
using var identity = System.Security.Principal.WindowsIdentity.GetCurrent();
|
||||
var principal = new System.Security.Principal.WindowsPrincipal(identity);
|
||||
return principal.IsInRole(
|
||||
System.Security.Principal.WindowsBuiltInRole.Administrator);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class AdoptConfig
|
||||
{
|
||||
[JsonPropertyName("baseurl")]
|
||||
public string? BaseUrl { get; set; }
|
||||
|
||||
[JsonPropertyName("collectorkey")]
|
||||
public string? CollectorKey { get; set; }
|
||||
|
||||
[JsonPropertyName("hostname")]
|
||||
public string? Hostname { get; set; }
|
||||
}
|
||||
|
||||
// Payload keys mirror the endpoints collector adopt schema exactly (lowercase,
|
||||
// concatenated) so the server stores them without translation. This tool sends
|
||||
// the adopt identity plus the hardware and SBOM subset the endpoint agent
|
||||
// collects; ongoing health telemetry (cpu, services, events) is intentionally
|
||||
// absent because this is agentless enrollment, not the agent.
|
||||
internal sealed class AdoptPayload
|
||||
{
|
||||
[JsonPropertyName("hostname")]
|
||||
public string Hostname { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("adopt")]
|
||||
public bool Adopt { get; set; }
|
||||
|
||||
[JsonPropertyName("loggedinuser")]
|
||||
public string? LoggedInUser { get; set; }
|
||||
|
||||
[JsonPropertyName("serialnumber")]
|
||||
public string? SerialNumber { get; set; }
|
||||
|
||||
[JsonPropertyName("pctype")]
|
||||
public string? PcType { get; set; }
|
||||
|
||||
[JsonPropertyName("vendorname")]
|
||||
public string? VendorName { get; set; }
|
||||
|
||||
[JsonPropertyName("modelnumber")]
|
||||
public string? ModelNumber { get; set; }
|
||||
|
||||
[JsonPropertyName("osname")]
|
||||
public string? OsName { get; set; }
|
||||
|
||||
[JsonPropertyName("ipaddress")]
|
||||
public string? IpAddress { get; set; }
|
||||
|
||||
[JsonPropertyName("lastboottime")]
|
||||
public DateTimeOffset? LastBootTime { get; set; }
|
||||
|
||||
[JsonPropertyName("applications")]
|
||||
public object[]? Applications { get; set; }
|
||||
|
||||
[JsonPropertyName("agentversion")]
|
||||
public string? AgentVersion { get; set; }
|
||||
}
|
||||
30
agent/ShopDbAdoptionTool/ShopDbAdoptionTool.csproj
Normal file
30
agent/ShopDbAdoptionTool/ShopDbAdoptionTool.csproj
Normal file
@@ -0,0 +1,30 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net8.0-windows</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>ShopDbAdoptionTool</RootNamespace>
|
||||
<AssemblyName>shopdb-adopt</AssemblyName>
|
||||
<!-- 0.1.0 (SPIKE): one-shot, agentless enrollment. Uses only the endpoints
|
||||
collector key to create a ShopDB PC (Computer) + Endpoint entry via the
|
||||
existing POST /api/collector/endpoints adopt path. Deliberately NOT the
|
||||
endpoint agent: no service, no polling, no delivery. For sites that want
|
||||
an inventory entry without running the full agent. -->
|
||||
<Version>0.1.0</Version>
|
||||
<InvariantGlobalization>true</InvariantGlobalization>
|
||||
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||
<SelfContained>true</SelfContained>
|
||||
<PublishSingleFile>true</PublishSingleFile>
|
||||
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
|
||||
<EnableCompressionInSingleFile>true</EnableCompressionInSingleFile>
|
||||
<DebugType>none</DebugType>
|
||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="System.Management" Version="8.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
20
agent/ShopDbAdoptionTool/app.manifest
Normal file
20
agent/ShopDbAdoptionTool/app.manifest
Normal file
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||
<assemblyIdentity version="0.1.0.0" name="ShopDbAdoptionTool.app" />
|
||||
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||
<security>
|
||||
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v2">
|
||||
<!-- Adoption reads the machine Uninstall hives and full WMI hardware for
|
||||
the SBOM and hardware collection. Require elevation so the run is not
|
||||
silently partial when launched from a standard user token. -->
|
||||
<requestedExecutionLevel level="requireAdministrator" uiAccess="false" />
|
||||
</requestedPrivileges>
|
||||
</security>
|
||||
</trustInfo>
|
||||
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
|
||||
<application>
|
||||
<!-- Windows 10 and Windows 11 -->
|
||||
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
|
||||
</application>
|
||||
</compatibility>
|
||||
</assembly>
|
||||
@@ -80,10 +80,76 @@ public sealed class AgentConfig
|
||||
return bare;
|
||||
}
|
||||
|
||||
// Per-name allowlist of pre-staged scripts for the generic run_script
|
||||
// action. Exactly like LaunchPaths, the wire never carries a script body or
|
||||
// path - only a script NAME the agent already holds here. A name absent
|
||||
// from this map fails closed. There are no shipped defaults: a script is a
|
||||
// site-specific artifact, so an empty map means run_script does nothing
|
||||
// until a site stages scripts and lists them in appsettings.json. Keyed by
|
||||
// script name (case-insensitive, .ps1 optional).
|
||||
[JsonPropertyName("scriptpaths")]
|
||||
public Dictionary<string, string> ScriptPaths { get; set; } = new();
|
||||
|
||||
// The effective script map: appsettings.json entries only (no defaults).
|
||||
// Keys are normalized to bare names so a lookup tolerates 'reset' or
|
||||
// 'reset.ps1'.
|
||||
public IReadOnlyDictionary<string, string> ResolveScriptPaths()
|
||||
{
|
||||
var resolved = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var pair in ScriptPaths)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(pair.Key) || string.IsNullOrWhiteSpace(pair.Value))
|
||||
continue;
|
||||
resolved[NormalizeScript(pair.Key)] = pair.Value;
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
private static string NormalizeScript(string name)
|
||||
{
|
||||
var bare = (name ?? "").Trim();
|
||||
if (bare.EndsWith(".ps1", StringComparison.OrdinalIgnoreCase))
|
||||
bare = bare[..^4];
|
||||
return bare;
|
||||
}
|
||||
|
||||
public bool IsUsable()
|
||||
=> !string.IsNullOrWhiteSpace(BaseUrl)
|
||||
&& !string.IsNullOrWhiteSpace(ApiKey)
|
||||
&& ApiKey != "REPLACE_WITH_COLLECTOR_KEY";
|
||||
|
||||
// Validates the BaseUrl scheme before any request is made. A plaintext
|
||||
// http:// URL aimed at a real (non-loopback) server is the classic
|
||||
// misconfiguration that hits the TLS port and used to spin the command
|
||||
// long-poll into a storm; refusing to start on it turns a silent, noisy
|
||||
// runtime failure into a single clear startup error. Loopback over http is
|
||||
// still allowed so a developer running the API on plain http locally works.
|
||||
// Returns a human-readable problem, or null when the BaseUrl is acceptable.
|
||||
public string? BaseUrlProblem()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(BaseUrl))
|
||||
return null;
|
||||
|
||||
if (!Uri.TryCreate(BaseUrl.Trim(), UriKind.Absolute, out var uri))
|
||||
return $"BaseUrl '{BaseUrl}' is not a valid absolute URL.";
|
||||
|
||||
if (uri.Scheme == Uri.UriSchemeHttps)
|
||||
return null;
|
||||
|
||||
if (uri.Scheme == Uri.UriSchemeHttp)
|
||||
{
|
||||
if (uri.IsLoopback)
|
||||
return null;
|
||||
return $"BaseUrl '{BaseUrl}' uses plaintext http:// against a " +
|
||||
"non-loopback host. The server requires https://; an http:// " +
|
||||
"URL hits the TLS port and fails every request. Reprovision " +
|
||||
"BaseUrl in HKLM\\SOFTWARE\\GE\\ShopDB (or appsettings.json) " +
|
||||
"with an https:// URL.";
|
||||
}
|
||||
|
||||
return $"BaseUrl '{BaseUrl}' uses unsupported scheme '{uri.Scheme}'. " +
|
||||
"Use https:// (or http:// only for a loopback dev server).";
|
||||
}
|
||||
}
|
||||
|
||||
// Watched targets for this endpoint. Both lists carry entries with a wire name
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Diagnostics;
|
||||
using System.Security.Cryptography;
|
||||
using System.ServiceProcess;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
@@ -58,12 +59,18 @@ public sealed class CommandExecutor
|
||||
// map cannot be started - process_start fails closed.
|
||||
private readonly IReadOnlyDictionary<string, string> launchPaths;
|
||||
|
||||
// Held for update_app: the agent joins InstallPath to BaseUrl and downloads
|
||||
// the pinned installer with its own collector credential (ApiKey), so the
|
||||
// installer body never rides the command wire.
|
||||
private readonly AgentConfig config;
|
||||
|
||||
public CommandExecutor(ILogger<CommandExecutor> logger,
|
||||
AgentConfig config, TimeSpan? startTimeout = null)
|
||||
{
|
||||
this.logger = logger;
|
||||
this.startTimeout = startTimeout ?? TimeSpan.FromSeconds(30);
|
||||
this.launchPaths = (config ?? new AgentConfig()).ResolveLaunchPaths();
|
||||
this.config = config ?? new AgentConfig();
|
||||
this.launchPaths = this.config.ResolveLaunchPaths();
|
||||
}
|
||||
|
||||
public async Task<CommandResult> ExecuteAsync(
|
||||
@@ -103,6 +110,17 @@ public sealed class CommandExecutor
|
||||
return RequireTarget(action, target)
|
||||
?? await ProcessStart(target, token);
|
||||
|
||||
// Software delivery. run_script fetches a ShopDB-hosted script
|
||||
// pinned by SHA-256 and runs it in the script's configured context
|
||||
// (system or the interactive user); update_app fetches a ShopDB
|
||||
// installer pinned by SHA-256 and runs it silently. Both download
|
||||
// the body themselves with the collector credential and fail closed.
|
||||
case "run_script":
|
||||
return RequireTarget(action, target)
|
||||
?? await RunScript(target, claim.Script, token);
|
||||
case "update_app":
|
||||
return await UpdateApp(claim.App, token);
|
||||
|
||||
// Fixed destructive action: reboot this endpoint immediately. Runs
|
||||
// as the SYSTEM agent, so shutdown does not need a user session.
|
||||
case "system_restart":
|
||||
@@ -470,6 +488,433 @@ public sealed class CommandExecutor
|
||||
return found;
|
||||
}
|
||||
|
||||
// ---- Software delivery: run_script and update_app ----
|
||||
|
||||
// Run a ShopDB-hosted script pinned by the server's delivery descriptor. The
|
||||
// wire carries no body: the agent downloads the script from BaseUrl +
|
||||
// InstallPath with its own collector credential, verifies the SHA-256 (when
|
||||
// the server pinned one), then runs it in the descriptor's run context -
|
||||
// 'system' through the agent's own service account, or 'user' in the active
|
||||
// console session so the script sees the interactive user's resources (for
|
||||
// example mapped network drives). Anything missing or mismatched fails closed.
|
||||
private async Task<CommandResult> RunScript(
|
||||
string name, ScriptDeliveryClaim? script, CancellationToken token)
|
||||
{
|
||||
if (script is null || string.IsNullOrWhiteSpace(script.InstallPath))
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Rejected run_script for {Name}: no delivery descriptor on the claim", name);
|
||||
return new CommandResult
|
||||
{
|
||||
Succeeded = false,
|
||||
Message = $"run_script for {name} arrived without a resolved script descriptor",
|
||||
Detail = ScriptDetail(name, script, scriptPath: null, exitCode: null),
|
||||
};
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(config.BaseUrl) || string.IsNullOrWhiteSpace(config.ApiKey))
|
||||
{
|
||||
return new CommandResult
|
||||
{
|
||||
Succeeded = false,
|
||||
Message = "run_script cannot download: agent has no base URL or collector key",
|
||||
Detail = ScriptDetail(name, script, scriptPath: null, exitCode: null),
|
||||
};
|
||||
}
|
||||
|
||||
var scriptUrl = CombineUrl(config.BaseUrl, script.InstallPath!);
|
||||
var tempPath = Path.Combine(GetStagingDirectory(),
|
||||
$"shopdb-script-{script.ScriptId}.ps1");
|
||||
|
||||
try
|
||||
{
|
||||
using var handler = new HttpClientHandler();
|
||||
using var client = new HttpClient(handler);
|
||||
client.Timeout = TimeSpan.FromMinutes(5);
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, scriptUrl);
|
||||
httpRequest.Headers.Add("X-API-Key", config.ApiKey);
|
||||
|
||||
using var response = await client.SendAsync(
|
||||
httpRequest, HttpCompletionOption.ResponseHeadersRead, token);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return new CommandResult
|
||||
{
|
||||
Succeeded = false,
|
||||
Message = $"run_script download failed: HTTP {(int)response.StatusCode}",
|
||||
Detail = ScriptDetail(name, script, scriptPath: null, exitCode: null),
|
||||
};
|
||||
}
|
||||
|
||||
await using (var source = await response.Content.ReadAsStreamAsync(token))
|
||||
await using (var file = File.Create(tempPath))
|
||||
{
|
||||
await source.CopyToAsync(file, token);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(script.Sha256))
|
||||
{
|
||||
var actual = HashFile(tempPath);
|
||||
if (!actual.Equals(script.Sha256, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
logger.LogWarning(
|
||||
"run_script SHA-256 mismatch for {Name}: expected {Expected} got {Actual}",
|
||||
name, script.Sha256, actual);
|
||||
return new CommandResult
|
||||
{
|
||||
Succeeded = false,
|
||||
Message = "run_script integrity check failed: script SHA-256 does not match",
|
||||
Detail = ScriptDetail(name, script, tempPath, exitCode: null),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
var runInUser = string.Equals(
|
||||
script.RunContext, "user", StringComparison.OrdinalIgnoreCase);
|
||||
return runInUser
|
||||
? RunScriptInUserSession(name, script, tempPath)
|
||||
: await RunScriptAsSystem(name, script, tempPath, token);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
logger.LogWarning(error, "run_script for {Name} failed", name);
|
||||
return new CommandResult
|
||||
{
|
||||
Succeeded = false,
|
||||
Message = $"Script {name} failed: {error.Message}",
|
||||
Detail = ScriptDetail(name, script, tempPath, exitCode: null),
|
||||
};
|
||||
}
|
||||
finally
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(tempPath))
|
||||
File.Delete(tempPath);
|
||||
}
|
||||
catch (Exception cleanupError)
|
||||
{
|
||||
logger.LogDebug(cleanupError, "run_script temp cleanup failed: {Path}", tempPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// System context: run through the agent's own service account and wait for
|
||||
// the exit code, the same as before. Fine for machine-scoped scripts that do
|
||||
// not need the interactive user's session resources.
|
||||
private async Task<CommandResult> RunScriptAsSystem(
|
||||
string name, ScriptDeliveryClaim script, string scriptPath, CancellationToken token)
|
||||
{
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = "powershell.exe",
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
WorkingDirectory = Path.GetDirectoryName(scriptPath) ?? "",
|
||||
};
|
||||
startInfo.ArgumentList.Add("-NoProfile");
|
||||
startInfo.ArgumentList.Add("-NonInteractive");
|
||||
startInfo.ArgumentList.Add("-ExecutionPolicy");
|
||||
startInfo.ArgumentList.Add("Bypass");
|
||||
startInfo.ArgumentList.Add("-File");
|
||||
startInfo.ArgumentList.Add(scriptPath);
|
||||
|
||||
using var process = Process.Start(startInfo);
|
||||
if (process is null)
|
||||
{
|
||||
return new CommandResult
|
||||
{
|
||||
Succeeded = false,
|
||||
Message = $"Failed to start script {name}",
|
||||
Detail = ScriptDetail(name, script, scriptPath, exitCode: null),
|
||||
};
|
||||
}
|
||||
|
||||
await process.WaitForExitAsync(token);
|
||||
var exit = process.ExitCode;
|
||||
return new CommandResult
|
||||
{
|
||||
Succeeded = exit == 0,
|
||||
Message = exit == 0
|
||||
? $"Script {name} completed (system context)"
|
||||
: $"Script {name} exited with code {exit}",
|
||||
Detail = ScriptDetail(name, script, scriptPath, exit),
|
||||
};
|
||||
}
|
||||
|
||||
// User context: launch PowerShell in the ACTIVE CONSOLE session so the script
|
||||
// runs at the interactive user's integrity and sees that user's resources
|
||||
// (mapped network drives, HKCU, profile). The launcher waits for the process
|
||||
// to exit and returns its real exit code, so success here means the script
|
||||
// actually ran to a zero exit - not merely that it was launched. Fails closed
|
||||
// when no interactive user is signed in.
|
||||
private CommandResult RunScriptInUserSession(
|
||||
string name, ScriptDeliveryClaim script, string scriptPath)
|
||||
{
|
||||
var quotedPath = "\"" + scriptPath + "\"";
|
||||
var arguments =
|
||||
"-NoProfile -NonInteractive -ExecutionPolicy Bypass -File " + quotedPath;
|
||||
var workingDirectory = Path.GetDirectoryName(scriptPath) ?? "";
|
||||
|
||||
var outcome = InteractiveProcessLauncher.LaunchInActiveSession(
|
||||
"powershell.exe", arguments, workingDirectory, waitForExit: true);
|
||||
if (!outcome.Launched)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"run_script for {Name} could not launch in the user session: {Error}",
|
||||
name, outcome.Error);
|
||||
return new CommandResult
|
||||
{
|
||||
Succeeded = false,
|
||||
Message = $"Script {name} could not run in the user session: {outcome.Error}",
|
||||
Detail = ScriptDetail(name, script, scriptPath, exitCode: null),
|
||||
};
|
||||
}
|
||||
|
||||
var exit = outcome.ExitCode;
|
||||
var ok = exit is null || exit == 0;
|
||||
return new CommandResult
|
||||
{
|
||||
Succeeded = ok,
|
||||
Message = exit is null
|
||||
? $"Script {name} launched in the interactive user session"
|
||||
: ok
|
||||
? $"Script {name} completed (user context)"
|
||||
: $"Script {name} exited with code {exit}",
|
||||
Detail = ScriptDetail(name, script, scriptPath, exit),
|
||||
};
|
||||
}
|
||||
|
||||
private static Dictionary<string, object> ScriptDetail(
|
||||
string name, ScriptDeliveryClaim? script, string? scriptPath, int? exitCode)
|
||||
{
|
||||
var detail = new Dictionary<string, object>
|
||||
{
|
||||
["kind"] = "script",
|
||||
["name"] = name,
|
||||
};
|
||||
if (script is not null)
|
||||
{
|
||||
detail["scriptid"] = script.ScriptId;
|
||||
if (!string.IsNullOrWhiteSpace(script.RunContext))
|
||||
detail["runcontext"] = script.RunContext!;
|
||||
}
|
||||
if (scriptPath is not null)
|
||||
detail["path"] = scriptPath;
|
||||
if (exitCode is not null)
|
||||
detail["exitcode"] = exitCode;
|
||||
return detail;
|
||||
}
|
||||
|
||||
// Fetch a ShopDB-hosted installer pinned by the server's delivery descriptor
|
||||
// and run it silently. The wire carries no bytes: the agent downloads the
|
||||
// package itself from BaseUrl + InstallPath using its own collector
|
||||
// credential, verifies the SHA-256 (when the server pinned one), then runs
|
||||
// an MSI via msiexec /qn or an EXE with the version's SilentArgs. Anything
|
||||
// missing or mismatched fails closed.
|
||||
private async Task<CommandResult> UpdateApp(AppDeliveryClaim? app, CancellationToken token)
|
||||
{
|
||||
if (app is null || string.IsNullOrWhiteSpace(app.InstallPath))
|
||||
{
|
||||
logger.LogWarning("Rejected update_app: no delivery descriptor on the claim");
|
||||
return new CommandResult
|
||||
{
|
||||
Succeeded = false,
|
||||
Message = "update_app arrived without a resolved package descriptor",
|
||||
Detail = AppDetail(app, installerPath: null, exitCode: null),
|
||||
};
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(config.BaseUrl) || string.IsNullOrWhiteSpace(config.ApiKey))
|
||||
{
|
||||
return new CommandResult
|
||||
{
|
||||
Succeeded = false,
|
||||
Message = "update_app cannot download: agent has no base URL or collector key",
|
||||
Detail = AppDetail(app, installerPath: null, exitCode: null),
|
||||
};
|
||||
}
|
||||
|
||||
var packageUrl = CombineUrl(config.BaseUrl, app.InstallPath!);
|
||||
var ext = Path.GetExtension(app.InstallPath) ?? "";
|
||||
var packageKey = app.AppVersionId?.ToString() ?? $"app{app.AppId}";
|
||||
var tempPath = Path.Combine(GetStagingDirectory(),
|
||||
$"shopdb-app-{packageKey}{ext}");
|
||||
|
||||
try
|
||||
{
|
||||
using var handler = new HttpClientHandler();
|
||||
using var client = new HttpClient(handler);
|
||||
client.Timeout = TimeSpan.FromMinutes(10);
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, packageUrl);
|
||||
httpRequest.Headers.Add("X-API-Key", config.ApiKey);
|
||||
|
||||
using var response = await client.SendAsync(
|
||||
httpRequest, HttpCompletionOption.ResponseHeadersRead, token);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return new CommandResult
|
||||
{
|
||||
Succeeded = false,
|
||||
Message = $"update_app download failed: HTTP {(int)response.StatusCode}",
|
||||
Detail = AppDetail(app, installerPath: null, exitCode: null),
|
||||
};
|
||||
}
|
||||
|
||||
await using (var source = await response.Content.ReadAsStreamAsync(token))
|
||||
await using (var file = File.Create(tempPath))
|
||||
{
|
||||
await source.CopyToAsync(file, token);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(app.Sha256))
|
||||
{
|
||||
var actual = HashFile(tempPath);
|
||||
if (!actual.Equals(app.Sha256, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
logger.LogWarning(
|
||||
"update_app SHA-256 mismatch for {App} v{Version}: expected {Expected} got {Actual}",
|
||||
app.AppName, app.Version, app.Sha256, actual);
|
||||
return new CommandResult
|
||||
{
|
||||
Succeeded = false,
|
||||
Message = "update_app integrity check failed: package SHA-256 does not match",
|
||||
Detail = AppDetail(app, tempPath, exitCode: null),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
var (fileName, arguments) = BuildInstallInvocation(tempPath, ext, app.SilentArgs);
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = fileName,
|
||||
Arguments = arguments,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
WorkingDirectory = Path.GetDirectoryName(tempPath) ?? "",
|
||||
};
|
||||
|
||||
using var installer = Process.Start(startInfo);
|
||||
if (installer is null)
|
||||
{
|
||||
return new CommandResult
|
||||
{
|
||||
Succeeded = false,
|
||||
Message = $"Failed to start installer for {app.AppName}",
|
||||
Detail = AppDetail(app, tempPath, exitCode: null),
|
||||
};
|
||||
}
|
||||
|
||||
await installer.WaitForExitAsync(token);
|
||||
var exit = installer.ExitCode;
|
||||
// MSI treats 3010 as success-pending-reboot; keep that as success.
|
||||
var ok = exit == 0 || exit == 3010;
|
||||
return new CommandResult
|
||||
{
|
||||
Succeeded = ok,
|
||||
Message = ok
|
||||
? $"Installed {app.AppName} v{app.Version}"
|
||||
+ (exit == 3010 ? " (reboot required)" : "")
|
||||
: $"Installer for {app.AppName} exited with code {exit}",
|
||||
Detail = AppDetail(app, tempPath, exit),
|
||||
};
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
logger.LogWarning(error, "update_app for {App} failed", app.AppName);
|
||||
return new CommandResult
|
||||
{
|
||||
Succeeded = false,
|
||||
Message = $"update_app for {app.AppName} failed: {error.Message}",
|
||||
Detail = AppDetail(app, tempPath, exitCode: null),
|
||||
};
|
||||
}
|
||||
finally
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(tempPath))
|
||||
File.Delete(tempPath);
|
||||
}
|
||||
catch (Exception cleanupError)
|
||||
{
|
||||
logger.LogDebug(cleanupError, "update_app temp cleanup failed: {Path}", tempPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MSI installs run through msiexec /i <pkg> /qn (any SilentArgs appended);
|
||||
// an EXE runs directly with its version's SilentArgs (homegrown EXEs vary by
|
||||
// developer, so the switches are per-version server data, not guessed here).
|
||||
private static (string fileName, string arguments) BuildInstallInvocation(
|
||||
string packagePath, string ext, string? silentArgs)
|
||||
{
|
||||
var extra = (silentArgs ?? "").Trim();
|
||||
if (ext.Equals(".msi", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var args = $"/i \"{packagePath}\" /qn";
|
||||
if (extra.Length > 0)
|
||||
args += " " + extra;
|
||||
return ("msiexec.exe", args);
|
||||
}
|
||||
return (packagePath, extra);
|
||||
}
|
||||
|
||||
// Fixed, known staging folder we control and that is Defender-excluded in
|
||||
// the environment. LocalSystem's Path.GetTempPath() resolves to
|
||||
// C:\Windows\SystemTemp on Win11/Server2022+, which we do not use; stage
|
||||
// downloads here instead so run_script and update_app land in a predictable
|
||||
// spot. Created on demand.
|
||||
private static string GetStagingDirectory()
|
||||
{
|
||||
var programData = Environment.GetFolderPath(
|
||||
Environment.SpecialFolder.CommonApplicationData);
|
||||
var staging = Path.Combine(programData, "ShopDB", "EndpointAgent");
|
||||
Directory.CreateDirectory(staging);
|
||||
return staging;
|
||||
}
|
||||
|
||||
private static string HashFile(string path)
|
||||
{
|
||||
using var stream = File.OpenRead(path);
|
||||
using var sha = SHA256.Create();
|
||||
var hash = sha.ComputeHash(stream);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
// Join the agent base URL to a ShopDB-relative package path, tolerating a
|
||||
// trailing slash on the base and a leading slash on the path.
|
||||
private static string CombineUrl(string baseUrl, string path)
|
||||
{
|
||||
var left = baseUrl.TrimEnd('/');
|
||||
var right = path.StartsWith('/') ? path : "/" + path;
|
||||
return left + right;
|
||||
}
|
||||
|
||||
private static Dictionary<string, object> AppDetail(
|
||||
AppDeliveryClaim? app, string? installerPath, int? exitCode)
|
||||
{
|
||||
var detail = new Dictionary<string, object> { ["kind"] = "app" };
|
||||
if (app is not null)
|
||||
{
|
||||
if (app.AppVersionId is not null) detail["appversionid"] = app.AppVersionId.Value;
|
||||
detail["appid"] = app.AppId;
|
||||
if (app.AppName is not null) detail["appname"] = app.AppName;
|
||||
if (app.Version is not null) detail["version"] = app.Version;
|
||||
}
|
||||
if (installerPath is not null) detail["installer"] = installerPath;
|
||||
if (exitCode is not null) detail["exitcode"] = exitCode;
|
||||
return detail;
|
||||
}
|
||||
|
||||
// ---- Fixed MTConnect actions ----
|
||||
|
||||
private async Task<CommandResult> MtConnectStartAsync(CancellationToken token)
|
||||
|
||||
@@ -24,6 +24,19 @@ public sealed class CommandWorker : BackgroundService
|
||||
// operator has to fix something, so re-polling fast changes nothing.
|
||||
private static readonly TimeSpan PermanentRetryDelay = TimeSpan.FromSeconds(300);
|
||||
|
||||
// A genuine idle long-poll close only happens after the server has held the
|
||||
// request open for most of its hold window (~25s). A connection that drops
|
||||
// in well under this did not idle out - it failed at connect/TLS (e.g. an
|
||||
// http:// BaseUrl hitting the TLS port), which must NOT be treated as a
|
||||
// normal close and fast re-polled. Below this threshold we take the
|
||||
// transient-failure backoff instead.
|
||||
private static readonly TimeSpan MinIdlePollDuration = TimeSpan.FromSeconds(5);
|
||||
|
||||
// Hard floor between poll starts. Even if some unforeseen path returns
|
||||
// immediately every iteration, this caps the loop at a safe request rate so
|
||||
// a misconfigured agent can never turn into a request storm.
|
||||
private static readonly TimeSpan MinPollInterval = TimeSpan.FromSeconds(2);
|
||||
|
||||
private readonly CommandClient client;
|
||||
private readonly CommandExecutor executor;
|
||||
private readonly ILogger<CommandWorker> logger;
|
||||
@@ -46,6 +59,7 @@ public sealed class CommandWorker : BackgroundService
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
var pollStarted = System.Diagnostics.Stopwatch.StartNew();
|
||||
try
|
||||
{
|
||||
var claim = await client.ClaimNextAsync(hostname, stoppingToken);
|
||||
@@ -64,18 +78,32 @@ public sealed class CommandWorker : BackgroundService
|
||||
"Permanent command-channel error; backing off");
|
||||
await SafeDelay(PermanentRetryDelay, stoppingToken);
|
||||
}
|
||||
catch (Exception error) when (IsIdlePollClose(error))
|
||||
catch (Exception error) when (IsIdlePollClose(error)
|
||||
&& pollStarted.Elapsed >= MinIdlePollDuration)
|
||||
{
|
||||
// The server (or a proxy) closed the held-open long poll without
|
||||
// a command. That is the normal end of a hold window, not a
|
||||
// failure: log quietly at debug and re-poll immediately.
|
||||
// The server (or a proxy) held the poll open for most of the
|
||||
// hold window and then closed it without a command. That is the
|
||||
// normal end of a hold window, not a failure: log quietly at
|
||||
// debug and re-poll immediately.
|
||||
logger.LogDebug(error, "Command poll connection closed; re-polling");
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
// Anything else - including a connection/TLS failure that drops
|
||||
// in under MinIdlePollDuration (e.g. an http:// BaseUrl hitting
|
||||
// the TLS port) - is a real transient failure. Back off so a
|
||||
// misconfiguration cannot spin into a request storm.
|
||||
logger.LogWarning(error, "Command poll failed; retrying shortly");
|
||||
await SafeDelay(TransientRetryDelay, stoppingToken);
|
||||
}
|
||||
|
||||
// Floor the loop rate. On the immediate re-poll paths (204 or a
|
||||
// genuine idle close) the elapsed time already exceeds this, so it
|
||||
// adds no latency; it only bites if an iteration returned unusually
|
||||
// fast, capping the worst case at a safe request rate.
|
||||
var remaining = MinPollInterval - pollStarted.Elapsed;
|
||||
if (remaining > TimeSpan.Zero)
|
||||
await SafeDelay(remaining, stoppingToken);
|
||||
}
|
||||
|
||||
logger.LogInformation("Command channel stopped");
|
||||
@@ -114,8 +142,10 @@ public sealed class CommandWorker : BackgroundService
|
||||
|
||||
// A long poll that the server or an intermediary closes without sending a
|
||||
// command surfaces as an HttpRequestException wrapping an HttpIOException
|
||||
// (ResponseEnded), or a bare IOException. That is the expected end of a hold
|
||||
// window, so it should not be treated as a transient failure.
|
||||
// (ResponseEnded), or a bare IOException. Whether that is a benign hold-
|
||||
// window close or a fast connect/TLS failure is decided by the caller using
|
||||
// how long the poll stayed open (see MinIdlePollDuration); this only matches
|
||||
// the exception shape.
|
||||
private static bool IsIdlePollClose(Exception error)
|
||||
{
|
||||
for (Exception? current = error; current is not null; current = current.InnerException)
|
||||
|
||||
@@ -29,6 +29,20 @@ public sealed class EndpointReport
|
||||
[JsonPropertyName("interfaces")] public object[]? Interfaces { get; set; }
|
||||
[JsonPropertyName("scheduledtasks")] public object[]? ScheduledTasks { get; set; }
|
||||
[JsonPropertyName("events")] public EndpointEventRecord[]? Events { get; set; }
|
||||
// Additive inventory telemetry. The server does not persist these yet; its
|
||||
// ingest logs them as unknown keys (plugins/endpoints/plugin.py) so the
|
||||
// incoming values are observable before any DB column or UI card exists.
|
||||
// Keys stay lowercase/no-underscore to match AGENT-CONTRACT.md.
|
||||
[JsonPropertyName("disks")] public object[]? Disks { get; set; }
|
||||
[JsonPropertyName("bios")] public object? Bios { get; set; }
|
||||
[JsonPropertyName("memory")] public object? Memory { get; set; }
|
||||
[JsonPropertyName("processor")] public object? Processor { get; set; }
|
||||
[JsonPropertyName("osinfo")] public object? OsInfo { get; set; }
|
||||
[JsonPropertyName("domain")] public string? Domain { get; set; }
|
||||
// Installed-software SBOM, mirroring the Assess-Orchestrator applications
|
||||
// collection: both Uninstall registry hives, {displayname, displayversion,
|
||||
// publisher}. Server logs it as an unpersisted key until the SBOM store lands.
|
||||
[JsonPropertyName("applications")] public object[]? Applications { get; set; }
|
||||
}
|
||||
|
||||
public sealed class EndpointEventRecord
|
||||
@@ -71,6 +85,49 @@ public sealed class CommandClaim
|
||||
[JsonPropertyName("action")] public string Action { get; set; } = "";
|
||||
[JsonPropertyName("target")] public string? Target { get; set; }
|
||||
[JsonPropertyName("targetkind")] public string? TargetKind { get; set; }
|
||||
|
||||
// For update_app the server resolves the pinned application version into a
|
||||
// delivery descriptor (package URL, SHA-256, silent-install switches). The
|
||||
// agent fetches the bytes itself with its collector credential; the wire
|
||||
// never carries the installer body. Null for every other action.
|
||||
[JsonPropertyName("app")] public AppDeliveryClaim? App { get; set; }
|
||||
|
||||
// For run_script the server resolves the ShopDB-hosted script into a
|
||||
// delivery descriptor (body URL, SHA-256, run context). The agent fetches
|
||||
// the body itself with its collector credential; the wire never carries the
|
||||
// script body. Null for every other action.
|
||||
[JsonPropertyName("script")] public ScriptDeliveryClaim? Script { get; set; }
|
||||
}
|
||||
|
||||
// The resolved installer-push descriptor accompanying an update_app claim.
|
||||
// InstallPath is a ShopDB-relative package URL the agent joins to its BaseUrl
|
||||
// and downloads with its collector credential; Sha256 pins the exact bytes;
|
||||
// SilentArgs are this version's unattended-install switches (may be empty for
|
||||
// an MSI, which the agent installs via msiexec /qn). AppVersionId is null when
|
||||
// the push resolves an app-level install source rather than a pinned version.
|
||||
public sealed class AppDeliveryClaim
|
||||
{
|
||||
[JsonPropertyName("appversionid")] public int? AppVersionId { get; set; }
|
||||
[JsonPropertyName("appid")] public int AppId { get; set; }
|
||||
[JsonPropertyName("appname")] public string? AppName { get; set; }
|
||||
[JsonPropertyName("version")] public string? Version { get; set; }
|
||||
[JsonPropertyName("installpath")] public string? InstallPath { get; set; }
|
||||
[JsonPropertyName("sha256")] public string? Sha256 { get; set; }
|
||||
[JsonPropertyName("silentargs")] public string? SilentArgs { get; set; }
|
||||
}
|
||||
|
||||
// The resolved script-push descriptor accompanying a run_script claim.
|
||||
// InstallPath is a ShopDB-relative body URL the agent joins to its BaseUrl and
|
||||
// downloads with its collector credential; Sha256 pins the exact bytes;
|
||||
// RunContext is 'system' (the agent's own service account) or 'user' (the
|
||||
// interactive logged-on session), decided per script in the ShopDB library.
|
||||
public sealed class ScriptDeliveryClaim
|
||||
{
|
||||
[JsonPropertyName("scriptid")] public int ScriptId { get; set; }
|
||||
[JsonPropertyName("scriptname")] public string? ScriptName { get; set; }
|
||||
[JsonPropertyName("installpath")] public string? InstallPath { get; set; }
|
||||
[JsonPropertyName("sha256")] public string? Sha256 { get; set; }
|
||||
[JsonPropertyName("runcontext")] public string? RunContext { get; set; }
|
||||
}
|
||||
|
||||
// Envelope for the claim long-poll: { status, data: { endpointcommandid, action } }.
|
||||
|
||||
@@ -50,6 +50,13 @@ public sealed class EndpointCollector : ICollector
|
||||
Services = GetMonitoredTargets(),
|
||||
Interfaces = GetInterfaces(),
|
||||
Events = GetCuratedEvents(),
|
||||
Disks = GetDisks(),
|
||||
Bios = GetBios(),
|
||||
Memory = GetMemory(),
|
||||
Processor = GetProcessor(),
|
||||
OsInfo = GetOsInfo(),
|
||||
Domain = GetDomain(),
|
||||
Applications = GetApplications(),
|
||||
};
|
||||
|
||||
if (report.LastBootTime is DateTimeOffset boot)
|
||||
@@ -460,6 +467,264 @@ public sealed class EndpointCollector : ICollector
|
||||
return records.ToArray();
|
||||
}
|
||||
|
||||
// Installed-software SBOM. Mirrors the Assess-Orchestrator applications
|
||||
// collection exactly: enumerate both Uninstall registry hives (64-bit and
|
||||
// WOW6432Node), keep only entries with a DisplayName, and emit
|
||||
// {displayname, displayversion, publisher}. This is the same registry
|
||||
// surface the machine-number/eDNC probes already read. Wire keys stay
|
||||
// lowercase/no-underscore; the server logs this as an unpersisted key until
|
||||
// the SBOM store is built. Never throws out to the caller.
|
||||
private object[] GetApplications()
|
||||
{
|
||||
const string uninstall64 =
|
||||
@"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall";
|
||||
const string uninstall32 =
|
||||
@"SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall";
|
||||
|
||||
var result = new List<object>();
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
CollectUninstallHive(uninstall64, result, seen);
|
||||
CollectUninstallHive(uninstall32, result, seen);
|
||||
return result.ToArray();
|
||||
}
|
||||
|
||||
private void CollectUninstallHive(string subKey, List<object> result,
|
||||
HashSet<string> seen)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var machine = RegistryKey.OpenBaseKey(
|
||||
RegistryHive.LocalMachine, RegistryView.Default);
|
||||
using var uninstall = machine.OpenSubKey(subKey);
|
||||
if (uninstall is null)
|
||||
return;
|
||||
|
||||
foreach (var name in uninstall.GetSubKeyNames())
|
||||
{
|
||||
try
|
||||
{
|
||||
using var entry = uninstall.OpenSubKey(name);
|
||||
var displayName = entry?.GetValue("DisplayName")?.ToString()?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(displayName))
|
||||
continue;
|
||||
|
||||
var displayVersion =
|
||||
entry?.GetValue("DisplayVersion")?.ToString()?.Trim() ?? "";
|
||||
var publisher =
|
||||
entry?.GetValue("Publisher")?.ToString()?.Trim() ?? "";
|
||||
|
||||
// De-dupe on name+version so the two hives do not double-list
|
||||
// the same package.
|
||||
if (!seen.Add(displayName + "\u0000" + displayVersion))
|
||||
continue;
|
||||
|
||||
result.Add(new
|
||||
{
|
||||
displayname = displayName,
|
||||
displayversion = displayVersion,
|
||||
publisher,
|
||||
});
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
logger.LogDebug(error, "Uninstall entry read blocked: {Entry}", name);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
logger.LogDebug(error, "Uninstall hive read blocked: {SubKey}", subKey);
|
||||
}
|
||||
}
|
||||
|
||||
// Fixed local disks with capacity/free so the server can flag a PC that is
|
||||
// running out of space. Win32_LogicalDisk DriveType 3 = local fixed disk;
|
||||
// network/removable drives are skipped. Bytes stay as longs; the server can
|
||||
// derive percentages. Defensive: a locked WMI hive yields an empty list.
|
||||
private object[] GetDisks()
|
||||
{
|
||||
var result = new List<object>();
|
||||
try
|
||||
{
|
||||
using var searcher = new ManagementObjectSearcher(
|
||||
"SELECT DeviceID, VolumeName, FileSystem, Size, FreeSpace " +
|
||||
"FROM Win32_LogicalDisk WHERE DriveType = 3");
|
||||
foreach (var mo in searcher.Get())
|
||||
{
|
||||
var size = ToLong(mo["Size"]);
|
||||
var free = ToLong(mo["FreeSpace"]);
|
||||
result.Add(new
|
||||
{
|
||||
drive = mo["DeviceID"]?.ToString()?.Trim() ?? "",
|
||||
label = mo["VolumeName"]?.ToString()?.Trim() ?? "",
|
||||
filesystem = mo["FileSystem"]?.ToString()?.Trim() ?? "",
|
||||
sizebytes = size,
|
||||
freebytes = free,
|
||||
usedbytes = size is long total && free is long available
|
||||
? total - available : (long?)null,
|
||||
});
|
||||
}
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
logger.LogDebug(error, "Disk enumeration blocked");
|
||||
}
|
||||
return result.ToArray();
|
||||
}
|
||||
|
||||
// Firmware identity from Win32_BIOS: vendor, version string, and release
|
||||
// date. Lets the server track BIOS/UEFI levels for a fleet update campaign.
|
||||
private object? GetBios()
|
||||
{
|
||||
try
|
||||
{
|
||||
using var searcher = new ManagementObjectSearcher(
|
||||
"SELECT Manufacturer, SMBIOSBIOSVersion, ReleaseDate FROM Win32_BIOS");
|
||||
foreach (var mo in searcher.Get())
|
||||
{
|
||||
return new
|
||||
{
|
||||
vendor = mo["Manufacturer"]?.ToString()?.Trim() ?? "",
|
||||
version = mo["SMBIOSBIOSVersion"]?.ToString()?.Trim() ?? "",
|
||||
releasedate = ToBiosDate(mo["ReleaseDate"]?.ToString()),
|
||||
};
|
||||
}
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
logger.LogDebug(error, "BIOS query failed");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Physical memory totals. TotalVisibleMemorySize/FreePhysicalMemory come
|
||||
// from Win32_OperatingSystem in kilobytes; normalize to bytes so the wire
|
||||
// stays in one unit with the disk figures.
|
||||
private object? GetMemory()
|
||||
{
|
||||
try
|
||||
{
|
||||
using var searcher = new ManagementObjectSearcher(
|
||||
"SELECT TotalVisibleMemorySize, FreePhysicalMemory " +
|
||||
"FROM Win32_OperatingSystem");
|
||||
foreach (var mo in searcher.Get())
|
||||
{
|
||||
var totalKb = ToLong(mo["TotalVisibleMemorySize"]);
|
||||
var freeKb = ToLong(mo["FreePhysicalMemory"]);
|
||||
return new
|
||||
{
|
||||
totalbytes = totalKb is long total ? total * 1024 : (long?)null,
|
||||
freebytes = freeKb is long free ? free * 1024 : (long?)null,
|
||||
};
|
||||
}
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
logger.LogDebug(error, "Memory query failed");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// CPU identity and core counts from Win32_Processor (first socket).
|
||||
private object? GetProcessor()
|
||||
{
|
||||
try
|
||||
{
|
||||
using var searcher = new ManagementObjectSearcher(
|
||||
"SELECT Name, NumberOfCores, NumberOfLogicalProcessors, " +
|
||||
"MaxClockSpeed FROM Win32_Processor");
|
||||
foreach (var mo in searcher.Get())
|
||||
{
|
||||
return new
|
||||
{
|
||||
name = mo["Name"]?.ToString()?.Trim() ?? "",
|
||||
cores = ToLong(mo["NumberOfCores"]),
|
||||
logicalprocessors = ToLong(mo["NumberOfLogicalProcessors"]),
|
||||
maxclockmhz = ToLong(mo["MaxClockSpeed"]),
|
||||
};
|
||||
}
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
logger.LogDebug(error, "Processor query failed");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// OS build/version detail beyond the caption already sent as osname. Lets
|
||||
// the server track patch level (build number) across the fleet.
|
||||
private object? GetOsInfo()
|
||||
{
|
||||
try
|
||||
{
|
||||
using var searcher = new ManagementObjectSearcher(
|
||||
"SELECT Version, BuildNumber, OSArchitecture, InstallDate " +
|
||||
"FROM Win32_OperatingSystem");
|
||||
foreach (var mo in searcher.Get())
|
||||
{
|
||||
return new
|
||||
{
|
||||
version = mo["Version"]?.ToString()?.Trim() ?? "",
|
||||
build = mo["BuildNumber"]?.ToString()?.Trim() ?? "",
|
||||
architecture = mo["OSArchitecture"]?.ToString()?.Trim() ?? "",
|
||||
installdate = ToBiosDate(mo["InstallDate"]?.ToString()),
|
||||
};
|
||||
}
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
logger.LogDebug(error, "OS info query failed");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// AD domain or workgroup this PC is joined to. PartOfDomain distinguishes a
|
||||
// domain-joined box from a workgroup one.
|
||||
private string? GetDomain()
|
||||
{
|
||||
try
|
||||
{
|
||||
using var searcher = new ManagementObjectSearcher(
|
||||
"SELECT Domain, PartOfDomain FROM Win32_ComputerSystem");
|
||||
foreach (var mo in searcher.Get())
|
||||
{
|
||||
var domain = mo["Domain"]?.ToString()?.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(domain))
|
||||
return domain;
|
||||
}
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
logger.LogDebug(error, "Domain query failed");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static long? ToLong(object? value)
|
||||
{
|
||||
if (value is null)
|
||||
return null;
|
||||
try { return Convert.ToInt64(value); }
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
// Win32 CIM datetimes (yyyymmddHHMMSS.ffffff+zzz) render as an ISO-8601 UTC
|
||||
// string; anything unparseable passes through untouched so nothing is lost.
|
||||
private string? ToBiosDate(string? raw)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw))
|
||||
return null;
|
||||
try
|
||||
{
|
||||
return ManagementDateTimeConverter.ToDateTime(raw)
|
||||
.ToUniversalTime().ToString("o");
|
||||
}
|
||||
catch
|
||||
{
|
||||
return raw.Trim();
|
||||
}
|
||||
}
|
||||
|
||||
private string? QueryFirst(string wql, string property)
|
||||
{
|
||||
try
|
||||
|
||||
@@ -18,7 +18,7 @@ namespace ShopDbEndpointAgent.Core;
|
||||
// Only process_start uses this. Stop/status/service actions stay as SYSTEM.
|
||||
public static class InteractiveProcessLauncher
|
||||
{
|
||||
public readonly record struct LaunchOutcome(bool Launched, string? Error);
|
||||
public readonly record struct LaunchOutcome(bool Launched, string? Error, int? ExitCode = null);
|
||||
|
||||
private const int TokenDuplicate = 0x0002;
|
||||
private const int TokenQuery = 0x0008;
|
||||
@@ -32,11 +32,28 @@ public static class InteractiveProcessLauncher
|
||||
|
||||
private const uint InvalidSessionId = 0xFFFFFFFF;
|
||||
|
||||
private const uint Infinite = 0xFFFFFFFF;
|
||||
|
||||
// Returns Launched=true when CreateProcessAsUser succeeds. Never throws;
|
||||
// failures come back as Launched=false with a descriptive Error so the
|
||||
// caller can surface a clean CommandResult.
|
||||
public static LaunchOutcome LaunchInActiveSession(
|
||||
string applicationPath, string? workingDirectory)
|
||||
=> LaunchInActiveSession(applicationPath, null, workingDirectory);
|
||||
|
||||
// Overload that also passes a command line (arguments). When commandLine is
|
||||
// supplied it is handed to CreateProcessAsUser as lpCommandLine with a null
|
||||
// lpApplicationName so the arguments are honored; the executable is resolved
|
||||
// from the command line's first token as usual.
|
||||
//
|
||||
// When waitForExit is true the launcher waits for the process to exit and
|
||||
// returns its real exit code in LaunchOutcome.ExitCode, so a caller that
|
||||
// needs the actual outcome (run_script in the user session) can report
|
||||
// completed/failed instead of fire-and-forget "launched". GUI callers
|
||||
// (process_start) leave it false and return as soon as the window is up.
|
||||
public static LaunchOutcome LaunchInActiveSession(
|
||||
string applicationPath, string? arguments, string? workingDirectory,
|
||||
bool waitForExit = false)
|
||||
{
|
||||
var sessionId = WTSGetActiveConsoleSessionId();
|
||||
if (sessionId == InvalidSessionId)
|
||||
@@ -75,10 +92,26 @@ public static class InteractiveProcessLauncher
|
||||
|
||||
var creationFlags = CreateUnicodeEnvironment | CreateNoWindow;
|
||||
|
||||
// With arguments, pass a single command line ("<exe> <args>") as
|
||||
// lpCommandLine and a null lpApplicationName so the arguments apply;
|
||||
// without arguments, keep the original applicationName form.
|
||||
string? applicationName;
|
||||
string? commandLine;
|
||||
if (string.IsNullOrWhiteSpace(arguments))
|
||||
{
|
||||
applicationName = applicationPath;
|
||||
commandLine = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
applicationName = null;
|
||||
commandLine = "\"" + applicationPath + "\" " + arguments;
|
||||
}
|
||||
|
||||
var launched = CreateProcessAsUser(
|
||||
duplicatedToken,
|
||||
applicationPath,
|
||||
null,
|
||||
applicationName,
|
||||
commandLine,
|
||||
IntPtr.Zero,
|
||||
IntPtr.Zero,
|
||||
false,
|
||||
@@ -95,12 +128,20 @@ public static class InteractiveProcessLauncher
|
||||
+ new Win32Exception(Marshal.GetLastWin32Error()).Message);
|
||||
}
|
||||
|
||||
int? exitCode = null;
|
||||
if (waitForExit && processInformation.hProcess != IntPtr.Zero)
|
||||
{
|
||||
WaitForSingleObject(processInformation.hProcess, Infinite);
|
||||
if (GetExitCodeProcess(processInformation.hProcess, out var code))
|
||||
exitCode = unchecked((int)code);
|
||||
}
|
||||
|
||||
if (processInformation.hProcess != IntPtr.Zero)
|
||||
CloseHandle(processInformation.hProcess);
|
||||
if (processInformation.hThread != IntPtr.Zero)
|
||||
CloseHandle(processInformation.hThread);
|
||||
|
||||
return new LaunchOutcome(true, null);
|
||||
return new LaunchOutcome(true, null, exitCode);
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -197,4 +238,10 @@ public static class InteractiveProcessLauncher
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
private static extern bool CloseHandle(IntPtr handle);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
private static extern uint WaitForSingleObject(IntPtr handle, uint milliseconds);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
private static extern bool GetExitCodeProcess(IntPtr process, out uint exitCode);
|
||||
}
|
||||
|
||||
@@ -2,4 +2,5 @@
|
||||
<Project Path="ShopDbEndpointAgent.Core/ShopDbEndpointAgent.Core.csproj" />
|
||||
<Project Path="ShopDbEndpointAgent.Tray/ShopDbEndpointAgent.Tray.csproj" />
|
||||
<Project Path="ShopDbEndpointAgent/ShopDbEndpointAgent.csproj" />
|
||||
<Project Path="ShopDbAdoptionTool/ShopDbAdoptionTool.csproj" />
|
||||
</Solution>
|
||||
|
||||
@@ -46,6 +46,13 @@ if (!config.IsUsable())
|
||||
return 1;
|
||||
}
|
||||
|
||||
var baseUrlProblem = config.BaseUrlProblem();
|
||||
if (baseUrlProblem is not null)
|
||||
{
|
||||
bootstrapLogger.LogError("Config rejected: {Problem}", baseUrlProblem);
|
||||
return 1;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (runOnce)
|
||||
|
||||
@@ -7,11 +7,19 @@
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>ShopDbEndpointAgent</RootNamespace>
|
||||
<AssemblyName>shopdbagent</AssemblyName>
|
||||
<!-- 1.2.0: process_start launches via a per-image path allowlist
|
||||
<!-- 1.4.1: run_script/update_app stage into a fixed, Defender-excludable
|
||||
folder (C:\ProgramData\ShopDB\EndpointAgent) instead of the LocalSystem
|
||||
temp path, and run_script user-context waits for exit and reports the
|
||||
real exit code instead of fire-and-forget "launched".
|
||||
1.4.0: run_script fetches a ShopDB-hosted script by digest and runs it
|
||||
in the script's configured context (system or the interactive user),
|
||||
replacing the local scriptpaths allowlist; update_app resolves an app-
|
||||
level or version-level install source.
|
||||
1.2.0: process_start launches via a per-image path allowlist
|
||||
(AgentConfig.LaunchPaths, shipped defaults overridable per site).
|
||||
1.1.0: generic, data-driven service_*/process_* actions carry their
|
||||
target over the wire, so new policies/apps need no agent rebuild. -->
|
||||
<Version>1.3.0</Version>
|
||||
<Version>1.4.1</Version>
|
||||
<InvariantGlobalization>true</InvariantGlobalization>
|
||||
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||
<SelfContained>true</SelfContained>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 = {}) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
46
migrations/versions/7d35_appversion_delivery_metadata.py
Normal file
46
migrations/versions/7d35_appversion_delivery_metadata.py
Normal 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')
|
||||
@@ -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')
|
||||
77
plugins/endpoints/ADOPTION-TOOL-SPIKE.md
Normal file
77
plugins/endpoints/ADOPTION-TOOL-SPIKE.md
Normal file
@@ -0,0 +1,77 @@
|
||||
# Adoption tool spike
|
||||
|
||||
Status: spike. Built on the `origin/main` baseline so it can be cherry-picked as
|
||||
a single unit once endpoint agent development is complete.
|
||||
|
||||
## What it is
|
||||
|
||||
A one-shot, agentless enrollment tool. An admin generates a small download from
|
||||
the Endpoints key settings page; running the tool once on a PC creates a ShopDB
|
||||
PC (Computer) and Endpoint entry using only the endpoints collector key. It is
|
||||
NOT the endpoint agent: it installs nothing, runs no service, and sends no
|
||||
ongoing telemetry. It targets sites that want an inventory entry without running
|
||||
the full agent.
|
||||
|
||||
## How it works
|
||||
|
||||
1. Admin generates or reuses an endpoint agent key (existing `/agentkey` flow).
|
||||
2. Admin opens the "Adoption tool" section, confirms the base URL (prefilled
|
||||
from the browser origin) and pastes the collector key (prefilled when a key
|
||||
was just generated, since the secret is only shown once).
|
||||
3. `POST /api/endpoints/adoptiontool/package` (admin-gated) returns a ZIP with
|
||||
`adopt.config.json`, `README.txt`, and `shopdb-adopt.exe` when a prebuilt
|
||||
binary is staged on the server.
|
||||
4. On the target PC, `shopdb-adopt.exe` reads the config beside it (or CLI args),
|
||||
then POSTs to `/api/collector/endpoints` with `X-API-Key` and `adopt=true`.
|
||||
The existing `_adopt_computer` path creates the Computer + EndpointState.
|
||||
|
||||
## Collection
|
||||
|
||||
The tool gathers the same identity, hardware, and SBOM subset the endpoint
|
||||
agent collects, using identical WMI queries and wire keys so the server stores
|
||||
it without translation:
|
||||
|
||||
- Identity: `hostname`, `loggedinuser`, `ipaddress`.
|
||||
- Hardware: `serialnumber` (Win32_BIOS), `vendorname` / `pctype` / `modelnumber`
|
||||
(Win32_ComputerSystem), `osname` and `lastboottime` (Win32_OperatingSystem).
|
||||
- SBOM: `applications` = both Uninstall registry hives (64-bit + WOW6432Node),
|
||||
de-duped `{displayname, displayversion, publisher}`. The server logs this as
|
||||
an unpersisted key today (same as the agent) until the SBOM store lands.
|
||||
|
||||
The tool requires administrator rights (enforced by its app manifest
|
||||
`requireAdministrator`, with a runtime elevation guard) because the machine
|
||||
Uninstall hives and full WMI need elevation, or collection would be silently
|
||||
partial.
|
||||
|
||||
## Cherry-pick boundary
|
||||
|
||||
The spike is deliberately isolated. Cherry-pick exactly:
|
||||
|
||||
- `agent/ShopDbAdoptionTool/` (new standalone tool project, incl. `app.manifest`)
|
||||
- `agent/ShopDbEndpointAgent.slnx` (one line adding the project)
|
||||
- `plugins/endpoints/api/adoption.py` (new module; attaches to `endpoints_bp`)
|
||||
- `plugins/endpoints/api/__init__.py` (one-line import to register the route)
|
||||
- `plugins/endpoints/frontend/views/AgentKeySettings.vue` (new UI section only)
|
||||
- `plugins/endpoints/ADOPTION-TOOL-SPIKE.md` (this file)
|
||||
|
||||
`plugins/endpoints/api/routes.py` is intentionally untouched so the delivery
|
||||
spike changes there do not tangle with this feature.
|
||||
|
||||
## Prebuilt exe distribution
|
||||
|
||||
The api container has no .NET SDK, so the package embeds the exe only when a
|
||||
prebuilt copy is staged at `instance/adoptiontool/shopdb-adopt.exe`. Otherwise
|
||||
the ZIP ships the config plus README (build the exe out of band, same model as
|
||||
the endpoint agent installer bundle). Follow-up: a CI job that publishes
|
||||
`shopdb-adopt.exe` and stages it into the image or instance path.
|
||||
|
||||
## Known follow-ups
|
||||
|
||||
- Agentless health signal: an adopted PC never reports again, so
|
||||
`EndpointState.lastreportedat` reflects only the adoption moment. The fleet
|
||||
health/dashboard logic should distinguish "agentless, adopted" from
|
||||
"agent expected but silent" so these PCs do not read as permanently failed.
|
||||
Left out of this spike to keep the server create-path unchanged for a clean
|
||||
cherry-pick; the tool does not send an `agentless` marker yet because the
|
||||
server does not persist one (`KNOWN_PAYLOAD_KEYS`).
|
||||
- CI packaging of the prebuilt exe (see above).
|
||||
112
plugins/endpoints/DELIVERY-TESTING-notes.md
Normal file
112
plugins/endpoints/DELIVERY-TESTING-notes.md
Normal 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.
|
||||
@@ -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']
|
||||
|
||||
107
plugins/endpoints/api/adoption.py
Normal file
107
plugins/endpoints/api/adoption.py
Normal file
@@ -0,0 +1,107 @@
|
||||
"""Endpoints adoption-tool packaging (SPIKE).
|
||||
|
||||
This module is deliberately self-contained so it can be cherry-picked onto
|
||||
main as a single unit. It attaches ONE admin-gated route to the existing
|
||||
endpoints blueprint and never edits routes.py.
|
||||
|
||||
The adoption tool is NOT the endpoint agent. It is a one-shot, agentless
|
||||
enrollment client: given only the endpoints collector key and this server's
|
||||
base URL, it creates a ShopDB PC (Computer) + Endpoint entry via the existing
|
||||
POST /api/collector/endpoints adopt path, then exits. This route packages that
|
||||
tool for download: a ZIP carrying the operator-supplied config, a README, and
|
||||
(when a prebuilt binary is staged on the server) the shopdb-adopt.exe.
|
||||
|
||||
The collector key is supplied by the admin and is NOT stored or logged here.
|
||||
The minted secret is unretrievable after generation, so the operator pastes it
|
||||
into the generator; it only lives inside the downloaded config file.
|
||||
"""
|
||||
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import zipfile
|
||||
|
||||
from flask import request, current_app, send_file
|
||||
|
||||
from shopdb.api import error_response, ErrorCodes, require_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')
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
440
plugins/endpoints/api/siteagent.py
Normal file
440
plugins/endpoints/api/siteagent.py
Normal 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')
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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' }
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -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; }
|
||||
|
||||
327
plugins/endpoints/frontend/views/DeliveryTestBench.vue
Normal file
327
plugins/endpoints/frontend/views/DeliveryTestBench.vue
Normal 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>
|
||||
@@ -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')
|
||||
@@ -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',
|
||||
]
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
189
scripts/uninstall-shopdbagent.ps1
Normal file
189
scripts/uninstall-shopdbagent.ps1
Normal 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'"
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
|
||||
@@ -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
206
shopdb/core/api/scripts.py
Normal 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)
|
||||
@@ -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',
|
||||
|
||||
@@ -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):
|
||||
|
||||
43
shopdb/core/models/script_library.py
Normal file
43
shopdb/core/models/script_library.py
Normal 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}>"
|
||||
@@ -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',
|
||||
|
||||
42
tests/test_core/test_appversion_delivery.py
Normal file
42
tests/test_core/test_appversion_delivery.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
69
tests/test_endpoint_command_reaper.py
Normal file
69
tests/test_endpoint_command_reaper.py
Normal 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
|
||||
50
tests/test_no_hidden_tests.py
Normal file
50
tests/test_no_hidden_tests.py
Normal 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).'
|
||||
)
|
||||
@@ -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.
|
||||
|
||||
87
tests/test_plugins/test_endpoint_applications_model.py
Normal file
87
tests/test_plugins/test_endpoint_applications_model.py
Normal 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
|
||||
81
tests/test_plugins/test_endpoint_delivery_descriptor.py
Normal file
81
tests/test_plugins/test_endpoint_delivery_descriptor.py
Normal 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
|
||||
@@ -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)
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user