Bring the wilm branch into main: endpoints plugin, agent, policies, CSV import

Ports the corporate-GitHub wilm branch onto main, keeping the two branches
separate. Thirteen upstream commits arrive with their authorship intact; the
review fixes and the portability work below are on top.

What arrives:

  endpoints plugin  - a ShopDB agent reporting endpoint state, telemetry,
                      services and interfaces, with a command channel the agent
                      long-polls. Ingest stays on the generic collector route
                      (ADR-006); the agent key is a UI-managed single-scope
                      token whose revocation is an instant kill switch.
  computers policies - Configuration and Business Policies (RMM-style), scope
                      trees validated at write time, actions checked against
                      the endpoints command allowlist. Feature-gated: every
                      route 404s while the setting is off, so enabling it can
                      never half-expose the surface.
  asset CSV import  - templates, a collector script carrying the site path, and
                      a validate-then-commit import. A plugin can own a virtual
                      column plus a post-write row hook, which is how a PC's
                      machine number becomes a controls link without a
                      hand-built relationships sheet.
  agent/            - the Windows agent and its installer.

Contract 0.21.0 -> 0.24.0, additive throughout: register_import_hook (0.22),
mint/list/revoke_service_token (0.23), collector_authorized (0.24). The stability
table had no rows for any of them and now does.

MySQL 5.6 portability. The branch stored three policy columns as sa.JSON, the
only JSON columns in the product, and 5.6 has no JSON type: the migration failed
with a bare syntax error near the word JSON. They are TEXT now, through a
JSONText type decorator, so all seventeen call sites still read and write dicts.
The convention elsewhere is already TEXT (guides.blocksjson,
geenforce.manifestjson).

Review fixes carried on top:

  - six policy GETs were jwt_required(optional=True), which authenticates a
    caller without checking anything; they require computers.view now, and six
    redundant jwt_required decorators are gone
  - the agent installer took a default BaseUrl and CollectorKey, so a mistyped
    run silently pointed an endpoint at the wrong instance; both are mandatory
    and a non-https BaseUrl throws unless -AllowInsecureHttp is passed
  - a committing CSV import wrote no audit row

Documentation: the 34 new routes are described in docs/api-inventory.json, which
is hand-maintained on purpose, and the generated spec now covers 331 paths and
477 operations across 22 surfaces. DEVELOPMENT-SETUP's 1071 troubleshooting row
said "use MySQL 8 for dev" and now gives the actual fix, including the trap that
5.6 downgrades ROW_FORMAT=DYNAMIC to Compact with a warning instead of an error.

Full suite 2123 passed, vitest 260 passed, all seventeen plugin chains migrate
clean on MySQL 5.6 and the naming, docs, parity and read-surface gates are green.
This commit is contained in:
cproudlock
2026-08-28 16:17:17 -04:00
parent 93ca7587ce
commit 784964c907
149 changed files with 17545 additions and 627 deletions

7
agent/.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
# .NET build output - never tracked; produced by dotnet build/publish.
bin/
obj/
publish/
# Local publish scratch and command-output captures.
*.user

166
agent/README.md Normal file
View File

@@ -0,0 +1,166 @@
# ShopDB Endpoint Agent
A Windows telemetry agent that reports endpoint state to a ShopDB server's
`endpoints` plugin. It runs as a Windows service in production and as a console
app for development and testing.
## Projects
- `ShopDbEndpointAgent.Core` - contracts, config, logging, collection, and the
resilient collector HTTP client. No host concerns; unit-testable.
- `ShopDbEndpointAgent` - the service host (generic host + Windows service
lifetime). Composes Core through DI. This is the telemetry runtime.
- `ShopDbEndpointAgent.Tray` - interactive user-session tray app: a system-tray
icon with "Send Status now" (on-demand report) and the 5-click issue reporter
(ServiceNow flow is still a scaffold). Separate process from the service,
because a SYSTEM service cannot draw a tray icon in a user session.
## Configuration
Runtime config is layered. Non-secret defaults live in `appsettings.json`
(shipped with the build). The collector key and base URL are provisioned out of
band in the registry and win over `appsettings.json`, so a distributed build
never carries the secret and the site can rotate the key without a rebuild.
### Registry (production)
Create these values under `HKLM\SOFTWARE\GE\ShopDB` (64-bit view):
| Value | Type | Meaning |
|----------------|--------|--------------------------------------------------|
| `BaseUrl` | String | `https://shopdb.example.net` (server root) |
| `CollectorKey` | String | The endpoint agent key from Settings -> Endpoints -> Key management |
Example (elevated PowerShell):
```
New-Item -Path 'HKLM:\SOFTWARE\GE\ShopDB' -Force | Out-Null
Set-ItemProperty -Path 'HKLM:\SOFTWARE\GE\ShopDB' -Name 'BaseUrl' -Value 'https://shopdb.example.net'
Set-ItemProperty -Path 'HKLM:\SOFTWARE\GE\ShopDB' -Name 'CollectorKey' -Value '<paste the generated key>'
```
### appsettings.json (all environments)
| Key | Default | Meaning |
|-------------------|-------------|------------------------------------------------|
| `baseurl` | "" | Dev fallback for the server root (registry wins). |
| `apikey` | placeholder | Dev fallback for the collector key (registry wins). |
| `cadenceseconds` | 900 | Report interval (15 min). |
| `jitterseconds` | 60 | Random +/- spread on the interval (anti-herd). |
| `adoptonfirstrun` | true | Send `adopt=true` on first contact only. |
| `utilitiespath` | "" | Directory of site PowerShell "agent utilities". |
| `logpath` | "" | Override the rolling log directory. |
For a dev box, set `baseurl` and `apikey` here and skip the registry.
## Running
Console (dev), one report then exit:
```
dotnet run --project ShopDbEndpointAgent -- --once
```
Console (dev), continuous cadence loop:
```
dotnet run --project ShopDbEndpointAgent -- --console
```
## Install as a Windows service
Publish self-contained, then register with `sc.exe` (elevated):
```
dotnet publish ShopDbEndpointAgent -c Release -r win-x64 --self-contained -o C:\ProgramData\ShopDB\EndpointAgent\bin
sc.exe create "ShopDBEndpointAgent" binPath= "C:\ProgramData\ShopDB\EndpointAgent\bin\shopdb-endpoint-agent.exe" start= auto DisplayName= "ShopDB Endpoint Agent"
sc.exe description "ShopDBEndpointAgent" "Reports endpoint telemetry to ShopDB."
sc.exe start "ShopDBEndpointAgent"
```
The service runs as LocalSystem by default, which it needs to read the Security
event channel and system WMI.
## Tray app (system-tray icon)
The tray is a per-user process, not a service, so it starts at sign-in rather
than under `sc.exe`. `install-shopdbagent.ps1` handles it: it copies
`shopdb-endpoint-tray.exe` next to the agent, registers an HKCU logon autostart
(`Software\Microsoft\Windows\CurrentVersion\Run\ShopDbEndpointTray`), and
launches it for the current user. Run the installer from the operator's own
session so the autostart lands in the right user hive. Pass `-NoTray` for a
headless, service-only install.
Right-click the tray icon for:
- `Send Status now` - reports this PC to ShopDB immediately so the monitor page
refreshes without waiting for the next cadence tick. It shells out to
`shopdbagent.exe --once`, reusing the exact production collection path; the
tray holds no collector key. A balloon tip reflects success or failure (on
failure, see the rolling agent log).
- `Report an issue...` - the ServiceNow ticket flow (scaffold; submission path
is an open decision).
A desktop shortcut to `shopdb-endpoint-tray.exe` can be added later for an
explicitly launchable ServiceNow surface.
## WebUI command execution model (security context)
The WebUI can send an endpoint an allowlisted command (server source of truth:
`plugins/endpoints/commands.py`; agent handler: `CommandExecutor`). The agent
service runs as LocalSystem, but not every command runs in that context. The
launch context is chosen per action so processes surface to the operator while
privileged operations keep the token they need:
- Generic `process_start` (for example DncMain, UDC) launches into the ACTIVE
CONSOLE USER's session at medium integrity, so the app is visible on the
operator's desktop. CyberArk/EPM brokers any elevation afterward.
- Generic `process_stop` / `process_status` run as SYSTEM (kill and probe).
- `service_start` / `service_stop` / `service_status` run as SYSTEM, because
service control requires the admin/SYSTEM token.
- `mtconnect_start` / `mtconnect_stop` / `mtconnect_status` all run as SYSTEM,
because MTConnect is the "UDC MTConnect Agent" Windows service and service
control requires the admin/SYSTEM token. `mtconnect_start` runs `install.bat`
(`Agent.exe install`) to install or repair the service, then ensures it is
running; `mtconnect_stop` stops the service; `mtconnect_status` queries it.
`run.bat` (`agent.exe run agent.cfg`) is a foreground TESTING launcher and is
deliberately NOT used by the agent.
- `system_restart` runs as SYSTEM and reboots the machine immediately via
`shutdown.exe /r /t 0` (no grace timer). It is a fixed action with no target,
gated server-side by the dedicated `endpoints.restart` permission on top of
`endpoints.command`, and fronted by a WebUI confirmation gate.
Do not route MTConnect through the generic `process_start` user-session path or
service control breaks. MTConnect is intentionally absent from the
`process_start` launch map for the same reason; the fixed `mtconnect_*` handler
owns it.
Behavior note for redeploys: the MTConnect service-control model is a runtime
change, so the agent must be rebuilt and redeployed for it to take effect.
Server-side command/allowlist/RBAC changes are picked up by the container
rebuild; rebuild and redeploy the agent whenever the runtime launch/control
logic itself changes.
## Diagnostics
- Rolling file log: `%ProgramData%\ShopDB\EndpointAgent\logs\agent-YYYYMMDD.log`
(14-day retention).
- Windows Event Log: agent runtime failures and unhandled exceptions are written
to the `Application` log under source `ShopDB Endpoint Agent`. The source is
created on first elevated run (or by the installer). Filter Event Viewer by
that source to see agent/system interop failures.
## Agent utilities (PowerShell seam)
If `utilitiespath` points at a directory, the runtime runs each `*.ps1` there
per report, parses its JSON stdout, and merges the result into the report. This
is the seam the future server-side "Agent configuration" settings page uses to
push site-authored collectors down. Scripts must be code-signed and
allow-listed before this is enabled in the field (rollout concern).
## Logging extensibility
All runtime code logs through `Microsoft.Extensions.Logging` (`ILogger`). Adding
a sink later (a ShopDB HTTP sink, Seq, syslog) is a host-builder change in
`Program.cs::ConfigureLogging` with no changes to the worker, collector, or
client.

View File

@@ -0,0 +1,109 @@
using System.Text.Json.Serialization;
namespace ShopDbEndpointAgent.Core;
// Runtime configuration for the agent. Non-secret values come from
// appsettings.json; the collector key and base URL are provisioned out of band
// in the registry (HKLM:\SOFTWARE\GE\ShopDB) and layered on top - see
// RegistryConfigProvider. The registry wins so a distributed appsettings.json
// never carries the secret and the site can rotate the key without a rebuild.
public sealed class AgentConfig
{
[JsonPropertyName("baseurl")] public string BaseUrl { get; set; } = "";
[JsonPropertyName("apikey")] public string ApiKey { get; set; } = "";
[JsonPropertyName("cadenceseconds")] public int CadenceSeconds { get; set; } = 900;
// Jitter spreads a fleet's reports so they do not arrive in lockstep
// (thundering herd). Actual delay = cadence +/- up to jitterseconds.
[JsonPropertyName("jitterseconds")] public int JitterSeconds { get; set; } = 60;
[JsonPropertyName("adoptonfirstrun")] public bool AdoptOnFirstRun { get; set; } = true;
// Directory holding site-provided PowerShell "agent utilities" the runtime
// may invoke through the documented collection seam. Empty = none loaded.
[JsonPropertyName("utilitiespath")] public string UtilitiesPath { get; set; } = "";
// Absolute path for the rolling diagnostics log. Empty = the default under
// %ProgramData%\ShopDB\EndpointAgent\logs.
[JsonPropertyName("logpath")] public string LogPath { get; set; } = "";
// What this endpoint watches. Shipped in appsettings.json (non-secret) so a
// site can retarget monitors without a rebuild. Processes are interactive
// apps watched by image name; services are Windows services watched by their
// service key name. Empty lists = nothing watched.
[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
// (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();
// 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
// by the fixed mtconnect_* handler (install.bat + service control), not the
// generic process_start path. eDNC and UDC launch their runtime executables
// directly.
public static readonly IReadOnlyDictionary<string, string> DefaultLaunchPaths =
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["DncMain.exe"] = @"C:\Program Files (x86)\Dnc\bin\DncMain.exe",
["UDC.exe"] = @"C:\Program Files\UDC\UDC.exe",
};
// The effective launch map: shipped defaults overlaid with appsettings.json
// entries (site wins). Keys are normalized to bare image names so a lookup
// tolerates 'DncMain' or 'DncMain.exe'.
public IReadOnlyDictionary<string, string> ResolveLaunchPaths()
{
var resolved = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var pair in DefaultLaunchPaths)
resolved[NormalizeImage(pair.Key)] = pair.Value;
foreach (var pair in LaunchPaths)
{
if (string.IsNullOrWhiteSpace(pair.Key) || string.IsNullOrWhiteSpace(pair.Value))
continue;
resolved[NormalizeImage(pair.Key)] = pair.Value;
}
return resolved;
}
private static string NormalizeImage(string image)
{
var bare = (image ?? "").Trim();
if (bare.EndsWith(".exe", StringComparison.OrdinalIgnoreCase))
bare = bare[..^4];
return bare;
}
public bool IsUsable()
=> !string.IsNullOrWhiteSpace(BaseUrl)
&& !string.IsNullOrWhiteSpace(ApiKey)
&& ApiKey != "REPLACE_WITH_COLLECTOR_KEY";
}
// Watched targets for this endpoint. Both lists carry entries with a wire name
// (process image name or Windows service key name) and an optional friendly app
// label the server/UI can show (for example "eDNC" for dncMain.exe).
public sealed class MonitorConfig
{
[JsonPropertyName("processes")] public MonitorTarget[] Processes { get; set; }
= Array.Empty<MonitorTarget>();
[JsonPropertyName("services")] public MonitorTarget[] Services { get; set; }
= Array.Empty<MonitorTarget>();
}
public sealed class MonitorTarget
{
// Process image name (for example "udc.exe") or Windows service key name
// (for example "MTConnectAgent"). Matched case-insensitively.
[JsonPropertyName("name")] public string Name { get; set; } = "";
// Human-facing application label, optional. Falls back to Name when empty.
[JsonPropertyName("app")] public string App { get; set; } = "";
}

View File

