From 6680c3c5de6398d0ed0224f1062a943c019318aa Mon Sep 17 00:00:00 2001 From: Cam Proudlock Date: Sun, 30 Aug 2026 19:41:15 -0400 Subject: [PATCH 1/9] 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. --- .gitignore | 47 ++ agent/.gitignore | 7 +- agent/ShopDbAdoptionTool/Program.cs | 358 ++++++++ .../ShopDbAdoptionTool.csproj | 30 + agent/ShopDbAdoptionTool/app.manifest | 20 + agent/ShopDbEndpointAgent.Core/AgentConfig.cs | 66 ++ .../CommandExecutor.cs | 447 +++++++++- .../ShopDbEndpointAgent.Core/CommandWorker.cs | 42 +- agent/ShopDbEndpointAgent.Core/Contracts.cs | 57 ++ .../EndpointCollector.cs | 265 ++++++ .../InteractiveProcessLauncher.cs | 55 +- agent/ShopDbEndpointAgent.slnx | 1 + agent/ShopDbEndpointAgent/Program.cs | 7 + .../ShopDbEndpointAgent.csproj | 12 +- docker-compose.yml | 35 + docs/openapi.json | 773 +++++++++++++++++- frontend/src/api/index.js | 37 + frontend/src/views/Dashboard.vue | 54 +- .../7d35_appversion_delivery_metadata.py | 46 ++ ..._scriptlibrary_and_app_installer_digest.py | 71 ++ plugins/endpoints/ADOPTION-TOOL-SPIKE.md | 77 ++ plugins/endpoints/DELIVERY-TESTING-notes.md | 112 +++ plugins/endpoints/api/__init__.py | 4 + plugins/endpoints/api/adoption.py | 107 +++ plugins/endpoints/api/routes.py | 393 ++++++++- plugins/endpoints/api/siteagent.py | 440 ++++++++++ plugins/endpoints/commands.py | 18 + plugins/endpoints/frontend/routes.js | 10 + .../frontend/views/AgentKeySettings.vue | 304 ++++++- .../frontend/views/DeliveryTestBench.vue | 327 ++++++++ .../versions/0004_endpoint_applications.py | 94 +++ plugins/endpoints/models/__init__.py | 8 +- plugins/endpoints/models/endpoint.py | 101 ++- plugins/endpoints/plugin.py | 174 +++- plugins/printedparts/tests/__init__.py | 0 scripts/gen_openapi.py | 8 +- scripts/uninstall-shopdbagent.ps1 | 189 +++++ shopdb/__init__.py | 8 +- shopdb/api/__init__.py | 2 + shopdb/core/api/__init__.py | 2 + shopdb/core/api/applications.py | 42 +- shopdb/core/api/dashboard.py | 33 + shopdb/core/api/scripts.py | 206 +++++ shopdb/core/models/__init__.py | 7 + shopdb/core/models/application.py | 15 +- shopdb/core/models/script_library.py | 43 + shopdb/plugins/alembic_template.py | 2 +- tests/test_core/test_appversion_delivery.py | 42 + tests/test_core/test_public_read_surface.py | 9 + tests/test_docs_publishable.py | 10 +- tests/test_docs_versions.py | 9 +- tests/test_endpoint_command_reaper.py | 69 ++ tests/test_no_hidden_tests.py | 50 ++ tests/test_plugin_migrations.py | 7 +- .../test_endpoint_applications_model.py | 87 ++ .../test_endpoint_delivery_descriptor.py | 81 ++ .../test_plugins/test_endpoints_plugin.py | 98 ++- .../test_plugins/test_printedparts_plugin.py | 0 tests/test_runtime_dependencies.py | 7 + 59 files changed, 5557 insertions(+), 68 deletions(-) create mode 100644 agent/ShopDbAdoptionTool/Program.cs create mode 100644 agent/ShopDbAdoptionTool/ShopDbAdoptionTool.csproj create mode 100644 agent/ShopDbAdoptionTool/app.manifest create mode 100644 migrations/versions/7d35_appversion_delivery_metadata.py create mode 100644 migrations/versions/7d36_scriptlibrary_and_app_installer_digest.py create mode 100644 plugins/endpoints/ADOPTION-TOOL-SPIKE.md create mode 100644 plugins/endpoints/DELIVERY-TESTING-notes.md create mode 100644 plugins/endpoints/api/adoption.py create mode 100644 plugins/endpoints/api/siteagent.py create mode 100644 plugins/endpoints/frontend/views/DeliveryTestBench.vue create mode 100644 plugins/endpoints/migrations/versions/0004_endpoint_applications.py delete mode 100644 plugins/printedparts/tests/__init__.py create mode 100644 scripts/uninstall-shopdbagent.ps1 create mode 100644 shopdb/core/api/scripts.py create mode 100644 shopdb/core/models/script_library.py create mode 100644 tests/test_core/test_appversion_delivery.py create mode 100644 tests/test_endpoint_command_reaper.py create mode 100644 tests/test_no_hidden_tests.py create mode 100644 tests/test_plugins/test_endpoint_applications_model.py create mode 100644 tests/test_plugins/test_endpoint_delivery_descriptor.py rename plugins/endpoints/tests/test_plugin.py => tests/test_plugins/test_endpoints_plugin.py (59%) rename plugins/printedparts/tests/test_plugin.py => tests/test_plugins/test_printedparts_plugin.py (100%) diff --git a/.gitignore b/.gitignore index 26c16f38..f2e77ab6 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/agent/.gitignore b/agent/.gitignore index aa65026a..43450ff8 100644 --- a/agent/.gitignore +++ b/agent/.gitignore @@ -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 diff --git a/agent/ShopDbAdoptionTool/Program.cs b/agent/ShopDbAdoptionTool/Program.cs new file mode 100644 index 00000000..c6126710 --- /dev/null +++ b/agent/ShopDbAdoptionTool/Program.cs @@ -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 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( + 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(); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + CollectUninstallHive(uninstall64, result, seen); + CollectUninstallHive(uninstall32, result, seen); + return result.ToArray(); + } + + private static void CollectUninstallHive(string subKey, List result, + HashSet 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; } +} diff --git a/agent/ShopDbAdoptionTool/ShopDbAdoptionTool.csproj b/agent/ShopDbAdoptionTool/ShopDbAdoptionTool.csproj new file mode 100644 index 00000000..ad644831 --- /dev/null +++ b/agent/ShopDbAdoptionTool/ShopDbAdoptionTool.csproj @@ -0,0 +1,30 @@ + + + + Exe + net8.0-windows + enable + enable + ShopDbAdoptionTool + shopdb-adopt + + 0.1.0 + true + win-x64 + true + true + true + true + none + app.manifest + + + + + + + diff --git a/agent/ShopDbAdoptionTool/app.manifest b/agent/ShopDbAdoptionTool/app.manifest new file mode 100644 index 00000000..3691a666 --- /dev/null +++ b/agent/ShopDbAdoptionTool/app.manifest @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + diff --git a/agent/ShopDbEndpointAgent.Core/AgentConfig.cs b/agent/ShopDbEndpointAgent.Core/AgentConfig.cs index f8a8b9f9..86672779 100644 --- a/agent/ShopDbEndpointAgent.Core/AgentConfig.cs +++ b/agent/ShopDbEndpointAgent.Core/AgentConfig.cs @@ -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 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 ResolveScriptPaths() + { + var resolved = new Dictionary(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 diff --git a/agent/ShopDbEndpointAgent.Core/CommandExecutor.cs b/agent/ShopDbEndpointAgent.Core/CommandExecutor.cs index c739bdc7..9ffae27c 100644 --- a/agent/ShopDbEndpointAgent.Core/CommandExecutor.cs +++ b/agent/ShopDbEndpointAgent.Core/CommandExecutor.cs @@ -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 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 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 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 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 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 ScriptDetail( + string name, ScriptDeliveryClaim? script, string? scriptPath, int? exitCode) + { + var detail = new Dictionary + { + ["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 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 /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 AppDetail( + AppDeliveryClaim? app, string? installerPath, int? exitCode) + { + var detail = new Dictionary { ["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 MtConnectStartAsync(CancellationToken token) diff --git a/agent/ShopDbEndpointAgent.Core/CommandWorker.cs b/agent/ShopDbEndpointAgent.Core/CommandWorker.cs index 9389d805..ec6dea0e 100644 --- a/agent/ShopDbEndpointAgent.Core/CommandWorker.cs +++ b/agent/ShopDbEndpointAgent.Core/CommandWorker.cs @@ -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 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) diff --git a/agent/ShopDbEndpointAgent.Core/Contracts.cs b/agent/ShopDbEndpointAgent.Core/Contracts.cs index cb504a36..d4c92a0a 100644 --- a/agent/ShopDbEndpointAgent.Core/Contracts.cs +++ b/agent/ShopDbEndpointAgent.Core/Contracts.cs @@ -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 } }. diff --git a/agent/ShopDbEndpointAgent.Core/EndpointCollector.cs b/agent/ShopDbEndpointAgent.Core/EndpointCollector.cs index 4154db63..c230d11b 100644 --- a/agent/ShopDbEndpointAgent.Core/EndpointCollector.cs +++ b/agent/ShopDbEndpointAgent.Core/EndpointCollector.cs @@ -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(); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + CollectUninstallHive(uninstall64, result, seen); + CollectUninstallHive(uninstall32, result, seen); + return result.ToArray(); + } + + private void CollectUninstallHive(string subKey, List result, + HashSet 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(); + 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 diff --git a/agent/ShopDbEndpointAgent.Core/InteractiveProcessLauncher.cs b/agent/ShopDbEndpointAgent.Core/InteractiveProcessLauncher.cs index 3b028bcb..326bdc07 100644 --- a/agent/ShopDbEndpointAgent.Core/InteractiveProcessLauncher.cs +++ b/agent/ShopDbEndpointAgent.Core/InteractiveProcessLauncher.cs @@ -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 (" ") 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); } diff --git a/agent/ShopDbEndpointAgent.slnx b/agent/ShopDbEndpointAgent.slnx index 1383261a..e58da209 100644 --- a/agent/ShopDbEndpointAgent.slnx +++ b/agent/ShopDbEndpointAgent.slnx @@ -2,4 +2,5 @@ + diff --git a/agent/ShopDbEndpointAgent/Program.cs b/agent/ShopDbEndpointAgent/Program.cs index c97a8839..aa75f556 100644 --- a/agent/ShopDbEndpointAgent/Program.cs +++ b/agent/ShopDbEndpointAgent/Program.cs @@ -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) diff --git a/agent/ShopDbEndpointAgent/ShopDbEndpointAgent.csproj b/agent/ShopDbEndpointAgent/ShopDbEndpointAgent.csproj index 3612a79a..9c7d55bf 100644 --- a/agent/ShopDbEndpointAgent/ShopDbEndpointAgent.csproj +++ b/agent/ShopDbEndpointAgent/ShopDbEndpointAgent.csproj @@ -7,11 +7,19 @@ enable ShopDbEndpointAgent shopdbagent - - 1.3.0 + 1.4.1 true win-x64 true diff --git a/docker-compose.yml b/docker-compose.yml index b8d54715..dc2e63e3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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, diff --git a/docs/openapi.json b/docs/openapi.json index b72ab67c..14eec236 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -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" + } + } + } + } + } } } } \ No newline at end of file diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index bc38bba6..e3eaa19d 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -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 = {}) { diff --git a/frontend/src/views/Dashboard.vue b/frontend/src/views/Dashboard.vue index ad886ee4..1555f7c3 100644 --- a/frontend/src/views/Dashboard.vue +++ b/frontend/src/views/Dashboard.vue @@ -69,14 +69,24 @@ {{ stats.totalassets || 0 }} - -

