Import the wilm endpoint-monitor and channel-distribution work
Everything on origin/wilm that is not on the published main, applied as one
change because the published history has rewritten hashes: per-commit
cherry-pick conflicts against our main even though the squashed diff applies
clean.
Contents: endpoint monitor catalog + policy attachment + warn-only drift
reconciler, operator issue reports from the tray, agent launch-path allowlist,
and the GHCR channel-image distribution path for dev workstations.
NOT READY FOR main. Open items:
- 10 new routes are absent from docs/api-inventory.json, so
test_api_inventory_parity fails. That gate SKIPS on the published mirror
(the test ships, its data file is scrubbed), which is why wilm never saw it.
- dev-bootstrap.ps1 and Start-ShopDBDev.ps1 hardcode the ge-aero GitHub org.
- plugins/endpoints/plugin.py carries a whitespace-only re-indent.
Scope: product
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; }
|
||||
}
|
||||
|
||||
123
agent/ShopDbEndpointAgent.Core/LaunchPathPolicy.cs
Normal file
123
agent/ShopDbEndpointAgent.Core/LaunchPathPolicy.cs
Normal file
@@ -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<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;
|
||||
}
|
||||
|
||||
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<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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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