@@ -0,0 +1,110 @@
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Win32;
namespace ShopDbEndpointAgent.Core;
// Builds the effective AgentConfig by layering two sources:
// 1. appsettings.json - non-secret runtime defaults, shipped with the build.
// 2. HKLM\SOFTWARE\GE\ShopDB - the collector key and base URL, provisioned
// out of band (Intune/DSC/GE-Enforce). Registry values WIN so a
// distributed appsettings.json never carries the secret and the site can
// rotate the key without a rebuild.
//
// Reading the registry never throws out to the caller: a missing hive or value
// simply leaves the appsettings value in place, which is what makes dev boxes
// (appsettings only) and fielded machines (registry provisioned) both work.
public static class AgentConfigProvider
{
public const string RegistryPath = @"SOFTWARE\GE\ShopDB";
// Registry value names. These mirror the names the computers reporter uses
// (see plugins/endpoints/DESIGN-DECISIONS-agent.md section 1).
public const string BaseUrlValue = "BaseUrl";
public const string CollectorKeyValue = "CollectorKey";
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
};
public static AgentConfig Load(string? appSettingsPath = null,
Microsoft.Extensions.Logging.ILogger? logger = null)
{
var config = LoadAppSettings(appSettingsPath) ?? new AgentConfig();
ApplyRegistryOverrides(config, logger);
return config;
}
private static AgentConfig? LoadAppSettings(string? path)
{
path ??= Path.Combine(AppContext.BaseDirectory, "appsettings.json");
if (!File.Exists(path))
return null;
try
{
var json = File.ReadAllText(path);
return JsonSerializer.Deserialize<AgentConfig>(json, JsonOptions);
}
catch
{
// Malformed appsettings: fall back to defaults + registry so the
// agent can still run if the secret is provisioned in the registry.
return null;
}
}
private static void ApplyRegistryOverrides(AgentConfig config,
Microsoft.Extensions.Logging.ILogger? logger = null)
{
// Try 64-bit view first, then the default/32-bit view. On a locked-down
// box (CyberArk/EPM gating regedit and hive reads) the read can throw
// SecurityException/UnauthorizedAccessException even when the values
// exist. We log the exact outcome per view instead of swallowing it, so
// "Config incomplete" can be told apart from "access denied".
RegistryView[] views = { RegistryView.Registry64, RegistryView.Registry32 };
foreach (var view in views)
{
try
{
using var machine = RegistryKey.OpenBaseKey(
RegistryHive.LocalMachine, view);
using var key = machine.OpenSubKey(RegistryPath);
if (key is null)
{
logger?.LogWarning(
"Registry key HKLM\\{Path} not found in {View} view.",
RegistryPath, view);
continue;
}
var baseUrl = key.GetValue(BaseUrlValue) as string;
if (!string.IsNullOrWhiteSpace(baseUrl))
config.BaseUrl = baseUrl.Trim();
var collectorKey = key.GetValue(CollectorKeyValue) as string;
if (!string.IsNullOrWhiteSpace(collectorKey))
config.ApiKey = collectorKey.Trim();
logger?.LogInformation(
"Registry {View}: BaseUrl {BaseUrlState}, CollectorKey {KeyState}.",
view,
string.IsNullOrWhiteSpace(baseUrl) ? "MISSING" : "present",
string.IsNullOrWhiteSpace(collectorKey) ? "MISSING" : "present");
// Success reading this view; stop unless nothing was found.
if (!string.IsNullOrWhiteSpace(baseUrl) ||
!string.IsNullOrWhiteSpace(collectorKey))
return;
}
catch (Exception error)
{
logger?.LogError(error,
"Registry read of HKLM\\{Path} in {View} view was blocked " +
"({ErrorType}). If CyberArk/EPM is gating the hive, grant the " +
"agent read access or use appsettings.json for this test.",
RegistryPath, view, error.GetType().Name);
}
}
}
}

View File

@@ -0,0 +1,91 @@
using System.Diagnostics;
using Microsoft.Extensions.Logging;
namespace ShopDbEndpointAgent.Core;
// Agent self-monitoring (WER-style). Routes otherwise-unobserved failures -
// unhandled exceptions on any thread and faulted tasks nobody awaited - to the
// ILogger (so they land in the rolling file) AND directly to a dedicated
// Windows Event Log source so agent runtime failures and agent/system interop
// errors are visible in Event Viewer alongside the OS's own records.
//
// The Event Log source is created on first use; creating a source needs admin,
// so if it is absent and cannot be created we degrade to the file log only and
// never throw from a fault handler (that would mask the original failure).
public sealed class AgentFaultMonitor
{
public const string EventLogSource = "ShopDB Endpoint Agent";
public const string EventLogName = "Application";
private readonly ILogger logger;
private readonly bool eventLogReady;
public AgentFaultMonitor(ILogger logger)
{
this.logger = logger;
eventLogReady = EnsureEventLogSource();
}
// Wire the process-wide fault hooks. Call once at startup.
public void Install()
{
AppDomain.CurrentDomain.UnhandledException += OnUnhandledException;
TaskScheduler.UnobservedTaskException += OnUnobservedTaskException;
}
public void ReportFatal(string context, Exception error)
=> Report(EventLogEntryType.Error, 1000, $"{context}: {error}");
private void OnUnhandledException(object sender, UnhandledExceptionEventArgs args)
{
var error = args.ExceptionObject as Exception;
var message = error?.ToString() ?? "unknown unhandled exception";
logger.LogCritical(error, "Unhandled exception (terminating={Terminating})",
args.IsTerminating);
Report(EventLogEntryType.Error, 1001,
$"Unhandled exception (terminating={args.IsTerminating}): {message}");
}
private void OnUnobservedTaskException(object? sender,
UnobservedTaskExceptionEventArgs args)
{
logger.LogError(args.Exception, "Unobserved task exception");
Report(EventLogEntryType.Warning, 1002,
$"Unobserved task exception: {args.Exception}");
// Mark observed so it does not escalate to a process-level failure.
args.SetObserved();
}
private void Report(EventLogEntryType type, int eventId, string message)
{
if (!eventLogReady)
return;
try
{
EventLog.WriteEntry(EventLogSource, message, type, eventId);
}
catch
{
// Event Log write failed (source removed, log full): the file log
// already has this entry; a fault handler must never throw.
}
}
private static bool EnsureEventLogSource()
{
try
{
if (EventLog.SourceExists(EventLogSource))
return true;
EventLog.CreateEventSource(new EventSourceCreationData(
EventLogSource, EventLogName));
return true;
}
catch
{
// Needs admin the first time; the installer creates the source, so
// in the field this succeeds. In dev without admin we log to file.
return false;
}
}
}

View File

@@ -0,0 +1,26 @@
namespace ShopDbEndpointAgent.Core;
// A one-shot wake signal shared between the command worker and the telemetry
// worker. After a command runs (e.g. MTConnect start/stop), the command worker
// calls Request() so the telemetry loop collects immediately instead of waiting
// out the remaining cadence, and the WebUI card reflects the new state within a
// single report cycle rather than up to 15 minutes later.
public sealed class CollectSignal
{
private readonly SemaphoreSlim gate = new(0, 1);
// Wake the telemetry loop for one off-cadence collection. Extra calls while
// one is already pending collapse into a single wake (the gate caps at 1).
public void Request()
{
try { gate.Release(); }
catch (SemaphoreFullException) { /* a wake is already pending */ }
}
// Wait until either the cadence delay elapses or a collect is requested.
// Returns true when woken by a request, false on a normal cadence timeout.
public async Task<bool> WaitAsync(TimeSpan delay, CancellationToken token)
{
return await gate.WaitAsync(delay, token);
}
}

View File

@@ -0,0 +1,118 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.Extensions.Logging;
namespace ShopDbEndpointAgent.Core;
// Posts one EndpointReport to POST {baseurl}/api/collector/endpoints with the
// X-API-Key header (plugins/endpoints/AGENT-CONTRACT.md section 1), unwrapping
// the ShopDB envelope { status, data, meta } to return the CollectorResponse.
//
// Resilience: transient failures (network errors, timeouts, 5xx, 429) are
// retried with exponential backoff plus jitter, bounded by maxAttempts. A 4xx
// other than 429 is a permanent error (bad key, unknown host in strict mode)
// and is surfaced immediately without retry - hammering the server changes
// nothing.
public sealed class CollectorClient : IDisposable
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
DefaultIgnoreCondition = System.Text.Json.Serialization
.JsonIgnoreCondition.WhenWritingNull,
};
private readonly HttpClient http;
private readonly ILogger<CollectorClient> logger;
private readonly int maxAttempts;
private readonly Random jitter = new();
public CollectorClient(AgentConfig config, ILogger<CollectorClient> logger,
int maxAttempts = 4)
{
this.logger = logger;
this.maxAttempts = Math.Max(1, maxAttempts);
http = new HttpClient
{
BaseAddress = new Uri(config.BaseUrl.TrimEnd('/') + "/"),
Timeout = TimeSpan.FromSeconds(30),
};
http.DefaultRequestHeaders.Add("X-API-Key", config.ApiKey);
}
public async Task<CollectorResponse> SendAsync(
EndpointReport report, CancellationToken token)
{
Exception? last = null;
for (int attempt = 1; attempt <= maxAttempts; attempt++)
{
token.ThrowIfCancellationRequested();
try
{
return await SendOnceAsync(report, token);
}
catch (CollectorException error) when (error.IsPermanent)
{
// Bad credential or rejected payload: no retry.
throw;
}
catch (Exception error) when (
error is CollectorException or HttpRequestException or TaskCanceledException
&& !token.IsCancellationRequested)
{
last = error;
if (attempt == maxAttempts)
break;
var delay = BackoffDelay(attempt);
logger.LogWarning(
"Collector send attempt {Attempt}/{Max} failed: {Message}; retrying in {Delay}s",
attempt, maxAttempts, error.Message, delay.TotalSeconds);
await Task.Delay(delay, token);
}
}
throw last ?? new CollectorException(0, "collector send failed");
}
private async Task<CollectorResponse> SendOnceAsync(
EndpointReport report, CancellationToken token)
{
using var response = await http.PostAsJsonAsync(
"api/collector/endpoints", report, JsonOptions, token);
var body = await response.Content.ReadAsStringAsync(token);
if (!response.IsSuccessStatusCode)
throw new CollectorException((int)response.StatusCode, body);
var envelope = JsonSerializer.Deserialize<CollectorEnvelope>(body, JsonOptions);
if (envelope?.Data is null)
throw new CollectorException((int)response.StatusCode, "empty response body");
return envelope.Data;
}
private TimeSpan BackoffDelay(int attempt)
{
// 2^(attempt-1) seconds, capped, plus up to 1s jitter.
double seconds = Math.Min(30, Math.Pow(2, attempt - 1));
return TimeSpan.FromSeconds(seconds + jitter.NextDouble());
}
public void Dispose() => http.Dispose();
}
public sealed class CollectorException : Exception
{
public int StatusCode { get; }
// A permanent failure is a 4xx that is not 429 (rate limit): retrying with
// the same key and payload cannot succeed, so the worker should back off to
// its normal cadence rather than fast-retry.
public bool IsPermanent =>
StatusCode >= 400 && StatusCode < 500
&& StatusCode != (int)HttpStatusCode.TooManyRequests;
public CollectorException(int statusCode, string body)
: base($"collector returned HTTP {statusCode}: {body}")
{
StatusCode = statusCode;
}
}

View File

@@ -0,0 +1,83 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.Extensions.Logging;
namespace ShopDbEndpointAgent.Core;
// Talks to the endpoint command channel (plugins/endpoints/AGENT-CONTRACT.md
// section 7). The agent opens no inbound port: it long-polls
// GET /api/endpoints/commands/next, which the server holds open until a command
// is queued (HTTP 200) or the hold window elapses (HTTP 204). After running the
// claimed command locally the agent posts the outcome to
// POST /api/endpoints/commands/{id}/result. Both share the endpoints collector
// key via X-API-Key, so the command channel rides the same kill switch as
// telemetry and adds no second secret.
public sealed class CommandClient : IDisposable
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
DefaultIgnoreCondition = System.Text.Json.Serialization
.JsonIgnoreCondition.WhenWritingNull,
};
private readonly HttpClient http;
private readonly ILogger<CommandClient> logger;
public CommandClient(AgentConfig config, ILogger<CommandClient> logger)
{
this.logger = logger;
http = new HttpClient
{
BaseAddress = new Uri(config.BaseUrl.TrimEnd('/') + "/"),
// Longer than the collector client: the claim request is held open
// server-side (~25s), so the read timeout must clear that plus slack.
Timeout = TimeSpan.FromSeconds(60),
};
http.DefaultRequestHeaders.Add("X-API-Key", config.ApiKey);
}
// Claim the next pending command for this hostname, or null when the server
// returned 204 (nothing queued within the hold window). A 4xx other than 429
// is surfaced so the worker can back off instead of hot-looping a bad key or
// an unknown host.
public async Task<CommandClaim?> ClaimNextAsync(
string hostname, CancellationToken token)
{
var url = "api/endpoints/commands/next?hostname="
+ Uri.EscapeDataString(hostname);
using var response = await http.GetAsync(url, token);
if (response.StatusCode == HttpStatusCode.NoContent)
return null;
var body = await response.Content.ReadAsStringAsync(token);
if (!response.IsSuccessStatusCode)
throw new CollectorException((int)response.StatusCode, body);
var envelope = JsonSerializer.Deserialize<CommandClaimEnvelope>(
body, JsonOptions);
return envelope?.Data;
}
// Report a claimed command's outcome. Best-effort: a failure to post the
// result is logged and swallowed so a transient network blip does not crash
// the worker; the command simply stays 'running' server-side until a later
// operator retry supersedes it.
public async Task ReportResultAsync(
int commandId, CommandResult result, CancellationToken token)
{
var url = $"api/endpoints/commands/{commandId}/result";
using var response = await http.PostAsJsonAsync(
url, result, JsonOptions, token);
if (!response.IsSuccessStatusCode)
{
var body = await response.Content.ReadAsStringAsync(token);
logger.LogWarning(
"Reporting result for command {CommandId} returned HTTP {Status}: {Body}",
commandId, (int)response.StatusCode, body);
}
}
public void Dispose() => http.Dispose();
}

View File

