From a24429046c3f4a7f24a53b9978d235f5dd10b162 Mon Sep 17 00:00:00 2001 From: "benjamin.baker" Date: Tue, 1 Sep 2026 15:45:30 -0400 Subject: [PATCH] Support launch-path process monitors in the endpoint agent Some monitored processes (for example c:\BladeRunner\BladeRunner.exe) need a known launch path so the agent can act on the policy, not just detect by image name. This threads launchpath through the agent config, contracts, collector client, and command executor, and bumps the agent to 1.6.1. --- agent/ShopDbEndpointAgent.Core/AgentConfig.cs | 58 ++++++++- .../CollectorClient.cs | 36 +++++ .../CommandExecutor.cs | 68 +++++++++- agent/ShopDbEndpointAgent.Core/Contracts.cs | 21 +++ .../LaunchPathPolicy.cs | 123 ++++++++++++++++++ .../TelemetryWorker.cs | 15 +++ .../ShopDbEndpointAgent.csproj | 19 ++- agent/ShopDbEndpointAgent/appsettings.json | 16 +-- 8 files changed, 336 insertions(+), 20 deletions(-) create mode 100644 agent/ShopDbEndpointAgent.Core/LaunchPathPolicy.cs diff --git a/agent/ShopDbEndpointAgent.Core/AgentConfig.cs b/agent/ShopDbEndpointAgent.Core/AgentConfig.cs index 86672779..991d857e 100644 --- a/agent/ShopDbEndpointAgent.Core/AgentConfig.cs +++ b/agent/ShopDbEndpointAgent.Core/AgentConfig.cs @@ -34,14 +34,23 @@ public sealed class AgentConfig [JsonPropertyName("monitors")] public MonitorConfig Monitors { get; set; } = new(); // Per-image launchable paths for the generic process_start action. The wire - // never carries a path (that would be a code-execution hole); Start sends - // only the image name and the agent maps it here to a known-good executable + // historically never carried a path; now a server-provisioned monitor MAY + // carry one, but the agent only accepts it after LaunchPathPolicy validates + // it against ApprovedLaunchRoots (see below). Start otherwise sends only the + // image name and the agent maps it here to a known-good executable // (or a .bat launcher). Anything not in the resolved map fails closed. A // site overrides or extends the shipped defaults in appsettings.json without // a rebuild. Keyed by process image name (case-insensitive, .exe optional). [JsonPropertyName("launchpaths")] public Dictionary LaunchPaths { get; set; } = new(); + // Approved root directories a server-provisioned launch path must sit under + // before the agent will honor it. Empty = fall back to LaunchPathPolicy's + // shipped org defaults (Program Files roots). A site (or the future Site + // Agent Configuration tool) can widen or narrow this without a rebuild. + [JsonPropertyName("approvedlaunchroots")] + public List ApprovedLaunchRoots { get; set; } = new(); + // Shipped defaults for the org's common shop-floor apps. A site's // appsettings.json LaunchPaths entries win over these on a per-image basis. // MTConnect is intentionally absent here: it is a Windows service controlled @@ -162,6 +171,42 @@ public sealed class MonitorConfig [JsonPropertyName("services")] public MonitorTarget[] Services { get; set; } = Array.Empty(); + + // Union the local (appsettings.json) watch list with the server-resolved one, + // keyed case-insensitively by wire name so a target watched both locally and + // by policy is not enumerated twice. Server entries win on the label (App) so + // a policy display name overrides a stale local one, but a local-only target + // is preserved - the server list augments, it does not replace. Nulls on + // either side degrade to the other, which is what keeps monitoring alive when + // a fetch fails. + public static MonitorConfig Merge(MonitorConfig? local, ResolvedMonitors? server) + { + return new MonitorConfig + { + Processes = MergeTargets(local?.Processes, server?.Processes), + Services = MergeTargets(local?.Services, server?.Services), + }; + } + + private static MonitorTarget[] MergeTargets( + MonitorTarget[]? local, MonitorTarget[]? server) + { + var byName = new Dictionary( + StringComparer.OrdinalIgnoreCase); + foreach (var target in local ?? Array.Empty()) + { + if (string.IsNullOrWhiteSpace(target?.Name)) + continue; + byName[target.Name.Trim()] = target; + } + foreach (var target in server ?? Array.Empty()) + { + if (string.IsNullOrWhiteSpace(target?.Name)) + continue; + byName[target.Name.Trim()] = target; + } + return byName.Values.ToArray(); + } } public sealed class MonitorTarget @@ -172,4 +217,13 @@ public sealed class MonitorTarget // Human-facing application label, optional. Falls back to Name when empty. [JsonPropertyName("app")] public string App { get; set; } = ""; + + // Optional server-provisioned launch path for a process monitor. Only set on + // process targets that a site leader configured a path for. The agent NEVER + // launches this blindly: LaunchPathPolicy validates it against an approved- + // root allowlist first, and a local appsettings.json launchpaths entry for + // the same image still wins. Null/empty means "no server path" - the agent + // falls back to its local launch map, and process_start fails closed when + // neither yields a validated path. + [JsonPropertyName("launchpath")] public string? LaunchPath { get; set; } } diff --git a/agent/ShopDbEndpointAgent.Core/CollectorClient.cs b/agent/ShopDbEndpointAgent.Core/CollectorClient.cs index 4c4d30d9..fa9f724e 100644 --- a/agent/ShopDbEndpointAgent.Core/CollectorClient.cs +++ b/agent/ShopDbEndpointAgent.Core/CollectorClient.cs @@ -89,6 +89,42 @@ public sealed class CollectorClient : IDisposable return envelope.Data; } + // Fetch the server-resolved watch list (policy-attached processes/services) + // for this hostname, GET /api/endpoints/monitors/resolved. Shares the same + // X-API-Key credential as telemetry. Best-effort: returns null on any + // failure so the caller falls back to the last-known/local list rather than + // blanking monitoring on a transient blip. A single attempt, no retry - the + // next telemetry cycle retries naturally, and a stale-but-usable watch list + // beats an empty one. + public async Task FetchResolvedMonitorsAsync( + string hostname, CancellationToken token) + { + var url = "api/endpoints/monitors/resolved?hostname=" + + Uri.EscapeDataString(hostname); + try + { + using var response = await http.GetAsync(url, token); + var body = await response.Content.ReadAsStringAsync(token); + if (!response.IsSuccessStatusCode) + { + logger.LogWarning( + "Resolved-monitors fetch returned HTTP {Status}: {Body}", + (int)response.StatusCode, body); + return null; + } + var envelope = JsonSerializer.Deserialize( + body, JsonOptions); + return envelope?.Data; + } + catch (Exception error) when ( + error is HttpRequestException or TaskCanceledException or JsonException + && !token.IsCancellationRequested) + { + logger.LogWarning(error, "Resolved-monitors fetch failed; keeping current watch list"); + return null; + } + } + private TimeSpan BackoffDelay(int attempt) { // 2^(attempt-1) seconds, capped, plus up to 1s jitter. diff --git a/agent/ShopDbEndpointAgent.Core/CommandExecutor.cs b/agent/ShopDbEndpointAgent.Core/CommandExecutor.cs index 9ffae27c..7db7a198 100644 --- a/agent/ShopDbEndpointAgent.Core/CommandExecutor.cs +++ b/agent/ShopDbEndpointAgent.Core/CommandExecutor.cs @@ -56,9 +56,14 @@ public sealed class CommandExecutor // Resolved per-image launchable paths for process_start (shipped defaults // overlaid with any appsettings.json overrides). A target absent from this - // map cannot be started - process_start fails closed. + // map - and without a validated server-provisioned path - cannot be started, + // so process_start fails closed. private readonly IReadOnlyDictionary launchPaths; + // Validates server-provisioned launch paths (from policy-attached monitors) + // before they are honored. Local launchPaths always win over a server path. + private readonly LaunchPathPolicy launchPathPolicy; + // 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. @@ -71,6 +76,7 @@ public sealed class CommandExecutor this.startTimeout = startTimeout ?? TimeSpan.FromSeconds(30); this.config = config ?? new AgentConfig(); this.launchPaths = this.config.ResolveLaunchPaths(); + this.launchPathPolicy = new LaunchPathPolicy(this.config.ApprovedLaunchRoots); } public async Task ExecuteAsync( @@ -366,8 +372,8 @@ public sealed class CommandExecutor if (bare.EndsWith(".exe", StringComparison.OrdinalIgnoreCase)) bare = bare[..^4]; - if (!launchPaths.TryGetValue(bare, out var launchPath) - || string.IsNullOrWhiteSpace(launchPath)) + var launchPath = ResolveLaunchPath(bare, image); + if (string.IsNullOrWhiteSpace(launchPath)) { logger.LogWarning( "Rejected process_start for {Image}: no launchable path configured", @@ -452,6 +458,62 @@ public sealed class CommandExecutor } } + // Resolves the launchable path for a process image. Precedence: + // 1. Local launch map (shipped defaults + appsettings.json). A site pin + // always wins so a server value never silently overrides it. + // 2. A server-provisioned path on the matching process monitor, but only + // after LaunchPathPolicy validates it (approved root, exists, launchable + // extension). A rejected server path is logged and dropped - the caller + // then fails closed exactly as before. + // Returns null when neither source yields a usable path. + private string? ResolveLaunchPath(string bareImage, string image) + { + if (launchPaths.TryGetValue(bareImage, out var localPath) + && !string.IsNullOrWhiteSpace(localPath)) + { + return localPath; + } + + var serverPath = ServerLaunchPathFor(image, bareImage); + if (string.IsNullOrWhiteSpace(serverPath)) + return null; + + if (launchPathPolicy.TryValidate(serverPath, out var validated, out var reason)) + return validated; + + logger.LogWarning( + "Rejected server-provisioned launch path for {Image} ({Path}): {Reason}", + image, serverPath, reason); + return null; + } + + // Finds the server-provisioned launch path carried on the process monitor + // whose wire name matches the image (case-insensitive, .exe optional). + private string? ServerLaunchPathFor(string image, string bareImage) + { + var processes = config.Monitors?.Processes; + if (processes is null) + return null; + + foreach (var target in processes) + { + if (string.IsNullOrWhiteSpace(target?.Name) + || string.IsNullOrWhiteSpace(target.LaunchPath)) + continue; + + var name = target.Name.Trim(); + var bareName = name.EndsWith(".exe", StringComparison.OrdinalIgnoreCase) + ? name[..^4] + : name; + if (string.Equals(name, image, StringComparison.OrdinalIgnoreCase) + || string.Equals(bareName, bareImage, StringComparison.OrdinalIgnoreCase)) + { + return target.LaunchPath; + } + } + return null; + } + private bool IsProcessRunning(string image) { var processes = FindProcessesByImage(image); diff --git a/agent/ShopDbEndpointAgent.Core/Contracts.cs b/agent/ShopDbEndpointAgent.Core/Contracts.cs index d4c92a0a..f389ce8b 100644 --- a/agent/ShopDbEndpointAgent.Core/Contracts.cs +++ b/agent/ShopDbEndpointAgent.Core/Contracts.cs @@ -147,3 +147,24 @@ public sealed class CommandResult [JsonPropertyName("mtconnectrunning")] public bool? MtConnectRunning { get; set; } [JsonPropertyName("detail")] public Dictionary? Detail { get; set; } } + +// The server-resolved watch list for this endpoint (processes and services the +// site's policy attaches to the box's PC type), fetched each telemetry cycle so +// a policy-attached monitor is enumerated without editing appsettings.json on +// the box. Same {name, app} shape as the local MonitorConfig, so the merge is a +// direct list union. Empty lists mean "watch nothing extra beyond local". +public sealed class ResolvedMonitors +{ + [JsonPropertyName("processes")] public MonitorTarget[] Processes { get; set; } + = Array.Empty(); + + [JsonPropertyName("services")] public MonitorTarget[] Services { get; set; } + = Array.Empty(); +} + +// Envelope for the resolved-monitors route: { status, data: { processes, services } }. +public sealed class ResolvedMonitorsEnvelope +{ + [JsonPropertyName("status")] public string Status { get; set; } = ""; + [JsonPropertyName("data")] public ResolvedMonitors? Data { get; set; } +} diff --git a/agent/ShopDbEndpointAgent.Core/LaunchPathPolicy.cs b/agent/ShopDbEndpointAgent.Core/LaunchPathPolicy.cs new file mode 100644 index 00000000..4976dc38 --- /dev/null +++ b/agent/ShopDbEndpointAgent.Core/LaunchPathPolicy.cs @@ -0,0 +1,123 @@ +using System.Text.Json.Serialization; + +namespace ShopDbEndpointAgent.Core; + +// Guards server-provisioned launch paths. Historically the command wire never +// carried a launch path at all - the agent mapped an image name to a known-good +// local path and anything else failed closed. Server-provisioned monitors now +// MAY carry a path a site leader set in the WebUI, which is a deliberate +// relaxation of that invariant. This policy is the replacement safeguard: a +// server path is only honored when it sits under an approved root, actually +// exists, and is an approved executable type. Everything else is rejected and +// the agent falls back to its local launch map (still fail closed). +// +// The approved-root list is org-opinionated with shipped defaults (the Windows +// Program Files roots), overridable per site via AgentConfig.ApprovedLaunchRoots +// so the future Site Agent Configuration tool can widen or narrow it without a +// rebuild. A local appsettings.json launchpaths entry still wins over any +// server path, so a site pin is never silently overridden. +public sealed class LaunchPathPolicy +{ + // Only these extensions are launchable. .bat is included because some shop- + // floor apps ship a launcher script (see the eDNC/UDC run.bat pattern). + private static readonly string[] ApprovedExtensions = + { ".exe", ".bat", ".cmd" }; + + // Shipped org defaults: the standard 64- and 32-bit install roots. Resolved + // from the environment so a non-C: system drive still matches. + public static IReadOnlyList DefaultApprovedRoots { get; } = + BuildDefaultRoots(); + + private readonly IReadOnlyList approvedRoots; + + public LaunchPathPolicy(IEnumerable? approvedRootsOverride = null) + { + var roots = (approvedRootsOverride ?? Enumerable.Empty()) + .Where(root => !string.IsNullOrWhiteSpace(root)) + .Select(NormalizeRoot) + .ToList(); + approvedRoots = roots.Count > 0 ? roots : DefaultApprovedRoots; + } + + // Result of validating a candidate path: the normalized full path when it is + // acceptable, or a human-readable reason when it is rejected. + public bool TryValidate(string? candidate, out string fullPath, out string reason) + { + fullPath = ""; + reason = ""; + + if (string.IsNullOrWhiteSpace(candidate)) + { + reason = "empty path"; + return false; + } + + string resolved; + try + { + resolved = Path.GetFullPath(candidate.Trim()); + } + catch (Exception ex) + { + reason = $"not a valid path: {ex.Message}"; + return false; + } + + var extension = Path.GetExtension(resolved); + if (!ApprovedExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase)) + { + reason = $"extension '{extension}' is not launchable"; + return false; + } + + if (!IsUnderApprovedRoot(resolved)) + { + reason = "path is outside every approved launch root"; + return false; + } + + if (!File.Exists(resolved)) + { + reason = "file does not exist"; + return false; + } + + fullPath = resolved; + return true; + } + + private bool IsUnderApprovedRoot(string resolved) + { + foreach (var root in approvedRoots) + { + // Compare against root + separator so "C:\Program Files" does not + // also match "C:\Program FilesEvil\...". Case-insensitive on Windows. + var prefix = root.EndsWith(Path.DirectorySeparatorChar) + ? root + : root + Path.DirectorySeparatorChar; + if (resolved.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + return true; + } + return false; + } + + private static string NormalizeRoot(string root) + { + var full = Path.GetFullPath(root.Trim()); + return full.TrimEnd(Path.DirectorySeparatorChar); + } + + private static IReadOnlyList BuildDefaultRoots() + { + var candidates = new[] + { + Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), + Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86), + }; + return candidates + .Where(path => !string.IsNullOrWhiteSpace(path)) + .Select(NormalizeRoot) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + } +} diff --git a/agent/ShopDbEndpointAgent.Core/TelemetryWorker.cs b/agent/ShopDbEndpointAgent.Core/TelemetryWorker.cs index 1fcd3412..2efa847d 100644 --- a/agent/ShopDbEndpointAgent.Core/TelemetryWorker.cs +++ b/agent/ShopDbEndpointAgent.Core/TelemetryWorker.cs @@ -96,6 +96,21 @@ public sealed class TelemetryWorker : BackgroundService // exact production collection path. public async Task RunOnceAsync(bool adopt, CancellationToken token) { + // Refresh the watch list from server policy BEFORE collecting so a + // policy-attached monitor (added in the WebUI, never in appsettings.json) + // is enumerated and reported this cycle. Server entries merge over the + // local list; a failed fetch returns null and Merge keeps the local list, + // so monitoring never blanks on a transient blip. + var resolved = await client.FetchResolvedMonitorsAsync( + Environment.MachineName, token); + if (resolved is not null) + { + config.Monitors = MonitorConfig.Merge(config.Monitors, resolved); + logger.LogDebug( + "Watch list refreshed from policy: {Processes} process(es), {Services} service(s)", + config.Monitors.Processes.Length, config.Monitors.Services.Length); + } + var report = collector.Collect(agentVersion, adopt); if (utilities is not null) diff --git a/agent/ShopDbEndpointAgent/ShopDbEndpointAgent.csproj b/agent/ShopDbEndpointAgent/ShopDbEndpointAgent.csproj index 9c7d55bf..c877a7fa 100644 --- a/agent/ShopDbEndpointAgent/ShopDbEndpointAgent.csproj +++ b/agent/ShopDbEndpointAgent/ShopDbEndpointAgent.csproj @@ -7,7 +7,22 @@ enable ShopDbEndpointAgent shopdbagent - - 1.4.1 + 1.6.1 true win-x64 true diff --git a/agent/ShopDbEndpointAgent/appsettings.json b/agent/ShopDbEndpointAgent/appsettings.json index 162913e3..6db6ca67 100644 --- a/agent/ShopDbEndpointAgent/appsettings.json +++ b/agent/ShopDbEndpointAgent/appsettings.json @@ -7,20 +7,10 @@ "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" } - ] + "processes": [], + "services": [] }, - "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" - } + "launchpaths": {} }