- {{ stats.activeassets || 0 }} in use - - - {{ stats.inrepair || 0 }} in repair + +

+ {{ row.name }} + {{ row.count }} +
+ + +

+ Active records only, dual-bay + pairs counted once. + + {{ stats.inactiveexcluded }} deactivated record{{ stats.inactiveexcluded === 1 ? '' : 's' }} + excluded.

@@ -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); } diff --git a/migrations/versions/7d35_appversion_delivery_metadata.py b/migrations/versions/7d35_appversion_delivery_metadata.py new file mode 100644 index 00000000..6252617e --- /dev/null +++ b/migrations/versions/7d35_appversion_delivery_metadata.py @@ -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') diff --git a/migrations/versions/7d36_scriptlibrary_and_app_installer_digest.py b/migrations/versions/7d36_scriptlibrary_and_app_installer_digest.py new file mode 100644 index 00000000..41ed8ad6 --- /dev/null +++ b/migrations/versions/7d36_scriptlibrary_and_app_installer_digest.py @@ -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') diff --git a/plugins/endpoints/ADOPTION-TOOL-SPIKE.md b/plugins/endpoints/ADOPTION-TOOL-SPIKE.md new file mode 100644 index 00000000..448c9f38 --- /dev/null +++ b/plugins/endpoints/ADOPTION-TOOL-SPIKE.md @@ -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). diff --git a/plugins/endpoints/DELIVERY-TESTING-notes.md b/plugins/endpoints/DELIVERY-TESTING-notes.md new file mode 100644 index 00000000..13a17060 --- /dev/null +++ b/plugins/endpoints/DELIVERY-TESTING-notes.md @@ -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//commands +{ "action": "run_script", "target": "remove-network-drives" } +{ "action": "update_app", "target": "" } +``` + +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//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/ (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//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. diff --git a/plugins/endpoints/api/__init__.py b/plugins/endpoints/api/__init__.py index 6ad50266..2fc14121 100644 --- a/plugins/endpoints/api/__init__.py +++ b/plugins/endpoints/api/__init__.py @@ -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'] diff --git a/plugins/endpoints/api/adoption.py b/plugins/endpoints/api/adoption.py new file mode 100644 index 00000000..eb59914a --- /dev/null +++ b/plugins/endpoints/api/adoption.py @@ -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') diff --git a/plugins/endpoints/api/routes.py b/plugins/endpoints/api/routes.py index 6032331b..ed8fb5ae 100644 --- a/plugins/endpoints/api/routes.py +++ b/plugins/endpoints/api/routes.py @@ -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//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/); + 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:" - the app's own uploaded installer (installersha256) + - "version:" - a specific AppVersion's pinned package + - bare "" - 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:, version:, 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/ 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:" 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: 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('//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/', 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/); + # 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/', methods=['GET']) +@endpoints_bp.route('/apppackage/app//', 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 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/', 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) + + diff --git a/plugins/endpoints/api/siteagent.py b/plugins/endpoints/api/siteagent.py new file mode 100644 index 00000000..502f06de --- /dev/null +++ b/plugins/endpoints/api/siteagent.py @@ -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://:' 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') diff --git a/plugins/endpoints/commands.py b/plugins/endpoints/commands.py index b846842e..fdf3f14c 100644 --- a/plugins/endpoints/commands.py +++ b/plugins/endpoints/commands.py @@ -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', } diff --git a/plugins/endpoints/frontend/routes.js b/plugins/endpoints/frontend/routes.js index 3e07f25d..bb9e2d28 100644 --- a/plugins/endpoints/frontend/routes.js +++ b/plugins/endpoints/frontend/routes.js @@ -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' } } ] diff --git a/plugins/endpoints/frontend/views/AgentKeySettings.vue b/plugins/endpoints/frontend/views/AgentKeySettings.vue index 8e67d7e5..539a1e04 100644 --- a/plugins/endpoints/frontend/views/AgentKeySettings.vue +++ b/plugins/endpoints/frontend/views/AgentKeySettings.vue @@ -14,6 +14,16 @@ ================================================================= -->