@@ -0,0 +1,581 @@
using System.Diagnostics;
using System.ServiceProcess;
using Microsoft.Extensions.Logging;
namespace ShopDbEndpointAgent.Core;
// Runs an allowlisted endpoint command locally and returns the result to post
// back. The allowlist here mirrors the server's (plugins/endpoints/commands.py):
// the wire carries an action NAME plus, for generic actions, a TARGET, and this
// is the only place that maps a name to concrete behavior. An unknown name
// fails closed - it is never treated as a command string to execute.
//
// Two families:
// Generic primitives (data-driven; NO agent rebuild for a new app/policy):
// service_start / service_stop / service_status - act on claim.Target, a
// Windows service name.
// process_stop / process_status - act on claim.Target, a process image name
// (for example 'DncMain.exe'). process_start also acts on the image name
// but launches ONLY through a per-image allowlist of known-good paths
// (AgentConfig.LaunchPaths, shipped defaults overridable per site); an
// unmapped target fails closed so the wire can never launch an arbitrary
// binary. process_start launches into the ACTIVE CONSOLE USER'S session
// (not session 0) at the user's medium integrity so the app is visible
// on their desktop; CyberArk/EPM brokers any elevation afterward. Stop
// and status stay as SYSTEM.
// Fixed MTConnect actions (target pinned in the agent, never on the wire):
// mtconnect_start / mtconnect_stop / mtconnect_status.
// MTConnect is a Windows service ("UDC MTConnect Agent"), so all three run
// as SYSTEM (service control needs the admin/SYSTEM token). Start runs
// install.bat (Agent.exe install) to install or repair the service, then
// ensures it is running; stop stops the service; status queries it. NOTE:
// run.bat (agent.exe run agent.cfg) is a foreground TESTING launcher and is
// deliberately NOT used - do not route MTConnect through the generic
// process_start user-session path or the service control breaks.
//
// Every action probes and reports the resulting run state (MtConnectRunning for
// MTConnect; the generic probes report via Message/Detail) so the WebUI toggle
// reflects reality rather than the action's intent.
public sealed class CommandExecutor
{
// The UDC MTConnect agent is a Windows service. Its name is fixed here (not
// sent over the wire) so a compromised WebUI cannot redirect service control.
// A later per-site override belongs in AgentConfig, not the command payload.
private const string MtConnectServiceName = "UDC MTConnect Agent";
// The MTConnect install/repair seam: Agent.exe install. run.bat (agent.exe
// run agent.cfg) is a foreground TESTING launcher and is deliberately NOT
// used here. install.bat registers or repairs the service; the start handler
// runs it, then ensures the service is running.
private const string MtConnectInstallBat =
@"C:\ProgramData\UDC\MTConnect_UDC\Agent\install.bat";
private readonly ILogger<CommandExecutor> logger;
private readonly TimeSpan startTimeout;
// 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.
private readonly IReadOnlyDictionary<string, string> launchPaths;
public CommandExecutor(ILogger<CommandExecutor> logger,
AgentConfig config, TimeSpan? startTimeout = null)
{
this.logger = logger;
this.startTimeout = startTimeout ?? TimeSpan.FromSeconds(30);
this.launchPaths = (config ?? new AgentConfig()).ResolveLaunchPaths();
}
public async Task<CommandResult> ExecuteAsync(
CommandClaim claim, CancellationToken token)
{
var action = claim.Action;
var target = (claim.Target ?? "").Trim();
switch (action)
{
// Fixed MTConnect actions: target is pinned in the agent.
case "mtconnect_start":
return await MtConnectStartAsync(token);
case "mtconnect_stop":
return MtConnectStop();
case "mtconnect_status":
return MtConnectStatus();
// Generic service primitives: act on the wire target.
case "service_start":
return RequireTarget(action, target)
?? ServiceControl(target, ServiceControllerStatus.Running);
case "service_stop":
return RequireTarget(action, target)
?? ServiceControl(target, ServiceControllerStatus.Stopped);
case "service_status":
return RequireTarget(action, target)
?? ServiceStatus(target);
// Generic process primitives: act on the wire target image name.
case "process_stop":
return RequireTarget(action, target)
?? ProcessStop(target);
case "process_status":
return RequireTarget(action, target)
?? ProcessStatus(target);
case "process_start":
return RequireTarget(action, target)
?? await ProcessStart(target, token);
// Fixed destructive action: reboot this endpoint immediately. Runs
// as the SYSTEM agent, so shutdown does not need a user session.
case "system_restart":
return SystemRestart();
// Force an immediate off-cadence telemetry report. The handler is a
// no-op success: CommandWorker fires collectSignal.Request() after
// every command, which wakes the telemetry loop to collect now, so
// the WebUI reflects live state instead of waiting for cadence.
case "collect_now":
logger.LogInformation("collect_now requested: forcing off-cadence telemetry");
return new CommandResult
{
Succeeded = true,
Message = "Fresh telemetry collection requested",
};
default:
logger.LogWarning("Rejected unknown command action {Action}", action);
return new CommandResult
{
Succeeded = false,
Message = $"Unknown or disallowed action: {action}",
};
}
}
// Guard: a generic action with no target has nothing to act on. Returns a
// failed result to short-circuit, or null when the target is present.
private CommandResult? RequireTarget(string action, string target)
{
if (!string.IsNullOrWhiteSpace(target))
return null;
logger.LogWarning("Action {Action} arrived with no target", action);
return new CommandResult
{
Succeeded = false,
Message = $"Action {action} requires a target",
};
}
// ---- Fixed destructive actions ----
// Reboot the machine now. shutdown.exe with /t 0 gives no grace timer; the
// WebUI confirmation gate is the safety, not a countdown. As SYSTEM the
// agent already holds the privilege, so no session or elevation dance.
private CommandResult SystemRestart()
{
try
{
var restart = new ProcessStartInfo
{
FileName = "shutdown.exe",
Arguments = "/r /t 0",
UseShellExecute = false,
CreateNoWindow = true,
};
using var process = Process.Start(restart);
logger.LogWarning("system_restart issued: rebooting now");
return new CommandResult
{
Succeeded = true,
Message = "Restart initiated",
};
}
catch (Exception error)
{
logger.LogError(error, "system_restart failed");
return new CommandResult
{
Succeeded = false,
Message = $"Failed to initiate restart: {error.Message}",
};
}
}
// ---- Generic service primitives ----
private CommandResult ServiceControl(
string serviceName, ServiceControllerStatus desired)
{
try
{
using var controller = new ServiceController(serviceName);
if (controller.Status == desired)
{
return new CommandResult
{
Succeeded = true,
Message = $"Service {serviceName} already {desired}",
Detail = ServiceDetail(controller),
};
}
if (desired == ServiceControllerStatus.Running)
controller.Start();
else
controller.Stop();
controller.WaitForStatus(desired, startTimeout);
controller.Refresh();
var reached = controller.Status == desired;
return new CommandResult
{
Succeeded = reached,
Message = reached
? $"Service {serviceName} {desired}"
: $"Service {serviceName} did not reach {desired} within timeout",
Detail = ServiceDetail(controller),
};
}
catch (Exception error)
{
logger.LogWarning(error, "Failed to control service {Service}", serviceName);
return new CommandResult
{
Succeeded = false,
Message = $"Failed to control service {serviceName}: {error.Message}",
};
}
}
private CommandResult ServiceStatus(string serviceName)
{
try
{
using var controller = new ServiceController(serviceName);
return new CommandResult
{
Succeeded = true,
Message = $"Service {serviceName} is {controller.Status}",
Detail = ServiceDetail(controller),
};
}
catch (Exception error)
{
logger.LogWarning(error, "Failed to read service {Service}", serviceName);
return new CommandResult
{
Succeeded = false,
Message = $"Failed to read service {serviceName}: {error.Message}",
};
}
}
private static Dictionary<string, object> ServiceDetail(ServiceController controller)
{
controller.Refresh();
return new Dictionary<string, object>
{
["kind"] = "service",
["name"] = controller.ServiceName,
["status"] = controller.Status.ToString(),
["running"] = controller.Status == ServiceControllerStatus.Running,
};
}
// ---- Generic process primitives ----
private CommandResult ProcessStop(string image)
{
var processes = FindProcessesByImage(image);
if (processes.Count == 0)
{
return new CommandResult
{
Succeeded = true,
Message = $"Process {image} already stopped",
Detail = ProcessDetail(image, false),
};
}
var errors = new List<string>();
foreach (var process in processes)
{
try
{
process.Kill(entireProcessTree: true);
process.WaitForExit(10000);
}
catch (Exception error)
{
errors.Add(error.Message);
logger.LogWarning(error, "Failed to stop process {Pid}", process.Id);
}
finally
{
process.Dispose();
}
}
var running = IsProcessRunning(image);
return new CommandResult
{
Succeeded = !running,
Message = running
? $"Process {image} still running after stop: {string.Join("; ", errors)}"
: $"Process {image} stopped",
Detail = ProcessDetail(image, running),
};
}
private CommandResult ProcessStatus(string image)
{
var running = IsProcessRunning(image);
return new CommandResult
{
Succeeded = true,
Message = running ? $"Process {image} running" : $"Process {image} stopped",
Detail = ProcessDetail(image, running),
};
}
private static Dictionary<string, object> ProcessDetail(string image, bool running)
=> new()
{
["kind"] = "process",
["name"] = image,
["running"] = running,
};
// Start a process by image name, but ONLY if it resolves to a known-good
// launchable path (shipped defaults + appsettings overrides). The wire never
// carries a path, so a compromised WebUI cannot launch an arbitrary binary:
// an unmapped target fails closed. A mapped .bat is launched from its own
// directory; a mapped .exe is launched
// directly. Either way we do not wait for the launcher to exit - we poll for
// the image to appear and report the resulting run state.
private async Task<CommandResult> ProcessStart(string image, CancellationToken token)
{
if (IsProcessRunning(image))
{
return new CommandResult
{
Succeeded = true,
Message = $"Process {image} already running",
Detail = ProcessDetail(image, true),
};
}
var bare = image;
if (bare.EndsWith(".exe", StringComparison.OrdinalIgnoreCase))
bare = bare[..^4];
if (!launchPaths.TryGetValue(bare, out var launchPath)
|| string.IsNullOrWhiteSpace(launchPath))
{
logger.LogWarning(
"Rejected process_start for {Image}: no launchable path configured",
image);
return new CommandResult
{
Succeeded = false,
Message = $"process_start is not permitted for {image}: no launch path configured",
Detail = ProcessDetail(image, false),
};
}
if (!File.Exists(launchPath))
{
logger.LogWarning(
"process_start for {Image}: configured launch path does not exist: {Path}",
image, launchPath);
return new CommandResult
{
Succeeded = false,
Message = $"Launch path not found for {image}: {launchPath}",
Detail = ProcessDetail(image, false),
};
}
try
{
// Launch into the ACTIVE CONSOLE USER'S session, not session 0. The
// agent is a SYSTEM service; a plain Process.Start here would put the
// GUI app on the hidden session-0 desktop where the operator never
// sees it. The app starts at the user's medium integrity and
// CyberArk/EPM brokers any elevation afterward.
var workingDirectory = Path.GetDirectoryName(launchPath) ?? "";
var outcome = InteractiveProcessLauncher.LaunchInActiveSession(
launchPath, workingDirectory);
if (!outcome.Launched)
{
logger.LogWarning(
"process_start could not launch {Image} in the user session: {Error}",
image, outcome.Error);
return new CommandResult
{
Succeeded = false,
Message = $"Failed to start {image}: {outcome.Error}",
Detail = ProcessDetail(image, false),
};
}
// The launcher (an .exe or a possibly long-lived run.bat) may not be
// the watched image itself, so poll for the target image to appear
// rather than waiting on the launcher to exit.
var deadline = DateTime.UtcNow + startTimeout;
while (DateTime.UtcNow < deadline)
{
if (IsProcessRunning(image))
return new CommandResult
{
Succeeded = true,
Message = $"Process {image} started",
Detail = ProcessDetail(image, true),
};
await Task.Delay(1000, token);
}
return new CommandResult
{
Succeeded = false,
Message = $"Process {image} did not report running within the timeout",
Detail = ProcessDetail(image, IsProcessRunning(image)),
};
}
catch (Exception ex)
{
logger.LogError(ex, "process_start failed for {Image} via {Path}",
image, launchPath);
return new CommandResult
{
Succeeded = false,
Message = $"Failed to start {image}: {ex.Message}",
Detail = ProcessDetail(image, false),
};
}
}
private bool IsProcessRunning(string image)
{
var processes = FindProcessesByImage(image);
try
{
return processes.Count > 0;
}
finally
{
foreach (var process in processes)
process.Dispose();
}
}
// Match a target image name against running processes. Accepts 'DncMain.exe'
// or 'DncMain' - Process.GetProcessesByName wants the bare name, so strip a
// trailing .exe before matching.
private static List<Process> FindProcessesByImage(string image)
{
var bare = image;
if (bare.EndsWith(".exe", StringComparison.OrdinalIgnoreCase))
bare = bare[..^4];
var found = new List<Process>();
try
{
found.AddRange(Process.GetProcessesByName(bare));
}
catch
{
// Access-denied on a foreign session's process: not our agent's to
// see, so treat it as absent rather than failing the probe.
}
return found;
}
// ---- Fixed MTConnect actions ----
private async Task<CommandResult> MtConnectStartAsync(CancellationToken token)
{
if (MtConnectServiceRunning())
{
return new CommandResult
{
Succeeded = true,
Message = "MTConnect already running",
MtConnectRunning = true,
};
}
// install.bat (Agent.exe install) installs or repairs the service. Run it
// as SYSTEM and wait for it to exit - unlike the long-lived run.bat, the
// installer is a quick one-shot. Missing installer is not fatal on its
// own: the service may already be registered, so fall through to start.
if (File.Exists(MtConnectInstallBat))
{
try
{
var install = new ProcessStartInfo
{
FileName = MtConnectInstallBat,
WorkingDirectory = Path.GetDirectoryName(MtConnectInstallBat) ?? "",
UseShellExecute = false,
CreateNoWindow = true,
};
using var installer = Process.Start(install);
if (installer is not null)
{
try
{
await installer.WaitForExitAsync(token);
}
catch (OperationCanceledException)
{
// Shutting down; leave the service in whatever state it
// reached and report the current status below.
}
}
}
catch (Exception error)
{
logger.LogWarning(error, "MTConnect install.bat failed");
}
}
else
{
logger.LogWarning(
"MTConnect installer not found: {Installer}; attempting service start anyway",
MtConnectInstallBat);
}
// install.bat may or may not leave the service started; ensure it is
// running via the same service-control path the generic primitives use.
var result = ServiceControl(MtConnectServiceName, ServiceControllerStatus.Running);
return new CommandResult
{
Succeeded = result.Succeeded,
Message = result.Succeeded ? "MTConnect started" : result.Message,
MtConnectRunning = MtConnectServiceRunning(),
Detail = result.Detail,
};
}
private CommandResult MtConnectStop()
{
var result = ServiceControl(MtConnectServiceName, ServiceControllerStatus.Stopped);
var running = MtConnectServiceRunning();
return new CommandResult
{
Succeeded = result.Succeeded,
Message = running ? result.Message : "MTConnect stopped",
MtConnectRunning = running,
Detail = result.Detail,
};
}
private CommandResult MtConnectStatus()
{
var result = ServiceStatus(MtConnectServiceName);
var running = MtConnectServiceRunning();
return new CommandResult
{
Succeeded = result.Succeeded,
Message = running ? "MTConnect running" : "MTConnect stopped",
MtConnectRunning = running,
Detail = result.Detail,
};
}
// Probe the MTConnect Windows service state. A missing/unqueryable service
// (not installed, access denied on a foreign session) reports not running
// rather than throwing, so the WebUI toggle degrades to "stopped".
private bool MtConnectServiceRunning()
{
try
{
using var controller = new ServiceController(MtConnectServiceName);
return controller.Status == ServiceControllerStatus.Running;
}
catch
{
return false;
}
}
}

View File

