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": {} }