Key management

+

+ STORE YOUR KEY SECURELY. IT IS SHOWN ONLY ONCE AND CANNOT BE RECOVERED. +

+

+ Before you generate a key, open the ShopDB Secure Send + 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. +

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

Save this key now

- It will not be shown again. Configure it as the agent - X-API-Key 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. +

+

+ Recommended: before you leave this page, open the + ShopDB Secure Send 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. +

+

+ Configure it as the agent X-API-Key header value.

{{ secret }}

+ +
+

Adoption tool

+
+

+ 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. +

+

+ 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. +

+ + + + + + + +
+ +
+

{{ adoptmessage }}

+

{{ adopterror }}

+
+
+ @@ -145,18 +217,92 @@

Build site agent

- 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 appsettings.json, and an + install.ps1 that writes the credentials to the registry + (HKLM\SOFTWARE\GE\ShopDB) - the key never lands in + appsettings.

+

+ No agent binary is staged on this server. The package + will still build with config and installer, but you must drop + shopdbagent.exe beside them before deploying. Stage a + prebuilt agent under instance/siteagent/ to have it ship + in the download. +

+ + + + + + + + + + + + + +

- Coming soon. 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 X-API-Key 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 install.ps1. Leave empty when the + server uses a publicly trusted certificate. An upload takes precedence + over the bundled server certificate above.