@@ -0,0 +1,143 @@
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace ShopDbEndpointAgent.Core;
// The command loop as a hosted BackgroundService, separate from TelemetryWorker
// so a slow or held-open command poll never delays a telemetry report and vice
// versa. It long-polls the server for an allowlisted command
// (plugins/endpoints/AGENT-CONTRACT.md section 7), runs it through
// CommandExecutor, and posts the result back. The server holds each poll open
// briefly, so this loop is effectively "wait for work, do it, immediately wait
// again" rather than a fixed-cadence poll.
//
// Like TelemetryWorker, one bad iteration never kills the loop: a permanent
// error (bad key, unknown host) backs off to a slow retry so it does not hammer
// the server, and any other failure is logged and retried after a short pause.
public sealed class CommandWorker : BackgroundService
{
// Pause after a transient failure before re-polling, so a flapping network
// or a briefly-down server does not spin this loop.
private static readonly TimeSpan TransientRetryDelay = TimeSpan.FromSeconds(10);
// Longer back-off after a permanent error (bad key, host removed): the
// operator has to fix something, so re-polling fast changes nothing.
private static readonly TimeSpan PermanentRetryDelay = TimeSpan.FromSeconds(300);
private readonly CommandClient client;
private readonly CommandExecutor executor;
private readonly ILogger<CommandWorker> logger;
private readonly CollectSignal? collectSignal;
private readonly string hostname;
public CommandWorker(CommandClient client, CommandExecutor executor,
ILogger<CommandWorker> logger, CollectSignal? collectSignal = null)
{
this.client = client;
this.executor = executor;
this.logger = logger;
this.collectSignal = collectSignal;
hostname = Environment.MachineName;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
logger.LogInformation("Command channel started for host {Host}", hostname);
while (!stoppingToken.IsCancellationRequested)
{
try
{
var claim = await client.ClaimNextAsync(hostname, stoppingToken);
if (claim is not null)
await RunClaimAsync(claim, stoppingToken);
// On a 204 (no work) the server already consumed the hold
// window, so re-poll immediately.
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (CollectorException error) when (error.IsPermanent)
{
logger.LogError(error,
"Permanent command-channel error; backing off");
await SafeDelay(PermanentRetryDelay, stoppingToken);
}
catch (Exception error) when (IsIdlePollClose(error))
{
// The server (or a proxy) closed the held-open long poll without
// a command. That is the normal end of a hold window, not a
// failure: log quietly at debug and re-poll immediately.
logger.LogDebug(error, "Command poll connection closed; re-polling");
}
catch (Exception error)
{
logger.LogWarning(error, "Command poll failed; retrying shortly");
await SafeDelay(TransientRetryDelay, stoppingToken);
}
}
logger.LogInformation("Command channel stopped");
}
private async Task RunClaimAsync(CommandClaim claim, CancellationToken token)
{
logger.LogInformation("Running command {Id} action={Action}",
claim.EndpointCommandId, claim.Action);
CommandResult result;
try
{
result = await executor.ExecuteAsync(claim, token);
}
catch (Exception error)
{
logger.LogError(error, "Command {Id} action={Action} threw",
claim.EndpointCommandId, claim.Action);
result = new CommandResult
{
Succeeded = false,
Message = $"Command failed: {error.Message}",
};
}
await client.ReportResultAsync(claim.EndpointCommandId, result, token);
logger.LogInformation("Command {Id} finished succeeded={Succeeded}",
claim.EndpointCommandId, result.Succeeded);
// A command changed something on the endpoint (e.g. MTConnect start/stop);
// wake the telemetry loop to collect now so the WebUI card reflects the
// new state within one report cycle instead of after the full cadence.
collectSignal?.Request();
}
// A long poll that the server or an intermediary closes without sending a
// command surfaces as an HttpRequestException wrapping an HttpIOException
// (ResponseEnded), or a bare IOException. That is the expected end of a hold
// window, so it should not be treated as a transient failure.
private static bool IsIdlePollClose(Exception error)
{
for (Exception? current = error; current is not null; current = current.InnerException)
{
if (current is System.Net.Http.HttpIOException httpIo
&& httpIo.HttpRequestError == System.Net.Http.HttpRequestError.ResponseEnded)
return true;
if (current is System.IO.IOException)
return true;
}
return false;
}
private static async Task SafeDelay(TimeSpan delay, CancellationToken token)
{
try
{
await Task.Delay(delay, token);
}
catch (OperationCanceledException)
{
// Shutting down: let the loop condition end it.
}
}
}

View File

@@ -0,0 +1,92 @@
using System.Text.Json.Serialization;
namespace ShopDbEndpointAgent.Core;
// Wire keys MUST stay lowercase/no-underscore to match the ShopDB collector
// schema (plugins/endpoints/AGENT-CONTRACT.md). PascalCase C# properties are
// mapped back with JsonPropertyName so the serializer does not rename them.
public sealed class EndpointReport
{
[JsonPropertyName("hostname")] public string Hostname { get; set; } = "";
[JsonPropertyName("adopt")] public bool? Adopt { get; set; }
[JsonPropertyName("loggedinuser")] public string? LoggedInUser { get; set; }
[JsonPropertyName("serialnumber")] public string? SerialNumber { get; set; }
[JsonPropertyName("pctype")] public string? PcType { get; set; }
[JsonPropertyName("vendorname")] public string? VendorName { get; set; }
[JsonPropertyName("modelnumber")] public string? ModelNumber { get; set; }
[JsonPropertyName("osname")] public string? OsName { get; set; }
[JsonPropertyName("ipaddress")] public string? IpAddress { get; set; }
[JsonPropertyName("machinenumber")] public string? MachineNumber { get; set; }
[JsonPropertyName("agentversion")] public string? AgentVersion { get; set; }
[JsonPropertyName("lastboottime")] public DateTimeOffset? LastBootTime { get; set; }
[JsonPropertyName("uptimeseconds")] public long? UptimeSeconds { get; set; }
[JsonPropertyName("idleseconds")] public long? IdleSeconds { get; set; }
[JsonPropertyName("cpupercent")] public double? CpuPercent { get; set; }
[JsonPropertyName("memorypercent")] public double? MemoryPercent { get; set; }
[JsonPropertyName("services")] public object[]? Services { get; set; }
[JsonPropertyName("ports")] public object[]? Ports { get; set; }
[JsonPropertyName("interfaces")] public object[]? Interfaces { get; set; }
[JsonPropertyName("scheduledtasks")] public object[]? ScheduledTasks { get; set; }
[JsonPropertyName("events")] public EndpointEventRecord[]? Events { get; set; }
}
public sealed class EndpointEventRecord
{
[JsonPropertyName("eventid")] public int EventId { get; set; }
[JsonPropertyName("eventchannel")] public string? EventChannel { get; set; }
[JsonPropertyName("eventsource")] public string? EventSource { get; set; }
[JsonPropertyName("eventtime")] public DateTimeOffset? EventTime { get; set; }
[JsonPropertyName("eventmessage")] public string? EventMessage { get; set; }
[JsonPropertyName("fields")] public Dictionary<string, object>? Fields { get; set; }
}
public sealed class CollectorResponse
{
[JsonPropertyName("status")] public string Status { get; set; } = "";
[JsonPropertyName("action")] public string? Action { get; set; }
[JsonPropertyName("assetid")] public int? AssetId { get; set; }
[JsonPropertyName("identityvalue")] public string? IdentityValue { get; set; }
[JsonPropertyName("warnings")] public string[]? Warnings { get; set; }
}
// The collector wraps the plugin payload in the ShopDB envelope
// { status, data: { action, assetid, identityvalue, warnings }, meta }.
public sealed class CollectorEnvelope
{
[JsonPropertyName("status")] public string Status { get; set; } = "";
[JsonPropertyName("data")] public CollectorResponse? 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 -
// Target is a Windows service name or a process image (for example
// 'DncMain.exe') and TargetKind is 'service' or 'process'. Fixed-target actions
// (mtconnect_*) leave both null. Carrying the target on the wire is what lets a
// site build the agent once and drive every future policy/app from server data.
public sealed class CommandClaim
{
[JsonPropertyName("endpointcommandid")] public int EndpointCommandId { get; set; }
[JsonPropertyName("action")] public string Action { get; set; } = "";
[JsonPropertyName("target")] public string? Target { get; set; }
[JsonPropertyName("targetkind")] public string? TargetKind { get; set; }
}
// Envelope for the claim long-poll: { status, data: { endpointcommandid, action } }.
public sealed class CommandClaimEnvelope
{
[JsonPropertyName("status")] public string Status { get; set; } = "";
[JsonPropertyName("data")] public CommandClaim? Data { get; set; }
}
// The outcome the agent posts back after running a claimed command. When the
// action probed MTConnect, MtConnectRunning carries the observed state so the
// server can cache it for the WebUI toggle.
public sealed class CommandResult
{
[JsonPropertyName("succeeded")] public bool Succeeded { get; set; }
[JsonPropertyName("message")] public string? Message { get; set; }
[JsonPropertyName("mtconnectrunning")] public bool? MtConnectRunning { get; set; }
[JsonPropertyName("detail")] public Dictionary<string, object>? Detail { get; set; }
}

View File

@@ -0,0 +1,44 @@
namespace ShopDbEndpointAgent.Core;
// The curated Windows Event catalog, LOCKED to the server list in
// plugins/endpoints/plugin.py (CURATED_EVENT_IDS) and documented in
// plugins/endpoints/DESIGN-DECISIONS-agent.md section 4. The server stores ONLY
// these IDs; anything else in a report's `events` is ignored (not an error).
// Sending only these keeps the payload small and mirrors the catalog intent.
//
// Channels matter: an ID is read from its own log, so the collector reads
// Security, System, and Application - not System alone. IDs are namespaced by
// channel here because the same numeric ID can mean different things across
// logs; we only harvest the (channel, id) pairs the server curates.
public static class CuratedEventCatalog
{
public sealed record Entry(int EventId, string Channel, string Meaning);
public static readonly IReadOnlyList<Entry> Entries = new List<Entry>
{
new(4624, "Security", "Successful logon"),
new(4625, "Security", "Failed logon"),
new(4634, "Security", "Logoff"),
new(4672, "Security", "Special privileges assigned to new logon"),
new(4697, "Security", "Service installed"),
new(4698, "Security", "Scheduled task created"),
new(4618, "Security", "Monitored security event pattern"),
new(41, "System", "Kernel-Power, unexpected shutdown"),
new(55, "System", "NTFS file system corruption"),
new(7011, "System", "Service Control Manager timeout"),
new(10016, "System", "DCOM permission error"),
new(1001, "Application", "Application error / bugcheck report"),
};
// The distinct channels the collector must read.
public static readonly IReadOnlyList<string> Channels =
Entries.Select(entry => entry.Channel).Distinct().ToList();
// The set of curated IDs for a given channel, for a fast membership check
// while walking that channel's log newest-first.
public static IReadOnlySet<int> IdsForChannel(string channel)
=> Entries.Where(entry =>
string.Equals(entry.Channel, channel, StringComparison.OrdinalIgnoreCase))
.Select(entry => entry.EventId)
.ToHashSet();
}

View File

@@ -0,0 +1,489 @@
using System.Diagnostics;
using System.Management;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Win32;
namespace ShopDbEndpointAgent.Core;
// Pure-C# endpoint collector implementing ICollector. Mirrors the ShopDB
// PowerShell asset-collector data set (minus the CSV emit): hostname, logged-in
// user, serial number, pc type, boot time, monitored processes, plus a curated
// Windows Event Log tail across the Security/System/Application channels the
// server curates (CuratedEventCatalog). WMI/registry/event access can throw or
// return null on a locked-down box, so every probe is defensive and never
// aborts the report.
public sealed class EndpointCollector : ICollector
{
// Max curated events harvested per channel per report, newest-first, to
// keep the payload bounded on a chatty Security log.
private const int MaxEventsPerChannel = 25;
private readonly ILogger<EndpointCollector> logger;
private readonly AgentConfig config;
private readonly IAgentUtilityRunner? utilities;
public EndpointCollector(ILogger<EndpointCollector> logger, AgentConfig config,
IAgentUtilityRunner? utilities = null)
{
this.logger = logger;
this.config = config;
this.utilities = utilities;
}
public EndpointReport Collect(string agentVersion, bool adopt)
{
var report = new EndpointReport
{
Hostname = Environment.MachineName,
AgentVersion = agentVersion,
Adopt = adopt ? true : null,
LoggedInUser = GetLoggedInUser(),
SerialNumber = GetSerialNumber(),
PcType = GetPcType(),
VendorName = GetVendor(),
ModelNumber = GetModelNumber(),
OsName = GetOsName(),
IpAddress = GetPrimaryIpAddress(),
MachineNumber = GetMachineNumber(),
LastBootTime = GetLastBootTime(),
Services = GetMonitoredTargets(),
Interfaces = GetInterfaces(),
Events = GetCuratedEvents(),
};
if (report.LastBootTime is DateTimeOffset boot)
report.UptimeSeconds = (long)(DateTimeOffset.UtcNow - boot).TotalSeconds;
return report;
}
// Combined watch list: interactive processes (by image name) and Windows
// services (by service key name). Both land in report.Services with a shared
// {name, app, kind, state, running, count} shape so the server/UI renders
// one list of health badges.
private object[] GetMonitoredTargets()
{
var result = new List<object>();
result.AddRange(GetMonitoredProcesses());
result.AddRange(GetMonitoredServices());
return result.ToArray();
}
private IEnumerable<object> GetMonitoredProcesses()
{
var targets = config.Monitors?.Processes ?? Array.Empty<MonitorTarget>();
if (targets.Length == 0)
return Array.Empty<object>();
var running = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
try
{
foreach (var proc in Process.GetProcesses())
{
string image;
try { image = proc.ProcessName + ".exe"; }
catch { continue; }
finally { proc.Dispose(); }
if (Array.FindIndex(targets, t =>
string.Equals(t.Name, image, StringComparison.OrdinalIgnoreCase)) < 0)
continue;
running.TryGetValue(image, out int count);
running[image] = count + 1;
}
}
catch (Exception error)
{
logger.LogDebug(error, "Process enumeration blocked");
}
var result = new List<object>(targets.Length);
foreach (var target in targets)
{
bool found = running.TryGetValue(target.Name, out int count);
result.Add(new
{
name = target.Name,
app = string.IsNullOrWhiteSpace(target.App) ? target.Name : target.App,
kind = "process",
// state string so the UI badge can color it; missing = unknown.
state = found ? "running" : "stopped",
running = found,
count = found ? count : 0,
});
}
return result;
}
// Windows services watched by service key name. Reports the reported state
// (running/stopped/etc.) from Win32_Service so the server can flag a
// collector service (MTConnect and the like) that has stopped or gone
// missing. Never aborts: a locked-down WMI hive leaves entries "unknown".
private IEnumerable<object> GetMonitoredServices()
{
var targets = config.Monitors?.Services ?? Array.Empty<MonitorTarget>();
if (targets.Length == 0)
return Array.Empty<object>();
var states = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
try
{
using var searcher = new ManagementObjectSearcher(
"SELECT Name, DisplayName, State FROM Win32_Service");
foreach (var mo in searcher.Get())
{
var state = mo["State"]?.ToString()?.Trim() ?? "";
// Index by both service key name and display name so a config
// that used either one still matches.
var name = mo["Name"]?.ToString();
if (!string.IsNullOrWhiteSpace(name))
states[name.Trim()] = state;
var display = mo["DisplayName"]?.ToString();
if (!string.IsNullOrWhiteSpace(display))
states[display.Trim()] = state;
}
}
catch (Exception error)
{
logger.LogDebug(error, "Service enumeration blocked");
}
var result = new List<object>(targets.Length);
foreach (var target in targets)
{
bool found = states.TryGetValue(target.Name, out var raw);
bool running = string.Equals(raw, "Running", StringComparison.OrdinalIgnoreCase);
result.Add(new
{
name = target.Name,
app = string.IsNullOrWhiteSpace(target.App) ? target.Name : target.App,
kind = "service",
// "unknown" = not installed / WMI unreadable; else lowercased
// Win32_Service state (running, stopped, start pending, ...).
state = found ? (raw ?? "").ToLowerInvariant() : "unknown",
running,
count = running ? 1 : 0,
});
}
return result;
}
private string? GetLoggedInUser()
{
var user = QueryFirst("SELECT UserName FROM Win32_ComputerSystem", "UserName");
return string.IsNullOrWhiteSpace(user)
? $"{Environment.UserDomainName}\\{Environment.UserName}"
: user;
}
private string? GetSerialNumber()
=> QueryFirst("SELECT SerialNumber FROM Win32_BIOS", "SerialNumber");
private string? GetPcType()
{
// Raw WMI model; the ShopDB pctype map normalizes it server-side, so the
// current site's catalog stays the single source of that mapping.
var model = QueryFirst("SELECT Model FROM Win32_ComputerSystem", "Model");
return string.IsNullOrWhiteSpace(model) ? null : model;
}
// Manufacturer for the Asset vendor/model rails (Win32_ComputerSystem
// Manufacturer, for example "Dell Inc.", "HP"). Server upserts Vendor.
private string? GetVendor()
=> QueryFirst("SELECT Manufacturer FROM Win32_ComputerSystem", "Manufacturer");
// Hardware model number. Prefer the human-readable Win32_ComputerSystem
// Model (for example "OptiPlex 7090", "Latitude 5540") - the same value
// pctype classifies on. SystemSKUNumber is an OEM SKU code (for example
// Dell's "0BCC"), not a model name, so it is only a last resort when Model
// is blank.
private string? GetModelNumber()
{
var model = QueryFirst("SELECT Model FROM Win32_ComputerSystem", "Model");
if (!string.IsNullOrWhiteSpace(model))
return model;
return QueryFirst("SELECT SystemSKUNumber FROM Win32_ComputerSystem",
"SystemSKUNumber");
}
// OS caption, for example "Microsoft Windows 10 Enterprise". The server
// matches this against its controlled OperatingSystem vocab (warns if the
// exact string is unknown), so it may need a catalog entry to bind.
private string? GetOsName()
=> QueryFirst("SELECT Caption FROM Win32_OperatingSystem", "Caption");
// Primary IPv4 address of the first enabled, non-loopback adapter with a
// default gateway. Feeds the Communication (IP) row server-side.
private string? GetPrimaryIpAddress()
{
try
{
using var searcher = new ManagementObjectSearcher(
"SELECT IPAddress, DefaultIPGateway, IPEnabled " +
"FROM Win32_NetworkAdapterConfiguration WHERE IPEnabled = True");
string? firstEnabled = null;
foreach (var mo in searcher.Get())
{
var addresses = mo["IPAddress"] as string[];
if (addresses is null)
continue;
var ipv4 = Array.Find(addresses, a =>
!string.IsNullOrWhiteSpace(a) && a.IndexOf(':') < 0);
if (string.IsNullOrWhiteSpace(ipv4))
continue;
firstEnabled ??= ipv4;
// Prefer an adapter that actually has a default gateway (the
// real LAN NIC, not a virtual/VPN interface).
if (mo["DefaultIPGateway"] is string[] gw && gw.Length > 0)
return ipv4;
}
return firstEnabled;
}
catch (Exception error)
{
logger.LogDebug(error, "IP address query failed");
return null;
}
}
// All hardware NICs, not just the primary. DESIGN-DECISIONS-agent.md 9.3:
// upwards of two-thirds of shop-floor PCs carry a SECOND, static, gatewayless
// NIC whose only job is to talk to the machine's onboard IPS. That interface
// is the physical PC-to-machine link and is exactly the one a single
// primary-IP probe throws away, so we report every enabled adapter with its
// alias/ipv4/mac/gateway/subnet and let the server keep the whole picture.
// Stored verbatim as JSON server-side (AGENT-CONTRACT.md), so keys are
// lowercase/no-underscore.
private object[] GetInterfaces()
{
var result = new List<object>();
try
{
using var searcher = new ManagementObjectSearcher(
"SELECT Description, IPAddress, IPSubnet, DefaultIPGateway, " +
"MACAddress, DHCPEnabled FROM Win32_NetworkAdapterConfiguration " +
"WHERE IPEnabled = True");
foreach (var mo in searcher.Get())
{
var mac = mo["MACAddress"] as string;
// No MAC = not a real hardware adapter (some tunnels/virtuals).
if (string.IsNullOrWhiteSpace(mac))
continue;
var addresses = mo["IPAddress"] as string[];
var ipv4 = addresses is null ? null : Array.Find(addresses,
a => !string.IsNullOrWhiteSpace(a) && a.IndexOf(':') < 0);
var subnets = mo["IPSubnet"] as string[];
var subnet = subnets is null ? null : Array.Find(subnets,
s => !string.IsNullOrWhiteSpace(s) && s.IndexOf(':') < 0);
var gateways = mo["DefaultIPGateway"] as string[];
var gateway = gateways is null || gateways.Length == 0
? null : gateways[0];
result.Add(new
{
alias = mo["Description"]?.ToString()?.Trim() ?? "",
ipv4 = ipv4 ?? "",
mac = mac.Trim(),
// A gatewayless NIC is the likely machine-IPS link; the
// gateway NIC is the corporate LAN. Server/UI can tell them
// apart from these two fields.
gateway = gateway ?? "",
subnet = subnet ?? "",
dhcp = mo["DHCPEnabled"] as bool? ?? false,
});
}
}
catch (Exception error)
{
logger.LogDebug(error, "Interface enumeration blocked");
}
return result.ToArray();
}
// The four-digit ShopDB Machine Number, discovered from on-box app config.
// DESIGN-DECISIONS-agent.md 9.4/9.5: when eDNC or UDC is installed its config
// carries the number that binds this PC to a ShopDB machines row. Source
// priority: eDNC live registry first, then UDC settings JSON. "" or "9999"
// means none (AGENT-CONTRACT.md). Never throws out to the caller.
private string? GetMachineNumber()
{
var fromRegistry = GetMachineNumberFromEdncRegistry();
if (IsUsableMachineNumber(fromRegistry))
return fromRegistry!.Trim();
var fromUdc = GetMachineNumberFromUdcSettings();
if (IsUsableMachineNumber(fromUdc))
return fromUdc!.Trim();
return null;
}
private static bool IsUsableMachineNumber(string? value)
=> !string.IsNullOrWhiteSpace(value) && value.Trim() != "9999";
// eDNC writes MachineNo under the GE Aircraft Engines\DNC\General key. Try
// the 64-bit view then the 32-bit (WOW6432Node) view, mirroring
// AgentConfigProvider so a locked-down or 32-bit-installed box still reads.
private string? GetMachineNumberFromEdncRegistry()
{
const string subKey = @"SOFTWARE\GE Aircraft Engines\DNC\General";
RegistryView[] views = { RegistryView.Registry64, RegistryView.Registry32 };
foreach (var view in views)
{
try
{
using var machine = RegistryKey.OpenBaseKey(
RegistryHive.LocalMachine, view);
using var key = machine.OpenSubKey(subKey);
var value = key?.GetValue("MachineNo")?.ToString();
if (IsUsableMachineNumber(value))
return value;
}
catch (Exception error)
{
logger.LogDebug(error,
"eDNC registry read blocked in {View} view", view);
}
}
return null;
}
// UDC stores GeneralSettings.MachineNumber in udc_settings_*.json under
// %ProgramData%\udc. Scan for the newest settings file and read the number.
private string? GetMachineNumberFromUdcSettings()
{
try
{
var root = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
"udc");
if (!Directory.Exists(root))
return null;
var files = Directory.EnumerateFiles(
root, "udc_settings*.json", SearchOption.AllDirectories)
.OrderByDescending(File.GetLastWriteTimeUtc);
foreach (var file in files)
{
try
{
using var doc = JsonDocument.Parse(File.ReadAllText(file));
if (doc.RootElement.TryGetProperty("GeneralSettings", out var general)
&& general.TryGetProperty("MachineNumber", out var number))
{
var value = number.ValueKind == JsonValueKind.String
? number.GetString()
: number.ToString();
if (IsUsableMachineNumber(value))
return value;
}
}
catch (Exception error)
{
logger.LogDebug(error, "UDC settings parse failed: {File}", file);
}
}
}
catch (Exception error)
{
logger.LogDebug(error, "UDC settings scan failed");
}
return null;
}
private DateTimeOffset? GetLastBootTime()
{
var raw = QueryFirst(
"SELECT LastBootUpTime FROM Win32_OperatingSystem", "LastBootUpTime");
if (string.IsNullOrWhiteSpace(raw))
return null;
try
{
return ManagementDateTimeConverter.ToDateTime(raw).ToUniversalTime();
}
catch (Exception error)
{
logger.LogDebug(error, "LastBootUpTime parse failed");
return null;
}
}
// Walk each curated channel newest-first, keep only the curated IDs for
// that channel, cap per channel. Reads Security/System/Application - the
// old scaffold read System only and filtered by the wrong IDs.
private EndpointEventRecord[] GetCuratedEvents()
{
var records = new List<EndpointEventRecord>();
foreach (var channel in CuratedEventCatalog.Channels)
{
var wanted = CuratedEventCatalog.IdsForChannel(channel);
try
{
using var log = new EventLog(channel);
int kept = 0;
for (int i = log.Entries.Count - 1;
i >= 0 && kept < MaxEventsPerChannel; i--)
{
EventLogEntry entry;
try { entry = log.Entries[i]; } catch { continue; }
int id = (int)(entry.InstanceId & 0xFFFF);
if (!wanted.Contains(id))
continue;
records.Add(new EndpointEventRecord
{
EventId = id,
EventChannel = channel,
EventSource = entry.Source,
EventTime = new DateTimeOffset(
entry.TimeGenerated.ToUniversalTime(), TimeSpan.Zero),
EventMessage = Truncate(entry.Message, 1000),
});
kept++;
}
}
catch (Exception error)
{
// Channel unreadable (Security needs elevation; a log may be
// off): log and continue with the other channels.
logger.LogDebug(error, "Event channel {Channel} unreadable", channel);
}
}
return records.ToArray();
}
private string? QueryFirst(string wql, string property)
{
try
{
using var searcher = new ManagementObjectSearcher(wql);
foreach (var mo in searcher.Get())
{
var value = mo[property]?.ToString();
if (!string.IsNullOrWhiteSpace(value))
return value.Trim();
}
}
catch (Exception error)
{
logger.LogDebug(error, "WMI query failed: {Query}", wql);
}
return null;
}
private static string Truncate(string? text, int max)
{
if (string.IsNullOrEmpty(text))
return "";
text = text.Trim();
return text.Length <= max ? text : text[..max];
}
}

View File

@@ -0,0 +1,164 @@
using System.Collections.Concurrent;
using System.Text;
using Microsoft.Extensions.Logging;
namespace ShopDbEndpointAgent.Core;
// A dependency-free rolling-file ILoggerProvider. The runtime code only ever
// talks to ILogger, so swapping or adding sinks later (a ShopDB HTTP sink, Seq,
// syslog) is a host-builder change with zero worker changes. This provider
// writes one line per entry to a daily file under the configured log directory
// and prunes files older than the retention window.
public sealed class FileLoggerProvider : ILoggerProvider
{
private readonly string directory;
private readonly LogLevel minLevel;
private readonly int retentionDays;
private readonly object gate = new();
private readonly ConcurrentDictionary<string, FileLogger> loggers = new();
private string currentDateStamp = "";
private StreamWriter? writer;
public FileLoggerProvider(string directory, LogLevel minLevel = LogLevel.Information,
int retentionDays = 14)
{
this.directory = directory;
this.minLevel = minLevel;
this.retentionDays = retentionDays;
// Best-effort: creating the log directory can fail on a locked-down host
// or when a non-privileged user targets a SYSTEM-owned path. Never let
// that abort construction; Write() degrades to a no-op if the sink is
// unusable.
try { Directory.CreateDirectory(directory); }
catch (Exception error) when (error is IOException or UnauthorizedAccessException) { }
}
// The default log directory when config leaves LogPath empty:
// %ProgramData%\ShopDB\EndpointAgent\logs
public static string DefaultDirectory()
=> Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
"ShopDB", "EndpointAgent", "logs");
public ILogger CreateLogger(string categoryName)
=> loggers.GetOrAdd(categoryName, name => new FileLogger(this, name));
internal bool IsEnabled(LogLevel level) => level >= minLevel && level != LogLevel.None;
internal void Write(string category, LogLevel level, string message, Exception? error)
{
var now = DateTimeOffset.Now;
var line = new StringBuilder()
.Append(now.ToString("yyyy-MM-dd HH:mm:ss.fff zzz"))
.Append(" [").Append(LevelTag(level)).Append("] ")
.Append(ShortCategory(category)).Append(": ")
.Append(message);
if (error is not null)
line.Append(Environment.NewLine).Append(error);
lock (gate)
{
// Logging must never take down the agent. If the log file cannot be
// opened or written (for example a non-privileged user running the
// exe against a logs directory owned by the SYSTEM service, which
// yields UnauthorizedAccessException on open), swallow it and drop
// the line rather than letting it unwind through bootstrap. A failed
// sink is a degraded state, not a fatal one.
try
{
RollIfNeeded(now);
writer?.WriteLine(line.ToString());
}
catch (Exception sinkError) when (sinkError is IOException or UnauthorizedAccessException)
{
writer = null;
}
}
}
private void RollIfNeeded(DateTimeOffset now)
{
var stamp = now.ToString("yyyyMMdd");
if (stamp == currentDateStamp && writer is not null)
return;
writer?.Flush();
writer?.Dispose();
currentDateStamp = stamp;
var path = Path.Combine(directory, $"agent-{stamp}.log");
writer = new StreamWriter(new FileStream(
path, FileMode.Append, FileAccess.Write, FileShare.ReadWrite))
{
AutoFlush = true,
};
PruneOldFiles();
}
private void PruneOldFiles()
{
try
{
var cutoff = DateTime.Now.AddDays(-retentionDays);
foreach (var file in Directory.EnumerateFiles(directory, "agent-*.log"))
{
if (File.GetLastWriteTime(file) < cutoff)
File.Delete(file);
}
}
catch
{
// Pruning is best-effort; never let housekeeping break logging.
}
}
private static string LevelTag(LogLevel level) => level switch
{
LogLevel.Trace => "TRC",
LogLevel.Debug => "DBG",
LogLevel.Information => "INF",
LogLevel.Warning => "WRN",
LogLevel.Error => "ERR",
LogLevel.Critical => "CRT",
_ => "---",
};
private static string ShortCategory(string category)
{
var dot = category.LastIndexOf('.');
return dot >= 0 && dot < category.Length - 1 ? category[(dot + 1)..] : category;
}
public void Dispose()
{
lock (gate)
{
writer?.Flush();
writer?.Dispose();
writer = null;
}
}
private sealed class FileLogger : ILogger
{
private readonly FileLoggerProvider provider;
private readonly string category;
public FileLogger(FileLoggerProvider provider, string category)
{
this.provider = provider;
this.category = category;
}
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(LogLevel logLevel) => provider.IsEnabled(logLevel);
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state,
Exception? exception, Func<TState, Exception?, string> formatter)
{
if (!IsEnabled(logLevel))
return;
provider.Write(category, logLevel, formatter(state, exception), exception);
}
}
}

