Merge the wilm work, act on the five-agent review, verify the launch-path policy on Windows
This commit is contained in:
@@ -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<string, string> 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<string> 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<MonitorTarget>();
|
||||
|
||||
// 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<string, MonitorTarget>(
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var target in local ?? Array.Empty<MonitorTarget>())
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(target?.Name))
|
||||
continue;
|
||||
byName[target.Name.Trim()] = target;
|
||||
}
|
||||
foreach (var target in server ?? Array.Empty<MonitorTarget>())
|
||||
{
|
||||
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; }
|
||||
}
|
||||
|
||||
@@ -34,7 +34,15 @@ public sealed class AgentFaultMonitor
|
||||
}
|
||||
|
||||
public void ReportFatal(string context, Exception error)
|
||||
=> Report(EventLogEntryType.Error, 1000, $"{context}: {error}");
|
||||
{
|
||||
// File log first, always: the Event Log sink below is admin-gated (the
|
||||
// source cannot be created without elevation), so an unelevated run such
|
||||
// as the tray-launched --once/--report path would otherwise fail with no
|
||||
// trace anywhere. The rolling file log is the one sink guaranteed to work
|
||||
// in every context.
|
||||
logger.LogError(error, "Fatal in {Context}", context);
|
||||
Report(EventLogEntryType.Error, 1000, $"{context}: {error}");
|
||||
}
|
||||
|
||||
private void OnUnhandledException(object sender, UnhandledExceptionEventArgs args)
|
||||
{
|
||||
|
||||
@@ -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<ResolvedMonitors?> 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<ResolvedMonitorsEnvelope>(
|
||||
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.
|
||||
@@ -96,6 +132,53 @@ public sealed class CollectorClient : IDisposable
|
||||
return TimeSpan.FromSeconds(seconds + jitter.NextDouble());
|
||||
}
|
||||
|
||||
// Uploads one operator issue report (free-text reason/details plus an
|
||||
// optional PNG screenshot) to POST /api/endpoints/operatorreports as
|
||||
// multipart/form-data, authorized by the same X-API-Key collector key as
|
||||
// telemetry. The server resolves this hostname to its asset. A single
|
||||
// attempt: the tray shows the operator the outcome and can retry by hand,
|
||||
// and a stuck retry loop holding a screenshot in memory is worse than a
|
||||
// clear failure. Returns the parsed upload response (new row id) or throws.
|
||||
public async Task<OperatorReportUploadResponse?> UploadOperatorReportAsync(
|
||||
string reason, string details, string? screenshotPath, string hostname,
|
||||
CancellationToken token)
|
||||
{
|
||||
using var form = new MultipartFormDataContent();
|
||||
form.Add(new StringContent(reason ?? string.Empty), "reason");
|
||||
form.Add(new StringContent(details ?? string.Empty), "details");
|
||||
form.Add(new StringContent(hostname ?? string.Empty), "hostname");
|
||||
|
||||
StreamContent? imageContent = null;
|
||||
FileStream? imageStream = null;
|
||||
try
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(screenshotPath) && File.Exists(screenshotPath))
|
||||
{
|
||||
imageStream = File.OpenRead(screenshotPath);
|
||||
imageContent = new StreamContent(imageStream);
|
||||
imageContent.Headers.ContentType =
|
||||
new System.Net.Http.Headers.MediaTypeHeaderValue("image/png");
|
||||
form.Add(imageContent, "screenshot",
|
||||
Path.GetFileName(screenshotPath));
|
||||
}
|
||||
|
||||
using var response = await http.PostAsync(
|
||||
"api/endpoints/operatorreports", form, token);
|
||||
var body = await response.Content.ReadAsStringAsync(token);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
throw new CollectorException((int)response.StatusCode, body);
|
||||
|
||||
var envelope = JsonSerializer.Deserialize<OperatorReportUploadEnvelope>(
|
||||
body, JsonOptions);
|
||||
return envelope?.Data;
|
||||
}
|
||||
finally
|
||||
{
|
||||
imageContent?.Dispose();
|
||||
imageStream?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose() => http.Dispose();
|
||||
}
|
||||
|
||||
|
||||
@@ -22,12 +22,26 @@ public sealed class CommandClient : IDisposable
|
||||
};
|
||||
|
||||
private readonly HttpClient http;
|
||||
private readonly SocketsHttpHandler handler;
|
||||
private readonly ILogger<CommandClient> logger;
|
||||
|
||||
public CommandClient(AgentConfig config, ILogger<CommandClient> logger)
|
||||
{
|
||||
this.logger = logger;
|
||||
http = new HttpClient
|
||||
// The command channel long-polls: the server holds each claim open for a
|
||||
// hold window and then, between windows, the pooled TLS connection sits
|
||||
// idle. Gunicorn's keep-alive closes an idle socket after a couple of
|
||||
// seconds, so a connection reused after that yields "unexpected EOF or 0
|
||||
// bytes from the transport stream" on the next TLS handshake. Retire
|
||||
// pooled connections faster than the server's keep-alive so a socket the
|
||||
// server has already dropped is never handed back out; the lifetime cap
|
||||
// is a hard backstop so no connection lingers indefinitely.
|
||||
handler = new SocketsHttpHandler
|
||||
{
|
||||
PooledConnectionIdleTimeout = TimeSpan.FromSeconds(1),
|
||||
PooledConnectionLifetime = TimeSpan.FromMinutes(2),
|
||||
};
|
||||
http = new HttpClient(handler, disposeHandler: true)
|
||||
{
|
||||
BaseAddress = new Uri(config.BaseUrl.TrimEnd('/') + "/"),
|
||||
// Longer than the collector client: the claim request is held open
|
||||
|
||||
@@ -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<string, string> 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<CommandResult> 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);
|
||||
|
||||
@@ -72,6 +72,20 @@ public sealed class CollectorEnvelope
|
||||
[JsonPropertyName("data")] public CollectorResponse? Data { get; set; }
|
||||
}
|
||||
|
||||
// Server response to an operator issue report upload. Carries the new row id so
|
||||
// the agent can log it for correlation.
|
||||
public sealed class OperatorReportUploadResponse
|
||||
{
|
||||
[JsonPropertyName("operatorreportid")] public int? OperatorReportId { get; set; }
|
||||
[JsonPropertyName("assetid")] public int? AssetId { get; set; }
|
||||
}
|
||||
|
||||
public sealed class OperatorReportUploadEnvelope
|
||||
{
|
||||
[JsonPropertyName("status")] public string Status { get; set; } = "";
|
||||
[JsonPropertyName("data")] public OperatorReportUploadResponse? Data { get; set; }
|
||||
}
|
||||
|
||||
// A command the server claimed for this endpoint on the long-poll. The
|
||||
// allowlisted action name crosses the wire (see AGENT-CONTRACT.md section 7);
|
||||
// generic actions (service_* / process_*) also carry the subject to act on -
|
||||
@@ -147,3 +161,24 @@ public sealed class CommandResult
|
||||
[JsonPropertyName("mtconnectrunning")] public bool? MtConnectRunning { get; set; }
|
||||
[JsonPropertyName("detail")] public Dictionary<string, object>? 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<MonitorTarget>();
|
||||
|
||||
[JsonPropertyName("services")] public MonitorTarget[] Services { get; set; }
|
||||
= Array.Empty<MonitorTarget>();
|
||||
}
|
||||
|
||||
// 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; }
|
||||
}
|
||||
|
||||
238
agent/ShopDbEndpointAgent.Core/LaunchPathPolicy.cs
Normal file
238
agent/ShopDbEndpointAgent.Core/LaunchPathPolicy.cs
Normal file
@@ -0,0 +1,238 @@
|
||||
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<string> DefaultApprovedRoots { get; } =
|
||||
BuildDefaultRoots();
|
||||
|
||||
private readonly IReadOnlyList<string> approvedRoots;
|
||||
|
||||
public LaunchPathPolicy(IEnumerable<string>? approvedRootsOverride = null)
|
||||
{
|
||||
var roots = (approvedRootsOverride ?? Enumerable.Empty<string>())
|
||||
.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;
|
||||
}
|
||||
|
||||
// Reject an alternate data stream before anything else looks at the
|
||||
// path. On .NET Framework an interior colon made GetFullPath throw; on
|
||||
// net8.0 it does not, so "readme.txt:payload.exe" survives, reports an
|
||||
// .exe extension, passes the root check, and File.Exists returns true
|
||||
// for the stream. CreateProcess will run it. Write access to any
|
||||
// existing file under an approved root would otherwise be enough.
|
||||
var lastSegment = Path.GetFileName(resolved);
|
||||
if (lastSegment.Contains(':'))
|
||||
{
|
||||
reason = "path names an alternate data stream";
|
||||
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;
|
||||
}
|
||||
|
||||
// Everything above is LEXICAL: GetFullPath is pure string normalization
|
||||
// and never touches the disk, so the root check so far constrains the
|
||||
// name, not the file. File.Exists follows reparse points, which means a
|
||||
// junction anywhere under an approved root pointed the check at one
|
||||
// place and the execution at another. Creating a junction needs no
|
||||
// privilege, only write access to the parent - and vendor install trees
|
||||
// under Program Files are not reliably locked down.
|
||||
//
|
||||
// So resolve what the path actually refers to and re-check THAT. A
|
||||
// resolve failure rejects: this is the last gate before the agent runs
|
||||
// the file.
|
||||
string finalPath;
|
||||
try
|
||||
{
|
||||
finalPath = ResolveFinalPath(resolved);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
reason = $"cannot resolve the real path: {ex.Message}";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!IsUnderApprovedRoot(finalPath))
|
||||
{
|
||||
reason = "path resolves outside every approved launch root";
|
||||
return false;
|
||||
}
|
||||
|
||||
fullPath = finalPath;
|
||||
return true;
|
||||
}
|
||||
|
||||
// The path with every symlink and junction on it followed, so the caller
|
||||
// checks the file that will actually execute. The leaf is resolved first,
|
||||
// then the directory chain above it - a junction is usually placed on a
|
||||
// DIRECTORY, so resolving only the leaf would miss the case this exists to
|
||||
// catch.
|
||||
private static string ResolveFinalPath(string resolved)
|
||||
{
|
||||
string? name;
|
||||
try
|
||||
{
|
||||
name = System.IO.File.ResolveLinkTarget(resolved, returnFinalTarget: true)?.FullName;
|
||||
}
|
||||
catch
|
||||
{
|
||||
name = null;
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
// ResolveLinkTarget resolves the file itself; run it back through
|
||||
// GetFullPath so the comparison sees the same shape as the roots.
|
||||
return Path.GetFullPath(name);
|
||||
}
|
||||
|
||||
// Not a link. Its directory chain still might be, so resolve that.
|
||||
var directory = Path.GetDirectoryName(resolved);
|
||||
if (string.IsNullOrEmpty(directory))
|
||||
return resolved;
|
||||
var realDirectory = ResolveDirectoryChain(directory);
|
||||
return Path.GetFullPath(Path.Combine(realDirectory, Path.GetFileName(resolved)));
|
||||
}
|
||||
|
||||
// Walk up the directory chain resolving any junction or symlink on it. A
|
||||
// junction is usually placed on a DIRECTORY, not on the executable, so
|
||||
// resolving only the leaf would miss the case this exists to catch.
|
||||
private static string ResolveDirectoryChain(string directory)
|
||||
{
|
||||
// Stop at the volume root. Walking into it and asking whether "C:\" is
|
||||
// a link throws DirectoryNotFoundException, which the caller turned
|
||||
// into a rejection - so the first version of this rejected every
|
||||
// legitimate path as well as the malicious ones. A fail-closed gate
|
||||
// that also fails the normal case is just an outage. Caught in the VM,
|
||||
// not here.
|
||||
var parent = Path.GetDirectoryName(directory);
|
||||
if (string.IsNullOrEmpty(parent) || parent == directory)
|
||||
return directory;
|
||||
|
||||
var resolvedDirectory = TryResolveLink(directory);
|
||||
if (resolvedDirectory is not null)
|
||||
return resolvedDirectory;
|
||||
|
||||
var realParent = ResolveDirectoryChain(parent);
|
||||
return realParent == parent
|
||||
? directory
|
||||
: Path.Combine(realParent, Path.GetFileName(directory));
|
||||
}
|
||||
|
||||
// A component's link target, or null when it is not a link OR cannot be
|
||||
// asked. Never throws: "cannot tell" must not be reported as "is a link",
|
||||
// and the caller re-checks the assembled path against the roots regardless,
|
||||
// so an unresolvable component cannot widen what is accepted.
|
||||
private static string? TryResolveLink(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
var target = System.IO.Directory
|
||||
.ResolveLinkTarget(path, returnFinalTarget: true)?.FullName;
|
||||
return string.IsNullOrWhiteSpace(target) ? null : target;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
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<string> 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();
|
||||
}
|
||||
}
|
||||
52
agent/ShopDbEndpointAgent.Core/OperatorReportPayload.cs
Normal file
52
agent/ShopDbEndpointAgent.Core/OperatorReportPayload.cs
Normal file
@@ -0,0 +1,52 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace ShopDbEndpointAgent.Core;
|
||||
|
||||
// The on-disk hand-off between the interactive tray (which captures the
|
||||
// operator's screen and notes) and the headless service binary (which holds the
|
||||
// collector key and performs the upload). The tray writes this JSON sidecar to a
|
||||
// per-run temp folder and invokes `shopdbagent.exe --report <path>`; the agent
|
||||
// reads it, uploads, and deletes the folder. Keeping the secret in the service
|
||||
// preserves the "tray never holds a credential" rule already used by --once.
|
||||
public sealed class OperatorReportPayload
|
||||
{
|
||||
[JsonPropertyName("reason")]
|
||||
public string Reason { get; set; } = "Other (free text)";
|
||||
|
||||
[JsonPropertyName("details")]
|
||||
public string Details { get; set; } = string.Empty;
|
||||
|
||||
// Absolute path to the captured PNG, or null when the operator declined a
|
||||
// screenshot. Written by the tray into the interactive operator's
|
||||
// %USERPROFILE%\ShopDBScreenshots folder and passed through as an absolute
|
||||
// path; the agent reads it verbatim and never re-derives the location (as
|
||||
// SYSTEM its own profile would resolve elsewhere).
|
||||
[JsonPropertyName("screenshotpath")]
|
||||
public string? ScreenshotPath { get; set; }
|
||||
|
||||
private static readonly JsonSerializerOptions Options = new()
|
||||
{
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = true,
|
||||
};
|
||||
|
||||
public static void Write(string filePath, string reason, string details,
|
||||
string? screenshotPath)
|
||||
{
|
||||
var payload = new OperatorReportPayload
|
||||
{
|
||||
Reason = reason,
|
||||
Details = details,
|
||||
ScreenshotPath = screenshotPath,
|
||||
};
|
||||
File.WriteAllText(filePath, JsonSerializer.Serialize(payload, Options));
|
||||
}
|
||||
|
||||
public static OperatorReportPayload Read(string filePath)
|
||||
{
|
||||
var json = File.ReadAllText(filePath);
|
||||
return JsonSerializer.Deserialize<OperatorReportPayload>(json, Options)
|
||||
?? throw new InvalidDataException("empty or invalid report payload");
|
||||
}
|
||||
}
|
||||
@@ -96,6 +96,21 @@ public sealed class TelemetryWorker : BackgroundService
|
||||
// exact production collection path.
|
||||
public async Task<CollectorResponse> 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)
|
||||
|
||||
97
agent/ShopDbEndpointAgent.PolicyProbe/Program.cs
Normal file
97
agent/ShopDbEndpointAgent.PolicyProbe/Program.cs
Normal file
@@ -0,0 +1,97 @@
|
||||
using ShopDbEndpointAgent.Core;
|
||||
|
||||
// Planted-violation suite for LaunchPathPolicy, run on real Windows because the
|
||||
// cases that matter (junctions, alternate data streams, 8.3 names) are
|
||||
// filesystem behaviour, not string behaviour. Linux would prove nothing.
|
||||
//
|
||||
// Builds its own fixtures under an approved root, so it exercises the same
|
||||
// shape as a real shop-floor install rather than a synthetic path.
|
||||
|
||||
int failures = 0;
|
||||
|
||||
void Check(string label, bool expectAccept, string candidate, LaunchPathPolicy policy)
|
||||
{
|
||||
var ok = policy.TryValidate(candidate, out var full, out var reason);
|
||||
var pass = ok == expectAccept;
|
||||
if (!pass) failures++;
|
||||
var verdict = pass ? "PASS" : "FAIL";
|
||||
var got = ok ? $"accepted -> {full}" : $"rejected ({reason})";
|
||||
Console.WriteLine($"{verdict} {label}");
|
||||
Console.WriteLine($" want={(expectAccept ? "accept" : "reject")} got={got}");
|
||||
}
|
||||
|
||||
var root = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "LppFixture");
|
||||
var outside = Path.Combine(Path.GetTempPath(), "LppOutside");
|
||||
Directory.CreateDirectory(root);
|
||||
Directory.CreateDirectory(outside);
|
||||
|
||||
var policy = new LaunchPathPolicy(new[] { root });
|
||||
|
||||
// A real executable inside the approved root.
|
||||
var good = Path.Combine(root, "good.exe");
|
||||
File.WriteAllBytes(good, new byte[] { 0x4D, 0x5A });
|
||||
|
||||
// The payload the attacker wants run, planted OUTSIDE the approved root.
|
||||
var payload = Path.Combine(outside, "payload.exe");
|
||||
File.WriteAllBytes(payload, new byte[] { 0x4D, 0x5A });
|
||||
|
||||
// A text file inside the root, to hang an alternate data stream off.
|
||||
var carrier = Path.Combine(root, "readme.txt");
|
||||
File.WriteAllText(carrier, "notes");
|
||||
|
||||
Console.WriteLine($"approved root : {root}");
|
||||
Console.WriteLine($"outside dir : {outside}");
|
||||
Console.WriteLine();
|
||||
|
||||
Check("a real exe under the approved root", true, good, policy);
|
||||
Check("wrong extension", false, carrier, policy);
|
||||
Check("file that does not exist", false, Path.Combine(root, "nope.exe"), policy);
|
||||
Check("traversal back out of the root", false,
|
||||
Path.Combine(root, "..", "..", "Windows", "System32", "cmd.exe"), policy);
|
||||
Check("UNC path", false, @"\\attacker\share\payload.exe", policy);
|
||||
Check("sibling directory sharing the root's prefix", false, root + "Evil\\payload.exe", policy);
|
||||
Check("empty path", false, "", policy);
|
||||
|
||||
// --- alternate data stream --------------------------------------------------
|
||||
try
|
||||
{
|
||||
File.WriteAllBytes(carrier + ":payload.exe", new byte[] { 0x4D, 0x5A });
|
||||
Check("alternate data stream on a file in the root", false,
|
||||
carrier + ":payload.exe", policy);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"SKIP alternate data stream fixture: {ex.Message}");
|
||||
}
|
||||
|
||||
// --- directory junction out of the root ------------------------------------
|
||||
var junction = Path.Combine(root, "logs");
|
||||
var mk = System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
|
||||
{
|
||||
FileName = "cmd.exe",
|
||||
Arguments = $"/c mklink /J \"{junction}\" \"{outside}\"",
|
||||
UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true,
|
||||
});
|
||||
mk!.WaitForExit();
|
||||
if (mk.ExitCode == 0)
|
||||
{
|
||||
Check("exe reached through a junction pointing out of the root", false,
|
||||
Path.Combine(junction, "payload.exe"), policy);
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine($"SKIP junction fixture: mklink exit {mk.ExitCode} {mk.StandardError.ReadToEnd().Trim()}");
|
||||
}
|
||||
|
||||
// --- 8.3 short name ---------------------------------------------------------
|
||||
// 8.3 short names used to be rejected, because the lexical prefix check could
|
||||
// not match "C:\PROGRA~1" against "C:\Program Files" - fail closed, but only by
|
||||
// accident of not understanding the path. Resolution now expands it to the real
|
||||
// directory, which IS under the approved root, so accepting is correct: the
|
||||
// check that matters runs on the resolved path either way.
|
||||
var shortRoot = Path.Combine(@"C:\PROGRA~1", "LppFixture", "good.exe");
|
||||
Check("8.3 short name resolving into the approved root", true, shortRoot, policy);
|
||||
|
||||
Console.WriteLine();
|
||||
Console.WriteLine(failures == 0 ? "ALL EXPECTATIONS MET" : $"{failures} EXPECTATION(S) NOT MET");
|
||||
return failures == 0 ? 0 : 1;
|
||||
33
agent/ShopDbEndpointAgent.PolicyProbe/README.md
Normal file
33
agent/ShopDbEndpointAgent.PolicyProbe/README.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# LaunchPathPolicy probe
|
||||
|
||||
A planted-violation probe for `LaunchPathPolicy`. It builds real fixtures under
|
||||
a real approved root - a junction pointing out of it, an alternate data stream,
|
||||
an 8.3 short name - and asserts what the policy does with each.
|
||||
|
||||
## Why it is not a unit test in CI
|
||||
|
||||
The cases worth testing are filesystem behaviour, not string behaviour:
|
||||
junctions, alternate data streams and 8.3 short names do not exist on Linux, and
|
||||
CI runs on Linux runners. A suite that skipped every meaningful case there would
|
||||
report green and mean nothing, which is the failure shape this repo has already
|
||||
been bitten by more than once. So it is a probe you run on Windows, by hand,
|
||||
when the policy changes.
|
||||
|
||||
## Running it
|
||||
|
||||
Publish from the dev box and run it in the win11 VM:
|
||||
|
||||
dotnet publish agent/ShopDbEndpointAgent.PolicyProbe -c Release -r win-x64 \
|
||||
--self-contained true -p:PublishSingleFile=true -o /tmp/lpp
|
||||
|
||||
Copy `lpp/lpptest.exe` into the VM and run it as an account that may create a
|
||||
junction under Program Files. Exit code 0 means every expectation held; it
|
||||
prints one line per case either way.
|
||||
|
||||
## What it caught
|
||||
|
||||
The first version of the link-resolving hardening recursed to the volume root
|
||||
and asked whether `C:\` was a link. That throws, and the caller treated a
|
||||
resolve failure as a rejection - so the policy rejected every legitimate launch
|
||||
path as well as the malicious ones. It compiled cleanly and read correctly. Only
|
||||
running it on Windows showed the agent would have failed closed on everything.
|
||||
@@ -0,0 +1,20 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<!-- Planted-violation probe for LaunchPathPolicy. See README.md next to this
|
||||
file: it is run BY HAND on Windows, not by CI, because the cases it
|
||||
exists to catch are filesystem behaviour and CI runs on Linux.
|
||||
|
||||
net8.0, not net8.0-windows: the policy itself only touches System.IO, and
|
||||
a plain target cross-publishes from the Linux dev box. -->
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<AssemblyName>lpptest</AssemblyName>
|
||||
<RootNamespace>LppTest</RootNamespace>
|
||||
<InvariantGlobalization>true</InvariantGlobalization>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<Compile Include="../ShopDbEndpointAgent.Core/LaunchPathPolicy.cs" Link="LaunchPathPolicy.cs" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -1,6 +1,8 @@
|
||||
using System.Threading;
|
||||
|
||||
namespace ShopDbEndpointAgent.Tray;
|
||||
|
||||
// Interactive user-session tray app (SCAFFOLD). Separate process from the
|
||||
// Interactive user-session tray app (SCAFFOLD).
|
||||
// headless Windows Service: the service owns telemetry, this owns the operator
|
||||
// surface - a system-tray icon and the 5-click-max ServiceNow ticket flow
|
||||
// (DESIGN-DECISIONS-agent.md section 6). This iteration wires the tray and a
|
||||
@@ -8,9 +10,35 @@ namespace ShopDbEndpointAgent.Tray;
|
||||
// is deferred until that decision is made server-side.
|
||||
internal static class Program
|
||||
{
|
||||
// Machine-wide single-instance guard. The installer starts the tray from an
|
||||
// HKLM Run value for every user at logon, and an operator can also launch it
|
||||
// from the Start Menu shortcut, so a second copy is easy to spawn. A named
|
||||
// mutex in the Global namespace keeps exactly one tray per session: a later
|
||||
// launch that cannot own the mutex exits immediately instead of stacking a
|
||||
// duplicate icon. Global\ scopes across sessions on the same machine.
|
||||
private const string SingleInstanceMutexName = @"Global\ShopDbEndpointAgentTray";
|
||||
|
||||
[STAThread]
|
||||
private static void Main()
|
||||
private static void Main(string[] args)
|
||||
{
|
||||
// Headless icon export for the installer: `--export-icon <path>` writes
|
||||
// the branded .ico and exits without starting the tray, so install.ps1
|
||||
// can point the Start Menu shortcut at a real brand icon. Kept in this
|
||||
// exe so the icon has a single source (TrayIcon) and no binary is
|
||||
// checked into the repo.
|
||||
if (args.Length >= 2 &&
|
||||
string.Equals(args[0], "--export-icon", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
TrayIcon.Export(args[1]);
|
||||
return;
|
||||
}
|
||||
|
||||
using var singleInstance = new Mutex(true, SingleInstanceMutexName, out bool owned);
|
||||
if (!owned)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ApplicationConfiguration.Initialize();
|
||||
Application.Run(new TrayContext());
|
||||
}
|
||||
|
||||
34
agent/ShopDbEndpointAgent.Tray/ScreenCapture.cs
Normal file
34
agent/ShopDbEndpointAgent.Tray/ScreenCapture.cs
Normal file
@@ -0,0 +1,34 @@
|
||||
using System.Drawing.Imaging;
|
||||
|
||||
namespace ShopDbEndpointAgent.Tray;
|
||||
|
||||
// Captures the full virtual desktop (all monitors) to a PNG on disk, porting
|
||||
// the operator PowerShell proof of concept:
|
||||
// CopyFromScreen over SystemInformation.VirtualScreen, then Bitmap.Save as
|
||||
// PNG. Kept in the tray project because only the interactive tray session can
|
||||
// see the operator's desktop; the headless service runs in session 0 and
|
||||
// would capture a black frame.
|
||||
internal static class ScreenCapture
|
||||
{
|
||||
// Grab every monitor in one shot. VirtualScreen spans the whole desktop so
|
||||
// a multi-head shop-floor PC is captured intact rather than just the
|
||||
// primary display. Returns the saved file path.
|
||||
public static string CaptureToPng(string filePath)
|
||||
{
|
||||
var bounds = SystemInformation.VirtualScreen;
|
||||
using var bitmap = new Bitmap(bounds.Width, bounds.Height,
|
||||
PixelFormat.Format32bppArgb);
|
||||
using (var graphics = Graphics.FromImage(bitmap))
|
||||
{
|
||||
graphics.CopyFromScreen(bounds.Left, bounds.Top, 0, 0,
|
||||
bounds.Size, CopyPixelOperation.SourceCopy);
|
||||
}
|
||||
|
||||
var directory = Path.GetDirectoryName(filePath);
|
||||
if (!string.IsNullOrEmpty(directory))
|
||||
Directory.CreateDirectory(directory);
|
||||
|
||||
bitmap.Save(filePath, ImageFormat.Png);
|
||||
return filePath;
|
||||
}
|
||||
}
|
||||
@@ -1,22 +1,20 @@
|
||||
namespace ShopDbEndpointAgent.Tray;
|
||||
|
||||
// The 5-click-max issue reporter (SCAFFOLD). Target flow from
|
||||
// DESIGN-DECISIONS-agent.md section 6:
|
||||
// network precheck -> issue dropdown -> optional screenshot -> submit.
|
||||
// This iteration renders the dropdown of fixed reasons and a submit button but
|
||||
// does NOT talk to ServiceNow yet - the submission path (direct API vs proxied
|
||||
// through a ShopDB server endpoint so ServiceNow creds stay server-side) is an
|
||||
// open decision. Submit currently shows what WOULD be sent.
|
||||
// The operator issue reporter. Collects a free-text description and an optional
|
||||
// screenshot request, then hands the result back to TrayContext which performs
|
||||
// the capture (after this dialog closes, so it never appears in the shot) and
|
||||
// the upload via the service binary.
|
||||
//
|
||||
// Only "Other (free text)" is offered today: the fixed-reason routing from the
|
||||
// original design is not wired to a ticketing backend, so a single free-text
|
||||
// path is the one flow that works end to end. The dropdown is kept (disabled,
|
||||
// preselected) so the richer reason list can return without reworking the form.
|
||||
internal sealed class TicketForm : Form
|
||||
{
|
||||
// Fixed dropdown reasons from the source conversation (section 6).
|
||||
// Only the free-text reason is functional. Kept as a one-item list so the
|
||||
// control and its layout stay in place for the future reason routing.
|
||||
private static readonly string[] Reasons =
|
||||
{
|
||||
"Can't logon to CSF",
|
||||
"Data collections down",
|
||||
"Can't download program",
|
||||
"Machine communication failing",
|
||||
"Network drives down",
|
||||
"Other (free text)",
|
||||
};
|
||||
|
||||
@@ -24,6 +22,11 @@ internal sealed class TicketForm : Form
|
||||
private readonly TextBox detailBox;
|
||||
private readonly CheckBox screenshotBox;
|
||||
|
||||
// Collected result, read by TrayContext after DialogResult.OK.
|
||||
public string Reason => reasonBox.SelectedItem?.ToString() ?? "Other (free text)";
|
||||
public string Details => detailBox.Text?.Trim() ?? string.Empty;
|
||||
public bool IncludeScreenshot => screenshotBox.Checked;
|
||||
|
||||
public TicketForm()
|
||||
{
|
||||
Text = "Report an issue";
|
||||
@@ -46,13 +49,16 @@ internal sealed class TicketForm : Form
|
||||
Top = 40,
|
||||
Width = 388,
|
||||
DropDownStyle = ComboBoxStyle.DropDownList,
|
||||
// Disabled until the fixed-reason routing exists; free text is the
|
||||
// only functional path, so the selection is fixed on it.
|
||||
Enabled = false,
|
||||
};
|
||||
reasonBox.Items.AddRange(Reasons);
|
||||
reasonBox.SelectedIndex = 0;
|
||||
|
||||
var detailLabel = new Label
|
||||
{
|
||||
Text = "Details (optional)",
|
||||
Text = "Details",
|
||||
Left = 16,
|
||||
Top = 76,
|
||||
Width = 388,
|
||||
@@ -72,6 +78,7 @@ internal sealed class TicketForm : Form
|
||||
Left = 16,
|
||||
Top = 188,
|
||||
Width = 388,
|
||||
Checked = true,
|
||||
};
|
||||
|
||||
var submit = new Button
|
||||
@@ -82,7 +89,6 @@ internal sealed class TicketForm : Form
|
||||
Width = 76,
|
||||
DialogResult = DialogResult.OK,
|
||||
};
|
||||
submit.Click += OnSubmit;
|
||||
|
||||
var cancel = new Button
|
||||
{
|
||||
@@ -101,18 +107,4 @@ internal sealed class TicketForm : Form
|
||||
AcceptButton = submit;
|
||||
CancelButton = cancel;
|
||||
}
|
||||
|
||||
private void OnSubmit(object? sender, EventArgs e)
|
||||
{
|
||||
// Placeholder: show what would be submitted. The real path (screenshot
|
||||
// capture to Pictures\Screenshots, then ServiceNow submit) lands once
|
||||
// the direct-vs-proxied decision is made.
|
||||
var summary =
|
||||
$"Reason: {reasonBox.SelectedItem}\n" +
|
||||
$"Details: {(string.IsNullOrWhiteSpace(detailBox.Text) ? "(none)" : detailBox.Text)}\n" +
|
||||
$"Screenshot: {(screenshotBox.Checked ? "yes" : "no")}";
|
||||
MessageBox.Show(
|
||||
"Ticketing is not wired up yet. This would be submitted:\n\n" + summary,
|
||||
"ShopDB agent (scaffold)", MessageBoxButtons.OK, MessageBoxIcon.Information);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
using System.Diagnostics;
|
||||
using System.Reflection;
|
||||
|
||||
using ShopDbEndpointAgent.Core;
|
||||
|
||||
namespace ShopDbEndpointAgent.Tray;
|
||||
|
||||
@@ -44,10 +47,102 @@ internal sealed class TrayContext : ApplicationContext
|
||||
trayIcon.DoubleClick += OnSendStatus;
|
||||
}
|
||||
|
||||
private void OnReportIssue(object? sender, EventArgs e)
|
||||
private async void OnReportIssue(object? sender, EventArgs e)
|
||||
{
|
||||
using var dialog = new TicketForm();
|
||||
dialog.ShowDialog();
|
||||
string reason;
|
||||
string details;
|
||||
bool includeScreenshot;
|
||||
using (var dialog = new TicketForm())
|
||||
{
|
||||
if (dialog.ShowDialog() != DialogResult.OK)
|
||||
return;
|
||||
reason = dialog.Reason;
|
||||
details = dialog.Details;
|
||||
includeScreenshot = dialog.IncludeScreenshot;
|
||||
}
|
||||
|
||||
var agentExe = ResolveAgentExe();
|
||||
if (agentExe is null)
|
||||
{
|
||||
ShowBalloon("Report an issue", "Could not find " + AgentExeName +
|
||||
". Is the ShopDB Endpoint Agent installed?", ToolTipIcon.Error);
|
||||
return;
|
||||
}
|
||||
|
||||
// Capture AFTER the dialog closes so the report form is never in the
|
||||
// shot. Stage into the interactive operator's own profile
|
||||
// (%USERPROFILE%\ShopDBScreenshots), NOT %TEMP%: on this locked-down
|
||||
// image the tray process's TEMP resolves to a different account's
|
||||
// AppData that ShopFloor cannot write, and AppData/Temp are aggressively
|
||||
// ACL'd/redirected. The profile root is owned by the operator and, by
|
||||
// inherited ACL, readable by SYSTEM (the agent service) so the handoff
|
||||
// works. Files are kept for the operator to show a DT/specialist later;
|
||||
// pruning is the agent's future server-driven Capture Cleanup job.
|
||||
var storeDir = OperatorReportStoreDir();
|
||||
var stamp = DateTime.Now.ToString("yyyyMMdd-HHmmss");
|
||||
string? payloadPath = null;
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(storeDir);
|
||||
string? screenshotPath = null;
|
||||
if (includeScreenshot)
|
||||
{
|
||||
screenshotPath = Path.Combine(storeDir, "screenshot-" + stamp + ".png");
|
||||
ScreenCapture.CaptureToPng(screenshotPath);
|
||||
}
|
||||
|
||||
payloadPath = Path.Combine(storeDir, "report-" + stamp + ".json");
|
||||
OperatorReportPayload.Write(payloadPath, reason, details, screenshotPath);
|
||||
|
||||
ShowBalloon("Report an issue", "Sending your report to ShopDB...",
|
||||
ToolTipIcon.Info);
|
||||
|
||||
int exitCode = await RunAgentAsync(agentExe, "--report \"" + payloadPath + "\"");
|
||||
if (exitCode == 0)
|
||||
ShowBalloon("Report an issue", "Your report was sent to ShopDB.",
|
||||
ToolTipIcon.Info);
|
||||
else
|
||||
ShowBalloon("Report an issue",
|
||||
"Report send failed (exit " + exitCode +
|
||||
"). See the agent log under ProgramData\\ShopDB.",
|
||||
ToolTipIcon.Warning);
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
ShowBalloon("Report an issue", "Could not send the report: " +
|
||||
error.Message, ToolTipIcon.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Only the transient JSON sidecar is removed; the screenshot stays
|
||||
// for the operator. The agent also deletes the sidecar on success,
|
||||
// so this just covers the failure path.
|
||||
if (payloadPath is not null)
|
||||
TryDeleteFile(payloadPath);
|
||||
}
|
||||
}
|
||||
|
||||
// The operator-visible folder screenshots and the report sidecar are staged
|
||||
// into, under the interactive user's profile. Resolved in the tray (running
|
||||
// as that user) and passed to the agent as an absolute path; the agent must
|
||||
// never re-derive it, since as SYSTEM its profile would resolve elsewhere.
|
||||
private static string OperatorReportStoreDir()
|
||||
=> Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
|
||||
"ShopDBScreenshots");
|
||||
|
||||
private static void TryDeleteFile(string filePath)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(filePath))
|
||||
File.Delete(filePath);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best-effort; a leftover sidecar is harmless and the agent also
|
||||
// removes it on a successful upload.
|
||||
}
|
||||
}
|
||||
|
||||
// Runs shopdbagent.exe --once out of process so the operator can push a
|
||||
@@ -74,7 +169,7 @@ internal sealed class TrayContext : ApplicationContext
|
||||
|
||||
try
|
||||
{
|
||||
int exitCode = await RunAgentOnceAsync(agentExe);
|
||||
int exitCode = await RunAgentAsync(agentExe, "--once");
|
||||
if (exitCode == 0)
|
||||
ShowBalloon("Send Status", "Status sent to ShopDB.",
|
||||
ToolTipIcon.Info);
|
||||
@@ -96,12 +191,12 @@ internal sealed class TrayContext : ApplicationContext
|
||||
}
|
||||
}
|
||||
|
||||
private static Task<int> RunAgentOnceAsync(string agentExe)
|
||||
private static Task<int> RunAgentAsync(string agentExe, string arguments)
|
||||
{
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = agentExe,
|
||||
Arguments = "--once",
|
||||
Arguments = arguments,
|
||||
WorkingDirectory = Path.GetDirectoryName(agentExe) ?? Environment.CurrentDirectory,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
@@ -181,9 +276,36 @@ internal sealed class TrayContext : ApplicationContext
|
||||
=> MessageBox.Show(
|
||||
"ShopDB Endpoint Agent tray.\n" +
|
||||
"Telemetry runs as the ShopDB Endpoint Agent Windows service.\n" +
|
||||
"Send Status now reports this PC to ShopDB on demand.",
|
||||
"Send Status now reports this PC to ShopDB on demand.\n\n" +
|
||||
"Tray version: " + TrayVersion() + "\n" +
|
||||
"Agent version: " + AgentVersion(),
|
||||
"About", MessageBoxButtons.OK, MessageBoxIcon.Information);
|
||||
|
||||
// The tray's own assembly version, the same value stamped from the csproj
|
||||
// <Version> at build time.
|
||||
private static string TrayVersion()
|
||||
=> Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "unknown";
|
||||
|
||||
// The installed agent service exe's file version, read from wherever the
|
||||
// tray resolves it (beside the tray, then the default install dir). The
|
||||
// service and tray can legitimately be different versions, so this is shown
|
||||
// as its own line rather than assumed equal to the tray. Degrades to a
|
||||
// clear note when the agent exe is not found or its version is unreadable.
|
||||
private static string AgentVersion()
|
||||
{
|
||||
var agentExe = ResolveAgentExe();
|
||||
if (agentExe is null)
|
||||
return "not found";
|
||||
try
|
||||
{
|
||||
return FileVersionInfo.GetVersionInfo(agentExe).FileVersion ?? "unknown";
|
||||
}
|
||||
catch (FileNotFoundException)
|
||||
{
|
||||
return "not found";
|
||||
}
|
||||
}
|
||||
|
||||
private void OnExit(object? sender, EventArgs e)
|
||||
{
|
||||
trayIcon.Visible = false;
|
||||
|
||||
@@ -62,6 +62,22 @@ internal static class TrayIcon
|
||||
}
|
||||
}
|
||||
|
||||
// Writes the branded icon to an .ico file so a Start Menu / desktop shortcut
|
||||
// can show the brand instead of the generic exe icon. Reuses the same GDI+
|
||||
// drawing as the tray so there is still one source of brand truth and no
|
||||
// checked-in binary. 64px suits shortcut/large-icon views; the OS downscales.
|
||||
public static void Export(string path, int size = 64)
|
||||
{
|
||||
using var icon = Create(size);
|
||||
var directory = Path.GetDirectoryName(path);
|
||||
if (!string.IsNullOrEmpty(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
using var stream = new FileStream(path, FileMode.Create, FileAccess.Write);
|
||||
icon.Save(stream);
|
||||
}
|
||||
|
||||
private static GraphicsPath RoundedRect(RectangleF rect, float radius)
|
||||
{
|
||||
float diameter = radius * 2;
|
||||
|
||||
@@ -3,4 +3,6 @@
|
||||
<Project Path="ShopDbEndpointAgent.Tray/ShopDbEndpointAgent.Tray.csproj" />
|
||||
<Project Path="ShopDbEndpointAgent/ShopDbEndpointAgent.csproj" />
|
||||
<Project Path="ShopDbAdoptionTool/ShopDbAdoptionTool.csproj" />
|
||||
<!-- Run by hand on Windows, never by CI. See its README. -->
|
||||
<Project Path="ShopDbEndpointAgent.PolicyProbe/ShopDbEndpointAgent.PolicyProbe.csproj" />
|
||||
</Solution>
|
||||
|
||||
@@ -10,6 +10,9 @@ using ShopDbEndpointAgent.Core;
|
||||
// (default) run as a Windows Service (AddWindowsService)
|
||||
// --console run interactively in the foreground for debugging
|
||||
// --once collect and report exactly once, then exit (smoke test / cron)
|
||||
// --report <path> upload one operator issue report (JSON sidecar written by
|
||||
// the tray), then exit. Keeps the collector key in the
|
||||
// service so the tray never holds a secret.
|
||||
//
|
||||
// Secrets are never read here: AgentConfigProvider layers the registry
|
||||
// (HKLM\SOFTWARE\GE\ShopDB: CollectorKey, BaseUrl) over the shipped, non-secret
|
||||
@@ -19,6 +22,7 @@ using ShopDbEndpointAgent.Core;
|
||||
|
||||
var runOnce = args.Any(a => string.Equals(a, "--once", StringComparison.OrdinalIgnoreCase));
|
||||
var forceConsole = args.Any(a => string.Equals(a, "--console", StringComparison.OrdinalIgnoreCase));
|
||||
var reportPayloadPath = ArgValue(args, "--report");
|
||||
|
||||
var agentVersion = Assembly.GetExecutingAssembly()
|
||||
.GetName().Version?.ToString() ?? "0.0.0";
|
||||
@@ -58,6 +62,9 @@ try
|
||||
if (runOnce)
|
||||
return await RunOnceAsync(config, agentVersion, logDirectory, faultMonitor);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(reportPayloadPath))
|
||||
return await RunReportAsync(config, reportPayloadPath, logDirectory, faultMonitor);
|
||||
|
||||
await RunHostAsync(config, agentVersion, logDirectory, forceConsole);
|
||||
return 0;
|
||||
}
|
||||
@@ -103,6 +110,61 @@ static async Task<int> RunOnceAsync(AgentConfig config, string agentVersion,
|
||||
}
|
||||
}
|
||||
|
||||
// Uploads one operator issue report described by a tray-written JSON sidecar,
|
||||
// then deletes the payload folder. Runs out of process from the tray so the
|
||||
// collector key stays in the service binary. The exit code is the tray's
|
||||
// contract: 0 = uploaded, non-zero = failed (detail in the agent log).
|
||||
static async Task<int> RunReportAsync(AgentConfig config, string payloadPath,
|
||||
string logDirectory, AgentFaultMonitor faultMonitor)
|
||||
{
|
||||
using var services = BuildOnceServices(config, "0.0.0", logDirectory);
|
||||
var client = services.GetRequiredService<CollectorClient>();
|
||||
var logger = services.GetRequiredService<ILogger<CollectorClient>>();
|
||||
try
|
||||
{
|
||||
var payload = OperatorReportPayload.Read(payloadPath);
|
||||
var response = await client.UploadOperatorReportAsync(
|
||||
payload.Reason, payload.Details, payload.ScreenshotPath,
|
||||
Environment.MachineName, CancellationToken.None);
|
||||
logger.LogInformation("Operator report uploaded: id={ReportId}",
|
||||
response?.OperatorReportId);
|
||||
return 0;
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
faultMonitor.ReportFatal("--report", error);
|
||||
return 1;
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Remove only the transient JSON sidecar. The screenshot lives in the
|
||||
// operator's %USERPROFILE%\ShopDBScreenshots folder and is deliberately
|
||||
// KEPT so the operator can show it to a DT/specialist later; pruning it
|
||||
// is the future server-driven Capture Cleanup job, not this path. (The
|
||||
// sidecar previously sat in a throwaway temp folder we deleted whole;
|
||||
// now it shares the operator's folder, so a recursive delete here would
|
||||
// wipe saved screenshots.)
|
||||
try
|
||||
{
|
||||
if (File.Exists(payloadPath))
|
||||
File.Delete(payloadPath);
|
||||
}
|
||||
catch { /* best-effort; the tray also removes the sidecar */ }
|
||||
}
|
||||
}
|
||||
|
||||
// Read the value following a flag (e.g. --report C:\path\report.json). Returns
|
||||
// null when the flag is absent or has no following token.
|
||||
static string? ArgValue(string[] args, string flag)
|
||||
{
|
||||
for (int i = 0; i < args.Length - 1; i++)
|
||||
{
|
||||
if (string.Equals(args[i], flag, StringComparison.OrdinalIgnoreCase))
|
||||
return args[i + 1];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static ServiceProvider BuildOnceServices(AgentConfig config, string agentVersion,
|
||||
string logDirectory)
|
||||
{
|
||||
|
||||
@@ -7,7 +7,38 @@
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>ShopDbEndpointAgent</RootNamespace>
|
||||
<AssemblyName>shopdbagent</AssemblyName>
|
||||
<!-- 1.4.1: run_script/update_app stage into a fixed, Defender-excludable
|
||||
<!-- 1.6.3: operator issue reports stage under the interactive operator's
|
||||
%USERPROFILE%\ShopDBScreenshots (resolved in the ShopFloor tray session)
|
||||
instead of %TEMP%, which on locked-down endpoints redirected to another
|
||||
user's AppData\Local\Temp and left the SYSTEM-context report upload
|
||||
reading an inaccessible path. Screenshots use flat timestamped names and
|
||||
are KEPT after a successful upload for operator review; only the transient
|
||||
JSON sidecar is pruned, so the report path no longer recursively deletes
|
||||
the shared folder. AgentFaultMonitor.ReportFatal now mirrors fatal report
|
||||
failures to the rolling file log so unelevated tray-launched runs surface
|
||||
a cause instead of exiting 1 silently.
|
||||
1.6.2: command channel retires pooled TLS connections faster than the
|
||||
server keep-alive (SocketsHttpHandler PooledConnectionIdleTimeout 1s,
|
||||
PooledConnectionLifetime 2m). Fixes intermittent "unexpected EOF or 0
|
||||
bytes from the transport stream" on the long-poll claim when the pool
|
||||
reused a socket the server had already closed between hold windows.
|
||||
Telemetry was unaffected; this only touched CommandClient.
|
||||
1.6.1: republished against the current source so both site packages
|
||||
(Wilmington and West Jefferson) carry the same vetted build. No agent
|
||||
behavior change; the server now computes warn-only drift from the
|
||||
service/process state the agent already reports each cycle.
|
||||
1.6.0: a policy-attached process monitor may carry a launch path set by
|
||||
a site leader in the WebUI; it rides the /monitors/resolved payload and
|
||||
the agent honors it only after LaunchPathPolicy validates it (approved
|
||||
root, exists, launchable extension). A local appsettings.json launchpaths
|
||||
entry still wins; an unvalidated path is dropped and process_start fails
|
||||
closed.
|
||||
1.5.0: the agent fetches its watch list from server policy each
|
||||
telemetry cycle (GET /api/endpoints/monitors/resolved) and merges it
|
||||
over local appsettings.json, so a policy-attached process/service
|
||||
monitor is enumerated and reported without editing the box. Fail-closed:
|
||||
a failed fetch keeps the local list.
|
||||
1.4.1: run_script/update_app stage into a fixed, Defender-excludable
|
||||
folder (C:\ProgramData\ShopDB\EndpointAgent) instead of the LocalSystem
|
||||
temp path, and run_script user-context waits for exit and reports the
|
||||
real exit code instead of fire-and-forget "launched".
|
||||
@@ -19,7 +50,7 @@
|
||||
(AgentConfig.LaunchPaths, shipped defaults overridable per site).
|
||||
1.1.0: generic, data-driven service_*/process_* actions carry their
|
||||
target over the wire, so new policies/apps need no agent rebuild. -->
|
||||
<Version>1.4.1</Version>
|
||||
<Version>1.6.3</Version>
|
||||
<InvariantGlobalization>true</InvariantGlobalization>
|
||||
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||
<SelfContained>true</SelfContained>
|
||||
|
||||
@@ -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": {}
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user