- + +
+ +
+

{{ agentmessage }}

+

{{ agenterror }}

@@ -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 + } +} diff --git a/plugins/endpoints/migrations/versions/0004_endpoint_applications.py b/plugins/endpoints/migrations/versions/0004_endpoint_applications.py new file mode 100644 index 00000000..cd7f3e81 --- /dev/null +++ b/plugins/endpoints/migrations/versions/0004_endpoint_applications.py @@ -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') diff --git a/plugins/endpoints/models/__init__.py b/plugins/endpoints/models/__init__.py index 1e1fabaa..1e7936a9 100644 --- a/plugins/endpoints/models/__init__.py +++ b/plugins/endpoints/models/__init__.py @@ -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', +] diff --git a/plugins/endpoints/models/endpoint.py b/plugins/endpoints/models/endpoint.py index 0b0e4593..47a04e31 100644 --- a/plugins/endpoints/models/endpoint.py +++ b/plugins/endpoints/models/endpoint.py @@ -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"" + + 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), + } diff --git a/plugins/endpoints/plugin.py b/plugins/endpoints/plugin.py index 61d5732a..245eef0e 100644 --- a/plugins/endpoints/plugin.py +++ b/plugins/endpoints/plugin.py @@ -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: diff --git a/plugins/printedparts/tests/__init__.py b/plugins/printedparts/tests/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/scripts/gen_openapi.py b/scripts/gen_openapi.py index d86ffa06..c0692c37 100644 --- a/scripts/gen_openapi.py +++ b/scripts/gen_openapi.py @@ -59,7 +59,13 @@ def _product_version(): def _norm_path(p): # Flask / -> 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): diff --git a/scripts/uninstall-shopdbagent.ps1 b/scripts/uninstall-shopdbagent.ps1 new file mode 100644 index 00000000..b4819ff3 --- /dev/null +++ b/scripts/uninstall-shopdbagent.ps1 @@ -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'" diff --git a/shopdb/__init__.py b/shopdb/__init__.py index ed4572e5..01a3bac5 100644 --- a/shopdb/__init__.py +++ b/shopdb/__init__.py @@ -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', diff --git a/shopdb/api/__init__.py b/shopdb/api/__init__.py index a2b61390..6393ab64 100644 --- a/shopdb/api/__init__.py +++ b/shopdb/api/__init__.py @@ -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', diff --git a/shopdb/core/api/__init__.py b/shopdb/core/api/__init__.py index 633aef9d..3ebda32a 100644 --- a/shopdb/core/api/__init__.py +++ b/shopdb/core/api/__init__.py @@ -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', diff --git a/shopdb/core/api/applications.py b/shopdb/core/api/applications.py index 3ddd974e..6712a2d3 100644 --- a/shopdb/core/api/applications.py +++ b/shopdb/core/api/applications.py @@ -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') diff --git a/shopdb/core/api/dashboard.py b/shopdb/core/api/dashboard.py index d8350d15..5d197e22 100644 --- a/shopdb/core/api/dashboard.py +++ b/shopdb/core/api/dashboard.py @@ -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, diff --git a/shopdb/core/api/scripts.py b/shopdb/core/api/scripts.py new file mode 100644 index 00000000..3206835c --- /dev/null +++ b/shopdb/core/api/scripts.py @@ -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('/', 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('/', 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('/', 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('//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=. Stored as script-.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/', 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) diff --git a/shopdb/core/models/__init__.py b/shopdb/core/models/__init__.py index f570a00a..f3d1e732 100644 --- a/shopdb/core/models/__init__.py +++ b/shopdb/core/models/__init__.py @@ -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', diff --git a/shopdb/core/models/application.py b/shopdb/core/models/application.py index 1ddf72df..2526e573 100644 --- a/shopdb/core/models/application.py +++ b/shopdb/core/models/application.py @@ -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): diff --git a/shopdb/core/models/script_library.py b/shopdb/core/models/script_library.py new file mode 100644 index 00000000..2369b1ed --- /dev/null +++ b/shopdb/core/models/script_library.py @@ -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-.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" + + diff --git a/frontend/src/components/PluginAssetPanels.vue b/frontend/src/components/PluginAssetPanels.vue index d5b8ab92..abe4b013 100644 --- a/frontend/src/components/PluginAssetPanels.vue +++ b/frontend/src/components/PluginAssetPanels.vue @@ -2,7 +2,7 @@ -