View File

@@ -0,0 +1,24 @@
namespace ShopDbEndpointAgent.Core;
// The collection surface. The runtime worker depends only on this, so the
// concrete implementation (pure C# today, possibly C# + PowerShell utilities
// later) can change without touching the worker or the HTTP client.
public interface ICollector
{
EndpointReport Collect(string agentVersion, bool adopt);
}
// The documented seam for site-provided PowerShell "agent utilities". The
// agent configuration settings page (server-side, future) lets site staff load
// their own .ps1 scripts as utilities; the runtime discovers them under the
// configured utilities directory and merges their JSON output into the report.
//
// This iteration ships the interface and a discovery contract only - the
// concrete runner is added when the settings page can push utilities down.
public interface IAgentUtilityRunner
{
// Run every discovered utility and return a map of utility name -> parsed
// JSON result, to be attached to the report. Never throws: a failing
// utility is logged and skipped so it cannot break the telemetry run.
IReadOnlyDictionary<string, object> RunAll(CancellationToken token);
}

View File

@@ -0,0 +1,200 @@
using System.ComponentModel;
using System.Runtime.InteropServices;
namespace ShopDbEndpointAgent.Core;
// Launches a program in the ACTIVE CONSOLE USER'S session at that user's
// integrity, from the agent's LocalSystem service (session 0).
//
// Why this exists: the agent runs as a session-0 service. A plain
// Process.Start() there launches GUI apps (DncMain.exe, UDC.exe) onto the hidden
// session-0 desktop, so the logged-in operator never sees a window even though
// the process is running. process_start needs the app to appear on the user's
// desktop, so we obtain the interactive user's token and CreateProcessAsUser.
//
// Elevation is deliberately NOT handled here: the process starts at the user's
// normal (medium) integrity and CyberArk/EPM brokers any elevation afterward.
//
// Only process_start uses this. Stop/status/service actions stay as SYSTEM.
public static class InteractiveProcessLauncher
{
public readonly record struct LaunchOutcome(bool Launched, string? Error);
private const int TokenDuplicate = 0x0002;
private const int TokenQuery = 0x0008;
private const int TokenAssignPrimary = 0x0001;
private const int TokenAdjustPrivileges = 0x0020;
private const int TokenAdjustDefault = 0x0080;
private const int TokenAdjustSessionId = 0x0100;
private const uint CreateUnicodeEnvironment = 0x00000400;
private const uint CreateNoWindow = 0x08000000;
private const uint InvalidSessionId = 0xFFFFFFFF;
// Returns Launched=true when CreateProcessAsUser succeeds. Never throws;
// failures come back as Launched=false with a descriptive Error so the
// caller can surface a clean CommandResult.
public static LaunchOutcome LaunchInActiveSession(
string applicationPath, string? workingDirectory)
{
var sessionId = WTSGetActiveConsoleSessionId();
if (sessionId == InvalidSessionId)
return new LaunchOutcome(false,
"No active console session (no interactive user is signed in).");
if (!WTSQueryUserToken(sessionId, out var userToken))
return new LaunchOutcome(false,
"Could not obtain the interactive user token (WTSQueryUserToken failed): "
+ new Win32Exception(Marshal.GetLastWin32Error()).Message);
var duplicatedToken = IntPtr.Zero;
var environmentBlock = IntPtr.Zero;
try
{
var desiredAccess = TokenAssignPrimary | TokenDuplicate | TokenQuery
| TokenAdjustPrivileges | TokenAdjustDefault | TokenAdjustSessionId;
if (!DuplicateTokenEx(userToken, (uint)desiredAccess, IntPtr.Zero,
SecurityImpersonationLevel.SecurityImpersonation,
TokenType.TokenPrimary, out duplicatedToken))
{
return new LaunchOutcome(false,
"Could not duplicate the user token (DuplicateTokenEx failed): "
+ new Win32Exception(Marshal.GetLastWin32Error()).Message);
}
// A per-user environment block so the launched app sees the user's
// profile variables rather than SYSTEM's.
if (!CreateEnvironmentBlock(out environmentBlock, duplicatedToken, false))
environmentBlock = IntPtr.Zero;
var startupInfo = new STARTUPINFO();
startupInfo.cb = Marshal.SizeOf(startupInfo);
// Target the interactive desktop so the window is visible.
startupInfo.lpDesktop = @"winsta0\default";
var creationFlags = CreateUnicodeEnvironment | CreateNoWindow;
var launched = CreateProcessAsUser(
duplicatedToken,
applicationPath,
null,
IntPtr.Zero,
IntPtr.Zero,
false,
creationFlags,
environmentBlock,
workingDirectory,
ref startupInfo,
out var processInformation);
if (!launched)
{
return new LaunchOutcome(false,
"CreateProcessAsUser failed: "
+ new Win32Exception(Marshal.GetLastWin32Error()).Message);
}
if (processInformation.hProcess != IntPtr.Zero)
CloseHandle(processInformation.hProcess);
if (processInformation.hThread != IntPtr.Zero)
CloseHandle(processInformation.hThread);
return new LaunchOutcome(true, null);
}
finally
{
if (environmentBlock != IntPtr.Zero)
DestroyEnvironmentBlock(environmentBlock);
if (duplicatedToken != IntPtr.Zero)
CloseHandle(duplicatedToken);
if (userToken != IntPtr.Zero)
CloseHandle(userToken);
}
}
private enum SecurityImpersonationLevel
{
SecurityAnonymous,
SecurityIdentification,
SecurityImpersonation,
SecurityDelegation,
}
private enum TokenType
{
TokenPrimary = 1,
TokenImpersonation,
}
[StructLayout(LayoutKind.Sequential)]
private struct PROCESS_INFORMATION
{
public IntPtr hProcess;
public IntPtr hThread;
public uint dwProcessId;
public uint dwThreadId;
}
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
private struct STARTUPINFO
{
public int cb;
public string? lpReserved;
public string? lpDesktop;
public string? lpTitle;
public uint dwX;
public uint dwY;
public uint dwXSize;
public uint dwYSize;
public uint dwXCountChars;
public uint dwYCountChars;
public uint dwFillAttribute;
public uint dwFlags;
public ushort wShowWindow;
public ushort cbReserved2;
public IntPtr lpReserved2;
public IntPtr hStdInput;
public IntPtr hStdOutput;
public IntPtr hStdError;
}
[DllImport("kernel32.dll", SetLastError = true)]
private static extern uint WTSGetActiveConsoleSessionId();
[DllImport("wtsapi32.dll", SetLastError = true)]
private static extern bool WTSQueryUserToken(uint sessionId, out IntPtr token);
[DllImport("advapi32.dll", SetLastError = true)]
private static extern bool DuplicateTokenEx(
IntPtr existingToken,
uint desiredAccess,
IntPtr tokenAttributes,
SecurityImpersonationLevel impersonationLevel,
TokenType tokenType,
out IntPtr newToken);
[DllImport("userenv.dll", SetLastError = true)]
private static extern bool CreateEnvironmentBlock(
out IntPtr environment, IntPtr token, bool inherit);
[DllImport("userenv.dll", SetLastError = true)]
private static extern bool DestroyEnvironmentBlock(IntPtr environment);
[DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
private static extern bool CreateProcessAsUser(
IntPtr token,
string? applicationName,
string? commandLine,
IntPtr processAttributes,
IntPtr threadAttributes,
bool inheritHandles,
uint creationFlags,
IntPtr environment,
string? currentDirectory,
ref STARTUPINFO startupInfo,
out PROCESS_INFORMATION processInformation);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool CloseHandle(IntPtr handle);
}

View File

@@ -0,0 +1,114 @@
using System.Diagnostics;
using System.Text.Json;
using Microsoft.Extensions.Logging;
namespace ShopDbEndpointAgent.Core;
// Site-provided PowerShell "agent utilities" runner. Discovers *.ps1 under the
// configured utilities directory and runs each with the OS powershell.exe,
// parsing its stdout as JSON. This is the documented collection seam: the
// future agent-configuration settings page lets site staff push their own
// scripts down, and their JSON output is merged into the report under
// `scheduledtasks`-adjacent extension keys without the runtime knowing what any
// script means.
//
// Safety posture for this iteration:
// - Disabled unless a utilities directory is configured AND exists.
// - Each script is bounded by a timeout and its failure is isolated.
// - Output must be a single JSON value; non-JSON output is logged and dropped.
// Script SIGNING/allow-listing is a rollout concern (code signing + Intune) and
// is called out in the design doc; do not enable this in production until that
// is in place.
public sealed class PowerShellUtilityRunner : IAgentUtilityRunner
{
private readonly string utilitiesPath;
private readonly ILogger<PowerShellUtilityRunner> logger;
private readonly TimeSpan perScriptTimeout;
public PowerShellUtilityRunner(string utilitiesPath,
ILogger<PowerShellUtilityRunner> logger, TimeSpan? perScriptTimeout = null)
{
this.utilitiesPath = utilitiesPath;
this.logger = logger;
this.perScriptTimeout = perScriptTimeout ?? TimeSpan.FromSeconds(30);
}
public IReadOnlyDictionary<string, object> RunAll(CancellationToken token)
{
var results = new Dictionary<string, object>();
if (string.IsNullOrWhiteSpace(utilitiesPath) || !Directory.Exists(utilitiesPath))
return results;
foreach (var script in Directory.EnumerateFiles(utilitiesPath, "*.ps1"))
{
token.ThrowIfCancellationRequested();
var name = Path.GetFileNameWithoutExtension(script);
try
{
var parsed = RunOne(script, token);
if (parsed is not null)
results[name] = parsed;
}
catch (Exception error)
{
logger.LogWarning(error, "Agent utility {Utility} failed", name);
}
}
return results;
}
private object? RunOne(string scriptPath, CancellationToken token)
{
var start = new ProcessStartInfo
{
FileName = "powershell.exe",
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
};
start.ArgumentList.Add("-NoProfile");
start.ArgumentList.Add("-NonInteractive");
start.ArgumentList.Add("-ExecutionPolicy");
start.ArgumentList.Add("Bypass");
start.ArgumentList.Add("-File");
start.ArgumentList.Add(scriptPath);
using var process = new Process { StartInfo = start };
if (!process.Start())
return null;
var stdout = process.StandardOutput.ReadToEnd();
var stderr = process.StandardError.ReadToEnd();
if (!process.WaitForExit((int)perScriptTimeout.TotalMilliseconds))
{
try { process.Kill(entireProcessTree: true); } catch { }
logger.LogWarning("Agent utility {Script} timed out", scriptPath);
return null;
}
if (process.ExitCode != 0)
{
logger.LogWarning("Agent utility {Script} exit {Code}: {Error}",
scriptPath, process.ExitCode, stderr.Trim());
return null;
}
stdout = stdout.Trim();
if (stdout.Length == 0)
return null;
try
{
using var document = JsonDocument.Parse(stdout);
return JsonSerializer.Deserialize<object>(stdout);
}
catch (JsonException)
{
logger.LogWarning("Agent utility {Script} produced non-JSON output",
scriptPath);
return null;
}
}
}

View File

@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0-windows</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>ShopDbEndpointAgent.Core</RootNamespace>
<AssemblyName>ShopDbEndpointAgent.Core</AssemblyName>
<InvariantGlobalization>true</InvariantGlobalization>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Options" Version="8.0.0" />
<PackageReference Include="Microsoft.Win32.Registry" Version="5.0.0" />
<PackageReference Include="System.Management" Version="8.0.0" />
<PackageReference Include="System.Diagnostics.EventLog" Version="8.0.0" />
<PackageReference Include="System.ServiceProcess.ServiceController" Version="8.0.0" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,129 @@
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace ShopDbEndpointAgent.Core;
// The telemetry loop as a hosted BackgroundService. On first contact it sends
// adopt=true (once) so the server's computers collector creates the PC; every
// run after that omits adopt and relies on strict hostname resolution. Between
// runs it waits the configured cadence plus/minus jitter so a fleet does not
// report in lockstep (thundering herd).
//
// The worker never lets one bad run kill the loop: a transient failure is
// already retried inside CollectorClient, and a permanent failure (bad key,
// PC removed server-side) is logged and the loop simply waits for the next
// cadence tick rather than hammering the server.
public sealed class TelemetryWorker : BackgroundService
{
private readonly ICollector collector;
private readonly CollectorClient client;
private readonly IAgentUtilityRunner? utilities;
private readonly AgentConfig config;
private readonly string agentVersion;
private readonly ILogger<TelemetryWorker> logger;
private readonly CollectSignal? collectSignal;
private readonly Random jitter = new();
public TelemetryWorker(ICollector collector, CollectorClient client,
AgentConfig config, string agentVersion, ILogger<TelemetryWorker> logger,
IAgentUtilityRunner? utilities = null, CollectSignal? collectSignal = null)
{
this.collector = collector;
this.client = client;
this.config = config;
this.agentVersion = agentVersion;
this.logger = logger;
this.utilities = utilities;
this.collectSignal = collectSignal;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
logger.LogInformation(
"Endpoint agent {Version} -> {BaseUrl}, cadence {Cadence}s +/-{Jitter}s",
agentVersion, config.BaseUrl, config.CadenceSeconds, config.JitterSeconds);
bool firstRun = true;
while (!stoppingToken.IsCancellationRequested)
{
bool adopt = firstRun && config.AdoptOnFirstRun;
try
{
await RunOnceAsync(adopt, stoppingToken);
firstRun = false;
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (CollectorException error) when (error.IsPermanent)
{
// Bad key or PC removed server-side: surface it, do not retry
// faster than cadence. If it was a first-run adopt, keep trying
// to adopt next tick (the operator may be provisioning the PC).
logger.LogError(error, "Permanent collector error; waiting for next cadence");
}
catch (Exception error)
{
logger.LogError(error, "Telemetry run failed");
}
try
{
if (collectSignal is not null)
{
// Wake early if a command asked for an immediate re-collect,
// otherwise fall through when the cadence delay elapses.
bool woken = await collectSignal.WaitAsync(NextDelay(), stoppingToken);
if (woken)
logger.LogInformation("Off-cadence collect requested after a command");
}
else
{
await Task.Delay(NextDelay(), stoppingToken);
}
}
catch (OperationCanceledException)
{
break;
}
}
logger.LogInformation("Endpoint agent stopped");
}
// Public so a --once console run and future on-demand triggers can reuse the
// exact production collection path.
public async Task<CollectorResponse> RunOnceAsync(bool adopt, CancellationToken token)
{
var report = collector.Collect(agentVersion, adopt);
if (utilities is not null)
{
var extra = utilities.RunAll(token);
if (extra.Count > 0)
logger.LogDebug("Loaded {Count} agent utility result(s)", extra.Count);
}
logger.LogInformation(
"Reporting (adopt={Adopt}) user={User} serial={Serial} events={Events}",
adopt, report.LoggedInUser, report.SerialNumber, report.Events?.Length ?? 0);
var result = await client.SendAsync(report, token);
logger.LogInformation("OK action={Action} assetid={AssetId} identity={Identity}",
result.Action, result.AssetId, result.IdentityValue);
foreach (var warning in result.Warnings ?? Array.Empty<string>())
logger.LogWarning("Server warning: {Warning}", warning);
return result;
}
private TimeSpan NextDelay()
{
int spread = Math.Max(0, config.JitterSeconds);
int offset = spread == 0 ? 0 : jitter.Next(-spread, spread + 1);
int seconds = Math.Max(1, config.CadenceSeconds + offset);
return TimeSpan.FromSeconds(seconds);
}
}

View File

@@ -0,0 +1,17 @@
namespace ShopDbEndpointAgent.Tray;
// Interactive user-session tray app (SCAFFOLD). Separate process from the
// 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
// stubbed ticket dialog; the ServiceNow submission (direct vs server-proxied)
// is deferred until that decision is made server-side.
internal static class Program
{
[STAThread]
private static void Main()
{
ApplicationConfiguration.Initialize();
Application.Run(new TrayContext());
}
}

View File

@@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<UseWindowsForms>true</UseWindowsForms>
<RootNamespace>ShopDbEndpointAgent.Tray</RootNamespace>
<AssemblyName>shopdb-endpoint-tray</AssemblyName>
<InvariantGlobalization>true</InvariantGlobalization>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<SelfContained>true</SelfContained>
<PublishSingleFile>true</PublishSingleFile>
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
<EnableCompressionInSingleFile>true</EnableCompressionInSingleFile>
<DebugType>none</DebugType>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\ShopDbEndpointAgent.Core\ShopDbEndpointAgent.Core.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,118 @@
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.
internal sealed class TicketForm : Form
{
// Fixed dropdown reasons from the source conversation (section 6).
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)",
};
private readonly ComboBox reasonBox;
private readonly TextBox detailBox;
private readonly CheckBox screenshotBox;
public TicketForm()
{
Text = "Report an issue";
FormBorderStyle = FormBorderStyle.FixedDialog;
StartPosition = FormStartPosition.CenterScreen;
MinimizeBox = false;
MaximizeBox = false;
ClientSize = new Size(420, 260);
var reasonLabel = new Label
{
Text = "What is happening?",
Left = 16,
Top = 16,
Width = 388,
};
reasonBox = new ComboBox
{
Left = 16,
Top = 40,
Width = 388,
DropDownStyle = ComboBoxStyle.DropDownList,
};
reasonBox.Items.AddRange(Reasons);
reasonBox.SelectedIndex = 0;
var detailLabel = new Label
{
Text = "Details (optional)",
Left = 16,
Top = 76,
Width = 388,
};
detailBox = new TextBox
{
Left = 16,
Top = 100,
Width = 388,
Height = 80,
Multiline = true,
};
screenshotBox = new CheckBox
{
Text = "Attach a screenshot of my screen",
Left = 16,
Top = 188,
Width = 388,
};
var submit = new Button
{
Text = "Submit",
Left = 244,
Top = 220,
Width = 76,
DialogResult = DialogResult.OK,
};
submit.Click += OnSubmit;
var cancel = new Button
{
Text = "Cancel",
Left = 328,
Top = 220,
Width = 76,
DialogResult = DialogResult.Cancel,
};
Controls.AddRange(new Control[]
{
reasonLabel, reasonBox, detailLabel, detailBox, screenshotBox,
submit, cancel,
});
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);
}
}

View File

@@ -0,0 +1,202 @@
using System.Diagnostics;
namespace ShopDbEndpointAgent.Tray;
// Owns the tray icon and its context menu for the app's lifetime. Kept minimal
// and headless-friendly: no main window, the icon is the whole UI surface until
// the operator opens the ticket dialog.
//
// "Send Status now" reuses the exact production collection path by shelling out
// to the installed shopdbagent.exe with --once. That keeps the collector key and
// all collection logic in the service binary: the tray never holds a secret and
// never duplicates wiring, it just asks the agent to report on demand so the
// ShopDB monitor page reflects the box immediately instead of at next cadence.
internal sealed class TrayContext : ApplicationContext
{
// The headless agent/service executable. The tray ships next to it in the
// install directory, so same-dir resolution is the primary path.
private const string AgentExeName = "shopdbagent.exe";
private const string DefaultInstallDir = @"C:\Program Files\ShopDB\EndpointAgent";
private readonly NotifyIcon trayIcon;
private readonly Icon brandIcon;
private bool sending;
public TrayContext()
{
var menu = new ContextMenuStrip();
menu.Items.Add("Send Status now", null, OnSendStatus);
menu.Items.Add("Report an issue...", null, OnReportIssue);
menu.Items.Add(new ToolStripSeparator());
menu.Items.Add("About ShopDB agent", null, OnAbout);
menu.Items.Add("Exit", null, OnExit);
// Branded tray icon drawn from the ShopDB WebUI favicon (see TrayIcon).
brandIcon = TrayIcon.Create();
trayIcon = new NotifyIcon
{
Icon = brandIcon,
Text = "ShopDB Endpoint Agent",
Visible = true,
ContextMenuStrip = menu,
};
trayIcon.DoubleClick += OnSendStatus;
}
private void OnReportIssue(object? sender, EventArgs e)
{
using var dialog = new TicketForm();
dialog.ShowDialog();
}
// Runs shopdbagent.exe --once out of process so the operator can push a
// fresh report without waiting for the next cadence tick. The exit code is
// the agent's own contract: 0 = reported OK, non-zero = failed (bad key,
// host removed, network) and the detail is in the rolling agent log.
private async void OnSendStatus(object? sender, EventArgs e)
{
if (sending)
return;
var agentExe = ResolveAgentExe();
if (agentExe is null)
{
ShowBalloon("Send Status", "Could not find " + AgentExeName +
". Is the ShopDB Endpoint Agent installed?", ToolTipIcon.Error);
return;
}
sending = true;
trayIcon.Text = "ShopDB Endpoint Agent (sending status...)";
ShowBalloon("Send Status", "Sending this PC's status to ShopDB...",
ToolTipIcon.Info);
try
{
int exitCode = await RunAgentOnceAsync(agentExe);
if (exitCode == 0)
ShowBalloon("Send Status", "Status sent to ShopDB.",
ToolTipIcon.Info);
else
ShowBalloon("Send Status",
"Status send failed (exit " + exitCode +
"). See the agent log under ProgramData\\ShopDB.",
ToolTipIcon.Warning);
}
catch (Exception error)
{
ShowBalloon("Send Status", "Could not run the agent: " +
error.Message, ToolTipIcon.Error);
}
finally
{
sending = false;
trayIcon.Text = "ShopDB Endpoint Agent";
}
}
private static Task<int> RunAgentOnceAsync(string agentExe)
{
var startInfo = new ProcessStartInfo
{
FileName = agentExe,
Arguments = "--once",
WorkingDirectory = Path.GetDirectoryName(agentExe) ?? Environment.CurrentDirectory,
UseShellExecute = false,
CreateNoWindow = true,
};
// The tray and the agent are both single-file, self-contained apps. When
// the tray host started, the .NET single-file bootstrapper set DOTNET_*
// and bundle-extraction variables in this process's environment. With
// UseShellExecute=false the child inherits those variables, so the
// freshly installed agent tries to bootstrap against the tray's stale
// extraction state and dies during host startup (exit 0xE0434352) before
// any managed code - or the file log - runs. Stripping these inherited
// markers gives the child the same clean host environment it gets when
// launched from a shell, and it also removes the need to restart the tray
// after every agent rebuild. The agent re-derives whatever it needs.
ScrubInheritedDotnetHostVariables(startInfo);
var completion = new TaskCompletionSource<int>();
var process = new Process { StartInfo = startInfo, EnableRaisingEvents = true };
process.Exited += (_, _) =>
{
completion.TrySetResult(process.ExitCode);
process.Dispose();
};
if (!process.Start())
{
process.Dispose();
completion.TrySetException(
new InvalidOperationException("failed to start " + agentExe));
}
return completion.Task;
}
// Remove the .NET host / single-file bundle markers this tray process
// inherited at startup so a spawned single-file child bootstraps cleanly
// instead of adopting the tray's extraction state. Names are matched by a
// DOTNET_/COREHOST_ prefix (covers DOTNET_BUNDLE_EXTRACT_BASE_DIR,
// DOTNET_ROOT, the internal startup hooks, etc.) so this stays correct as
// the runtime adds new host variables.
private static void ScrubInheritedDotnetHostVariables(ProcessStartInfo startInfo)
{
var toRemove = startInfo.Environment.Keys
.Where(key =>
key.StartsWith("DOTNET_", StringComparison.OrdinalIgnoreCase) ||
key.StartsWith("COREHOST_", StringComparison.OrdinalIgnoreCase))
.ToList();
foreach (var key in toRemove)
startInfo.Environment.Remove(key);
}
// Same directory first (the installer ships the tray next to the agent),
// then the known Program Files install directory as a fallback.
private static string? ResolveAgentExe()
{
var beside = Path.Combine(AppContext.BaseDirectory, AgentExeName);
if (File.Exists(beside))
return beside;
var installed = Path.Combine(DefaultInstallDir, AgentExeName);
if (File.Exists(installed))
return installed;
return null;
}
private void ShowBalloon(string title, string text, ToolTipIcon icon)
{
trayIcon.BalloonTipTitle = title;
trayIcon.BalloonTipText = text;
trayIcon.BalloonTipIcon = icon;
trayIcon.ShowBalloonTip(4000);
}
private void OnAbout(object? sender, EventArgs e)
=> 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.",
"About", MessageBoxButtons.OK, MessageBoxIcon.Information);
private void OnExit(object? sender, EventArgs e)
{
trayIcon.Visible = false;
ExitThread();
}
protected override void Dispose(bool disposing)
{
if (disposing)
{
trayIcon.Dispose();
brandIcon.Dispose();
}
base.Dispose(disposing);
}
}

View File

@@ -0,0 +1,81 @@
using System.Drawing.Drawing2D;
using System.Drawing.Imaging;
using System.Runtime.InteropServices;
namespace ShopDbEndpointAgent.Tray;
// Builds the tray icon from the ShopDB WebUI favicon (frontend/public/favicon.svg:
// a #4181ff rounded square with a white "S") by drawing it with GDI+ at runtime.
// Drawing it here rather than shipping a binary .ico keeps the brand in one
// place (these values mirror the SVG) and avoids adding an SVG rasterizer or a
// checked-in binary to the build.
internal static class TrayIcon
{
// Favicon colors (frontend/public/favicon.svg).
private static readonly Color Background = ColorTranslator.FromHtml("#4181ff");
private static readonly Color Foreground = Color.White;
// Renders the branded icon at a tray-friendly size. 32px covers standard and
// most high-DPI tray scaling; the OS downsamples as needed.
public static Icon Create(int size = 32)
{
using var bitmap = new Bitmap(size, size, PixelFormat.Format32bppArgb);
using (var graphics = Graphics.FromImage(bitmap))
{
graphics.SmoothingMode = SmoothingMode.AntiAlias;
graphics.TextRenderingHint =
System.Drawing.Text.TextRenderingHint.AntiAlias;
graphics.Clear(Color.Transparent);
// Rounded square (SVG rect rx=14 on a 64 viewport -> ~0.22 radius).
float inset = size * 0.03f;
var rect = new RectangleF(inset, inset,
size - inset * 2, size - inset * 2);
float radius = size * 0.22f;
using (var path = RoundedRect(rect, radius))
using (var fill = new SolidBrush(Background))
graphics.FillPath(fill, path);
// White "S", centered.
using var font = new Font("Arial", size * 0.58f,
FontStyle.Bold, GraphicsUnit.Pixel);
using var text = new SolidBrush(Foreground);
using var format = new StringFormat
{
Alignment = StringAlignment.Center,
LineAlignment = StringAlignment.Center,
};
graphics.DrawString("S", font, text, rect, format);
}
// Icon.FromHandle borrows the GDI HICON, so clone into a managed Icon
// and free the handle to avoid leaking one per launch.
IntPtr hicon = bitmap.GetHicon();
try
{
using var borrowed = Icon.FromHandle(hicon);
return (Icon)borrowed.Clone();
}
finally
{
DestroyIcon(hicon);
}
}
private static GraphicsPath RoundedRect(RectangleF rect, float radius)
{
float diameter = radius * 2;
var path = new GraphicsPath();
path.AddArc(rect.Left, rect.Top, diameter, diameter, 180, 90);
path.AddArc(rect.Right - diameter, rect.Top, diameter, diameter, 270, 90);
path.AddArc(rect.Right - diameter, rect.Bottom - diameter, diameter,
diameter, 0, 90);
path.AddArc(rect.Left, rect.Bottom - diameter, diameter, diameter, 90, 90);
path.CloseFigure();
return path;
}
[DllImport("user32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool DestroyIcon(IntPtr handle);
}

View File

@@ -0,0 +1,5 @@
<Solution>
<Project Path="ShopDbEndpointAgent.Core/ShopDbEndpointAgent.Core.csproj" />
<Project Path="ShopDbEndpointAgent.Tray/ShopDbEndpointAgent.Tray.csproj" />
<Project Path="ShopDbEndpointAgent/ShopDbEndpointAgent.csproj" />
</Solution>

View File

@@ -0,0 +1,164 @@
using System.Reflection;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using ShopDbEndpointAgent.Core;
// Headless Windows Service host for the ShopDB endpoint agent. Composes the
// Core runtime (config -> collector -> client -> worker) behind the generic
// host, adds durable file logging and a WER-style fault monitor, and supports:
// (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)
//
// Secrets are never read here: AgentConfigProvider layers the registry
// (HKLM\SOFTWARE\GE\ShopDB: CollectorKey, BaseUrl) over the shipped, non-secret
// appsettings.json. The executable is intentionally named shopdbagent.exe so it
// never collides with third-party agent.exe processes/services (for example the
// MTConnect agent this same build can monitor).
var runOnce = args.Any(a => string.Equals(a, "--once", StringComparison.OrdinalIgnoreCase));
var forceConsole = args.Any(a => string.Equals(a, "--console", StringComparison.OrdinalIgnoreCase));
var agentVersion = Assembly.GetExecutingAssembly()
.GetName().Version?.ToString() ?? "0.0.0";
// Bootstrap logger first so config loading (including the registry read that
// CyberArk/EPM may gate) is fully diagnosable before the host starts.
using var bootstrapFileLogger = new FileLoggerProvider(FileLoggerProvider.DefaultDirectory());
var bootstrapLogger = bootstrapFileLogger.CreateLogger("Startup");
var faultMonitor = new AgentFaultMonitor(bootstrapLogger);
faultMonitor.Install();
var config = AgentConfigProvider.Load(logger: bootstrapLogger);
var logDirectory = string.IsNullOrWhiteSpace(config.LogPath)
? FileLoggerProvider.DefaultDirectory()
: config.LogPath;
if (!config.IsUsable())
{
bootstrapLogger.LogError(
"Config incomplete: provision CollectorKey and BaseUrl in " +
"HKLM\\SOFTWARE\\GE\\ShopDB (or baseurl/apikey in appsettings.json for dev). " +
"See the registry lines above to tell 'missing' from 'access denied'.");
return 1;
}
try
{
if (runOnce)
return await RunOnceAsync(config, agentVersion, logDirectory, faultMonitor);
await RunHostAsync(config, agentVersion, logDirectory, forceConsole);
return 0;
}
catch (Exception error)
{
faultMonitor.ReportFatal("host", error);
return 1;
}
static async Task RunHostAsync(AgentConfig config, string agentVersion,
string logDirectory, bool forceConsole)
{
var builder = Host.CreateApplicationBuilder();
if (!forceConsole)
builder.Services.AddWindowsService(options =>
options.ServiceName = "ShopDbEndpointAgent");
ConfigureLogging(builder.Logging, logDirectory);
RegisterCoreServices(builder.Services, config, agentVersion);
builder.Services.AddHostedService(sp => sp.GetRequiredService<TelemetryWorker>());
builder.Services.AddHostedService(sp => sp.GetRequiredService<CommandWorker>());
await builder.Build().RunAsync();
}
static async Task<int> RunOnceAsync(AgentConfig config, string agentVersion,
string logDirectory, AgentFaultMonitor faultMonitor)
{
using var services = BuildOnceServices(config, agentVersion, logDirectory);
var worker = services.GetRequiredService<TelemetryWorker>();
var logger = services.GetRequiredService<ILogger<TelemetryWorker>>();
try
{
var result = await worker.RunOnceAsync(config.AdoptOnFirstRun, CancellationToken.None);
logger.LogInformation("Single report complete: action={Action}", result.Action);
return 0;
}
catch (Exception error)
{
faultMonitor.ReportFatal("--once", error);
return 1;
}
}
static ServiceProvider BuildOnceServices(AgentConfig config, string agentVersion,
string logDirectory)
{
var services = new ServiceCollection();
services.AddLogging(logging => ConfigureLogging(logging, logDirectory));
RegisterCoreServices(services, config, agentVersion);
return services.BuildServiceProvider();
}
static void RegisterCoreServices(IServiceCollection services, AgentConfig config,
string agentVersion)
{
services.AddSingleton(config);
// Shared wake used by the command worker to trigger an immediate telemetry
// collect after a command changes endpoint state (e.g. MTConnect toggle).
services.AddSingleton<CollectSignal>();
if (!string.IsNullOrWhiteSpace(config.UtilitiesPath) &&
Directory.Exists(config.UtilitiesPath))
{
services.AddSingleton<IAgentUtilityRunner>(sp =>
new PowerShellUtilityRunner(config.UtilitiesPath,
sp.GetRequiredService<ILogger<PowerShellUtilityRunner>>()));
}
services.AddSingleton<ICollector>(sp => new EndpointCollector(
sp.GetRequiredService<ILogger<EndpointCollector>>(),
config,
sp.GetService<IAgentUtilityRunner>()));
services.AddSingleton(sp => new CollectorClient(
config, sp.GetRequiredService<ILogger<CollectorClient>>()));
services.AddSingleton(sp => new TelemetryWorker(
sp.GetRequiredService<ICollector>(),
sp.GetRequiredService<CollectorClient>(),
config,
agentVersion,
sp.GetRequiredService<ILogger<TelemetryWorker>>(),
sp.GetService<IAgentUtilityRunner>(),
sp.GetRequiredService<CollectSignal>()));
// Command channel: outbound long-poll for allowlisted WebUI commands, run
// through the fail-closed executor. Hosted alongside telemetry but on its
// own loop so a held-open poll never delays a report.
services.AddSingleton(sp => new CommandClient(
config, sp.GetRequiredService<ILogger<CommandClient>>()));
services.AddSingleton(sp => new CommandExecutor(
sp.GetRequiredService<ILogger<CommandExecutor>>(),
config));
services.AddSingleton(sp => new CommandWorker(
sp.GetRequiredService<CommandClient>(),
sp.GetRequiredService<CommandExecutor>(),
sp.GetRequiredService<ILogger<CommandWorker>>(),
sp.GetRequiredService<CollectSignal>()));
}
static void ConfigureLogging(ILoggingBuilder logging, string logDirectory)
{
logging.ClearProviders();
logging.AddProvider(new FileLoggerProvider(logDirectory));
logging.SetMinimumLevel(LogLevel.Information);
}

View File

@@ -0,0 +1,40 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>ShopDbEndpointAgent</RootNamespace>
<AssemblyName>shopdbagent</AssemblyName>
<!-- 1.2.0: process_start launches via a per-image path allowlist
(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.3.0</Version>
<InvariantGlobalization>true</InvariantGlobalization>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<SelfContained>true</SelfContained>
<PublishSingleFile>true</PublishSingleFile>
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
<EnableCompressionInSingleFile>true</EnableCompressionInSingleFile>
<DebugType>none</DebugType>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.EventLog" Version="8.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\ShopDbEndpointAgent.Core\ShopDbEndpointAgent.Core.csproj" />
</ItemGroup>
<ItemGroup>
<None Update="appsettings.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,26 @@
{
"baseurl": "",
"apikey": "REPLACE_WITH_COLLECTOR_KEY",
"cadenceseconds": 900,
"jitterseconds": 60,
"adoptonfirstrun": true,
"utilitiespath": "",
"logpath": "",
"monitors": {
"processes": [
{ "name": "dncMain.exe", "app": "eDNC" },
{ "name": "udc.exe", "app": "Universal Data Collector" },
{ "name": "agent.exe", "app": "MTConnect" }
],
"services": [
{ "name": "MTConnect UDC Agent", "app": "MTConnect" }
]
},
"launchpaths": {
"DncMain.exe": "C:\\Program Files (x86)\\Dnc\\bin\\DncMain.exe",
"UDC.exe": "C:\\Program Files\\UDC\\UDC.exe",
"agent.exe": "C:\\ProgramData\\udc\\mtconnect_udc\\agent\\run.bat"
}
}

View File

@@ -0,0 +1,355 @@
# install-shopdbagent.ps1
# ShopDB Endpoint Agent installer for the test endpoint. Run from an ELEVATED
# (admin) PowerShell on the endpoint. Provisions the registry secrets, copies
# the agent files to Program Files, runs a one-shot smoke test, then installs
# and starts the Windows service.
#
# Secrets are provisioned to HKLM\SOFTWARE\GE\ShopDB. On locked-down hosts
# (CyberArk/EPM) the agent may be blocked from READING that hive, so the same
# values are also stamped into the installed appsettings.json as a fallback.
#
# The agent and tray are published as single-file self-contained executables,
# so copying the two .exe files plus appsettings.json is the complete payload.
[CmdletBinding()]
param(
# ShopDB server root URL for this instance. REQUIRED - there is no default,
# because a default here means one site's hostname shipped to every site
# (ADR-015) and, worse, a working install pointed at somebody else's server.
#
# https is required. The collector key travels in the X-API-Key header and
# every telemetry payload follows it, so plain http puts both on the wire in
# clear. Pass -AllowInsecureHttp only for a local test instance.
[Parameter(Mandatory = $true)]
[string] $BaseUrl,
# Endpoints-only collector key: this instance's .env COLLECTOR_API_KEY_ENDPOINTS.
# REQUIRED and never defaulted - a key in source is a key in git history, and
# this one is also the command channel's kill switch.
[Parameter(Mandatory = $true)]
[string] $CollectorKey,
# Permit an http:// BaseUrl. For a local test instance only.
[switch] $AllowInsecureHttp,
[string] $InstallDir = "C:\Program Files\ShopDB\EndpointAgent",
[string] $ServiceName = "ShopDbEndpointAgent",
# Interactive tray app that shows the system-tray icon and the on-demand
# "Send Status now" action. Separate process from the SYSTEM service (a
# service cannot draw a tray icon in a user session). Pass -NoTray to skip.
[string] $TrayExeName = "shopdb-endpoint-tray.exe",
[switch] $NoTray,
# Add a Microsoft Defender path exclusion for the install directory. The
# agent and tray are unsigned single-file self-contained executables, which
# Defender heuristics (and the tray's GDI/user32 icon P/Invoke) can flag as
# a false positive and quarantine, killing the tray before its icon appears.
# Pass -NoDefenderExclusion to skip (e.g. where policy manages exclusions
# centrally or the binaries are code-signed).
[switch] $NoDefenderExclusion
)
# Refuse cleartext unless it was asked for explicitly. The key is a bearer
# credential for the whole fleet's telemetry and command channel.
if ($BaseUrl -notmatch '^https://' -and -not $AllowInsecureHttp) {
throw "BaseUrl must be https:// (got '$BaseUrl'). Pass -AllowInsecureHttp for a local test instance."
}
$ErrorActionPreference = "Stop"
$sourceDir = Split-Path -Parent $MyInvocation.MyCommand.Path
function Assert-Admin {
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
$principal = New-Object Security.Principal.WindowsPrincipal($identity)
if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
throw "This script must be run from an elevated (Run as administrator) PowerShell."
}
}
Assert-Admin
if ([string]::IsNullOrWhiteSpace($BaseUrl)) {
throw "BaseUrl is not set. Pass -BaseUrl 'http://<host>:<port>' when running the script."
}
# Write HKLM values through the explicit 64-bit registry view. If this script
# runs under 32-bit PowerShell (some management tooling launches x86), the
# PSDrive HKLM: silently redirects writes into WOW6432Node. The 64-bit agent
# service and 64-bit Explorer (the logon Run key) then read the 64-bit view and
# see nothing, so config "goes missing" and the tray never autostarts. Pinning
# the view removes that dependency on the launching shell's bitness.
function Set-Hklm64Value {
param(
[Parameter(Mandatory)] [string] $SubKey,
[Parameter(Mandatory)] [string] $Name,
[Parameter(Mandatory)] [string] $Value
)
$base = [Microsoft.Win32.RegistryKey]::OpenBaseKey(
[Microsoft.Win32.RegistryHive]::LocalMachine,
[Microsoft.Win32.RegistryView]::Registry64)
try {
$key = $base.CreateSubKey($SubKey)
try {
$key.SetValue($Name, $Value, [Microsoft.Win32.RegistryValueKind]::String)
}
finally { $key.Dispose() }
}
finally { $base.Dispose() }
}
# Tear down any previously installed agent BEFORE touching files. The agent and
# tray are single-file self-contained exes; while the old service or tray is
# running it holds those .exe files open, so Copy-Item over them fails with a
# file-in-use error. Stopping the service, deleting it, and killing any lingering
# agent/tray processes up front makes the install a clean overwrite. Best-effort
# throughout: a missing service or process is the normal fresh-install case.
function Uninstall-PreviousAgent {
param(
[Parameter(Mandatory)] [string] $Service,
[Parameter(Mandatory)] [string] $TrayExe,
[Parameter(Mandatory)] [string] $TargetDir
)
$existing = Get-Service -Name $Service -ErrorAction SilentlyContinue
if ($existing) {
Write-Host " stopping and removing existing service $Service ..."
if ($existing.Status -ne "Stopped") {
try { Stop-Service -Name $Service -Force -ErrorAction Stop }
catch { Write-Warning " could not stop service: $($_.Exception.Message)" }
}
# sc.exe delete only marks the service for deletion; the SCM finishes the
# removal once every handle closes. Give it a moment so the subsequent
# sc.exe create does not hit a "marked for deletion" (1072) error.
sc.exe delete $Service | Out-Null
Start-Sleep -Seconds 2
}
# Kill any lingering worker or tray processes that would keep the exes
# locked. Match by the installed exe base names; the service host process is
# shopdbagent, the tray is the TrayExe base name.
$trayBase = [System.IO.Path]::GetFileNameWithoutExtension($TrayExe)
foreach ($name in @("shopdbagent", $trayBase)) {
$procs = Get-Process -Name $name -ErrorAction SilentlyContinue
foreach ($p in $procs) {
Write-Host " killing lingering process $($p.ProcessName) (pid $($p.Id)) ..."
try { Stop-Process -Id $p.Id -Force -ErrorAction Stop }
catch { Write-Warning " could not kill $($p.ProcessName): $($_.Exception.Message)" }
}
}
# Also kill anything still running out of the target install directory,
# regardless of exe name. A tray from an older build (different exe name) or
# one launched from a stale path would otherwise survive the name-based sweep
# above, keep its exe locked, and leave a duplicate icon once step 5 starts
# the fresh tray. Match on the executable path so we only touch our own dir.
$targetPrefix = $TargetDir.TrimEnd('\') + '\'
foreach ($p in (Get-Process -ErrorAction SilentlyContinue)) {
$path = $null
try { $path = $p.Path } catch { $path = $null }
if ($path -and $path.StartsWith($targetPrefix, [System.StringComparison]::OrdinalIgnoreCase)) {
Write-Host " killing process from install dir $($p.ProcessName) (pid $($p.Id)) ..."
try { Stop-Process -Id $p.Id -Force -ErrorAction Stop }
catch { Write-Warning " could not kill $($p.ProcessName): $($_.Exception.Message)" }
}
}
# Brief settle so file handles are released before the copy overwrites them.
Start-Sleep -Milliseconds 500
}
Write-Host "== ShopDB Endpoint Agent install ==" -ForegroundColor Cyan
Write-Host "BaseUrl : $BaseUrl"
Write-Host "InstallDir : $InstallDir"
Write-Host "ServiceName : $ServiceName"
# 0. Clear the Mark-of-the-Web on the SOURCE payload first. The operator
# typically extracts installer.zip into Downloads, so every file carries a
# Zone.Identifier ADS. SmartScreen reads that and blocks the unsigned exes with
# "Windows protected your PC" the first time they run - including the [3/5]
# smoke test below, which launches shopdbagent.exe out of $sourceDir before the
# installed copy exists. Unblocking the source folder up front keeps the smoke
# test (and any manual run from the extract folder) clean; the installed copies
# get unblocked again in step 2a after they land in Program Files. Best-effort:
# a file with no MotW is a no-op.
Write-Host "`n[0a] Clearing Mark-of-the-Web on the extracted installer files ..."
Get-ChildItem -Path $sourceDir -File | ForEach-Object {
try { Unblock-File -Path $_.FullName -ErrorAction Stop }
catch { Write-Warning " could not unblock $($_.Name): $($_.Exception.Message)" }
}
# 0. Uninstall any previous agent first, so the file copy is a clean overwrite
# and does not fail on exes locked by a running service or tray.
Write-Host "`n[0/5] Removing any previously installed agent ..."
Uninstall-PreviousAgent -Service $ServiceName -TrayExe $TrayExeName -TargetDir $InstallDir
# 1. Provision registry secrets (64-bit view, see Set-Hklm64Value).
$regPath = "HKLM:\SOFTWARE\GE\ShopDB"
Write-Host "`n[1/5] Provisioning registry $regPath (Registry64) ..."
Set-Hklm64Value -SubKey "SOFTWARE\GE\ShopDB" -Name "BaseUrl" -Value $BaseUrl
Set-Hklm64Value -SubKey "SOFTWARE\GE\ShopDB" -Name "CollectorKey" -Value $CollectorKey
# 2. Copy agent files to the install directory. The agent and tray are
# single-file self-contained executables, so the .exe plus appsettings.json is
# the whole payload; there are no side-by-side runtime DLLs to copy.
Write-Host "[2/5] Copying agent files to $InstallDir ..."
New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null
$agentSource = Join-Path $sourceDir "shopdbagent.exe"
if (-not (Test-Path $agentSource)) {
throw "shopdbagent.exe not found in $sourceDir. Unzip the full installer package and run from that folder."
}
Copy-Item -Path $agentSource -Destination $InstallDir -Force
$appsettings = Join-Path $sourceDir "appsettings.json"
$installedSettings = Join-Path $InstallDir "appsettings.json"
if (Test-Path $appsettings) {
Copy-Item -Path $appsettings -Destination $InstallDir -Force
}
# Copy the interactive tray app next to the agent so its same-dir resolution
# finds shopdbagent.exe for the "Send Status now" action. Optional: absent on a
# service-only (headless) build.
$trayInstalled = $null
if (-not $NoTray) {
$traySource = Join-Path $sourceDir $TrayExeName
if (Test-Path $traySource) {
Copy-Item -Path $traySource -Destination $InstallDir -Force
$trayInstalled = Join-Path $InstallDir $TrayExeName
Write-Host " copied tray app $TrayExeName"
}
else {
Write-Warning " tray app $TrayExeName not found in $sourceDir; skipping tray (service still installs)"
}
}
# Fallback for locked-down endpoints: some hosts (CyberArk/EPM) gate the HKLM
# hive so the agent cannot READ the values the registry step just wrote. Stamp
# BaseUrl/CollectorKey into the installed appsettings.json too. The agent reads
# appsettings first, then lets the registry override it, so this is a safe
# no-op where the registry read works and the rescue where it does not.
if (Test-Path $installedSettings) {
try {
$json = Get-Content $installedSettings -Raw | ConvertFrom-Json
$json.baseurl = $BaseUrl
$json.apikey = $CollectorKey
$json | ConvertTo-Json -Depth 10 | Set-Content -Path $installedSettings -Encoding UTF8
Write-Host " stamped BaseUrl/CollectorKey into appsettings.json (registry-read fallback)"
}
catch {
Write-Warning " could not stamp appsettings.json: $($_.Exception.Message)"
}
}
$exePath = Join-Path $InstallDir "shopdbagent.exe"
# 2a. Strip the Mark-of-the-Web from the copied files. Windows tags files
# extracted from a downloaded zip with a Zone.Identifier alternate data stream;
# SmartScreen reads that and blocks the unsigned exe with "Windows protected
# your PC" the first time it runs, and the operator sees only a "Don't run"
# button. Unblock-File removes the ADS so the smoke test and the tray launch
# start clean. Best-effort: a file with no MotW is a no-op.
Write-Host "[2a/5] Clearing Mark-of-the-Web on installed files ..."
Get-ChildItem -Path $InstallDir -File | ForEach-Object {
try { Unblock-File -Path $_.FullName -ErrorAction Stop }
catch { Write-Warning " could not unblock $($_.Name): $($_.Exception.Message)" }
}
# 2b. Defender path exclusion for the install dir. Unsigned single-file
# self-contained binaries (and the tray's icon P/Invoke) can trip Defender
# heuristics and get quarantined, which is why the tray dies before showing an
# icon. Excluding the fixed install directory is the least-privilege fix short
# of code-signing. Best-effort: Set-MpPreference is absent on servers without
# Defender and can be blocked by tamper protection; a failure is not fatal.
if (-not $NoDefenderExclusion) {
Write-Host "[2b/5] Adding Defender path exclusion for $InstallDir ..."
if (Get-Command Add-MpPreference -ErrorAction SilentlyContinue) {
try {
Add-MpPreference -ExclusionPath $InstallDir -ErrorAction Stop
Write-Host " excluded $InstallDir from Defender scanning."
}
catch {
Write-Warning " could not add Defender exclusion (tamper protection or policy?): $($_.Exception.Message)"
Write-Warning " if the tray is quarantined, code-sign the binaries or add the exclusion via policy."
}
}
else {
Write-Host " Defender cmdlets not present; skipping exclusion."
}
}
# 3. One-shot smoke test (exact production collection path, then exits).
Write-Host "[3/5] Running one-shot smoke test (--once) ..."
$proc = Start-Process -FilePath $exePath -ArgumentList "--once" -WorkingDirectory $InstallDir -Wait -PassThru -NoNewWindow
Write-Host " smoke test exit code: $($proc.ExitCode)"
$logDir = Join-Path $env:ProgramData "ShopDB\EndpointAgent\logs"
$logFile = Join-Path $logDir ("agent-{0}.log" -f (Get-Date -Format "yyyyMMdd"))
if (Test-Path $logFile) {
Write-Host " --- last log lines ---" -ForegroundColor DarkGray
Get-Content $logFile -Tail 20 | ForEach-Object { Write-Host " $_" -ForegroundColor DarkGray }
}
if ($proc.ExitCode -ne 0) {
throw "Smoke test failed (exit $($proc.ExitCode)). Check $logFile before installing the service."
}
# 4. Install the Windows service. Any previous instance was already stopped,
# deleted, and its processes killed in step 0, so this is a clean create. Guard
# against the rare case where the SCM has not finished a pending deletion yet.
Write-Host "[4/5] Installing service $ServiceName ..."
$existing = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue
if ($existing) {
Write-Host " service still present (pending deletion); removing again ..."
if ($existing.Status -ne "Stopped") { Stop-Service -Name $ServiceName -Force }
sc.exe delete $ServiceName | Out-Null
Start-Sleep -Seconds 2
}
$binPath = '"' + $exePath + '"'
sc.exe create $ServiceName binPath= $binPath start= auto DisplayName= "ShopDB Endpoint Agent" | Out-Null
sc.exe description $ServiceName "Collects endpoint telemetry for ShopDB and runs allowlisted WebUI commands." | Out-Null
sc.exe failure $ServiceName reset= 86400 actions= restart/5000/restart/5000/restart/5000 | Out-Null
Start-Service -Name $ServiceName
Write-Host " service started."
# 5. Register the interactive tray app to launch at user logon (per-machine).
Write-Host "[5/5] Registering tray app for logon ..."
if ($trayInstalled -and (Test-Path $trayInstalled)) {
# Register in the 64-bit Run key so 64-bit Explorer fires it at logon. A
# 32-bit write would land in WOW6432Node\...\Run, which Explorer ignores.
Set-Hklm64Value -SubKey "SOFTWARE\Microsoft\Windows\CurrentVersion\Run" `
-Name "ShopDbEndpointTray" -Value ('"' + $trayInstalled + '"')
Write-Host " tray app registered to start at logon (Registry64): $trayInstalled"
# Start it now so the icon appears without waiting for the next logon. The
# Run key only fires at logon, so a fresh install would otherwise show no
# tray until the operator signs out and back in.
#
# The installer runs elevated, so a direct Start-Process would launch the
# tray ELEVATED too. That is wrong for a tray app (it should run at the
# user's medium integrity) and, on EPM-managed hosts (CyberArk), elevating an
# unsigned exe triggers a broker prompt that comes back as Win32 1223
# "The operation was canceled by the user" - which is exactly what we hit.
# Launching through explorer.exe hands the exe to the already-running user
# shell, which spawns it de-elevated in the interactive session, sidestepping
# the elevation broker entirely.
$interactiveUser = $env:USERNAME
if ($interactiveUser -and $interactiveUser -ne "SYSTEM") {
try {
# explorer.exe <path> re-parents the launch under the user shell at
# medium integrity; it returns immediately regardless of the tray's
# own lifetime, so there is nothing to wait on here.
Start-Process -FilePath "explorer.exe" -ArgumentList "`"$trayInstalled`"" | Out-Null
Write-Host " tray app started for the current session (de-elevated via explorer)."
}
catch {
Write-Warning " could not start tray now: $($_.Exception.Message); it will start at next logon."
}
}
else {
Write-Host " no interactive user session; tray will start at next logon."
}
}
else {
Write-Host " tray skipped (headless install)."
}
Write-Host "`nInstall complete." -ForegroundColor Green
Write-Host "Service '$ServiceName' is running and set to auto-start."
Write-Host "Logs: $logDir"