Gate endpoint UI refresh on fresh telemetry and land endpoint agent
A command reaching 'succeeded' only proves the agent ran the action, not that it posted a new telemetry snapshot. Reloading panels on command success alone re-rendered stale service/process rows, so a stopped process could still show running right after Refresh Status. Add a lightweight telemetrymeta probe exposing lastreportedat and gate both the header Refresh Status action and inline row Start/Stop on that stamp advancing past a pre-action baseline. Polling is bounded so an offline or idle agent never hangs the button, and messaging distinguishes a confirmed refresh from a queued one. Bring the .NET endpoint agent source (service, tray, installer) into the repo so Cameron can merge and test, with an agent/.gitignore keeping bin/obj/publish out. Includes the DncMain launch-path fix, tray single-file host env scrub, and best-effort file logger.
This commit is contained in:
7
agent/.gitignore
vendored
Normal file
7
agent/.gitignore
vendored
Normal 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
166
agent/README.md
Normal 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.
|
||||
109
agent/ShopDbEndpointAgent.Core/AgentConfig.cs
Normal file
109
agent/ShopDbEndpointAgent.Core/AgentConfig.cs
Normal 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; } = "";
|
||||
}
|
||||
110
agent/ShopDbEndpointAgent.Core/AgentConfigProvider.cs
Normal file
110
agent/ShopDbEndpointAgent.Core/AgentConfigProvider.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
91
agent/ShopDbEndpointAgent.Core/AgentFaultMonitor.cs
Normal file
91
agent/ShopDbEndpointAgent.Core/AgentFaultMonitor.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
26
agent/ShopDbEndpointAgent.Core/CollectSignal.cs
Normal file
26
agent/ShopDbEndpointAgent.Core/CollectSignal.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
118
agent/ShopDbEndpointAgent.Core/CollectorClient.cs
Normal file
118
agent/ShopDbEndpointAgent.Core/CollectorClient.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
83
agent/ShopDbEndpointAgent.Core/CommandClient.cs
Normal file
83
agent/ShopDbEndpointAgent.Core/CommandClient.cs
Normal 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();
|
||||
}
|
||||
581
agent/ShopDbEndpointAgent.Core/CommandExecutor.cs
Normal file
581
agent/ShopDbEndpointAgent.Core/CommandExecutor.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
143
agent/ShopDbEndpointAgent.Core/CommandWorker.cs
Normal file
143
agent/ShopDbEndpointAgent.Core/CommandWorker.cs
Normal 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.
|
||||
}
|
||||
}
|
||||
}
|
||||
92
agent/ShopDbEndpointAgent.Core/Contracts.cs
Normal file
92
agent/ShopDbEndpointAgent.Core/Contracts.cs
Normal 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; }
|
||||
}
|
||||
44
agent/ShopDbEndpointAgent.Core/CuratedEventCatalog.cs
Normal file
44
agent/ShopDbEndpointAgent.Core/CuratedEventCatalog.cs
Normal 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();
|
||||
}
|
||||
489
agent/ShopDbEndpointAgent.Core/EndpointCollector.cs
Normal file
489
agent/ShopDbEndpointAgent.Core/EndpointCollector.cs
Normal 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];
|
||||
}
|
||||
}
|
||||
164
agent/ShopDbEndpointAgent.Core/FileLoggerProvider.cs
Normal file
164
agent/ShopDbEndpointAgent.Core/FileLoggerProvider.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
24
agent/ShopDbEndpointAgent.Core/ICollector.cs
Normal file
24
agent/ShopDbEndpointAgent.Core/ICollector.cs
Normal 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);
|
||||
}
|
||||
200
agent/ShopDbEndpointAgent.Core/InteractiveProcessLauncher.cs
Normal file
200
agent/ShopDbEndpointAgent.Core/InteractiveProcessLauncher.cs
Normal 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);
|
||||
}
|
||||
114
agent/ShopDbEndpointAgent.Core/PowerShellUtilityRunner.cs
Normal file
114
agent/ShopDbEndpointAgent.Core/PowerShellUtilityRunner.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
129
agent/ShopDbEndpointAgent.Core/TelemetryWorker.cs
Normal file
129
agent/ShopDbEndpointAgent.Core/TelemetryWorker.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
17
agent/ShopDbEndpointAgent.Tray/Program.cs
Normal file
17
agent/ShopDbEndpointAgent.Tray/Program.cs
Normal 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());
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
118
agent/ShopDbEndpointAgent.Tray/TicketForm.cs
Normal file
118
agent/ShopDbEndpointAgent.Tray/TicketForm.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
202
agent/ShopDbEndpointAgent.Tray/TrayContext.cs
Normal file
202
agent/ShopDbEndpointAgent.Tray/TrayContext.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
81
agent/ShopDbEndpointAgent.Tray/TrayIcon.cs
Normal file
81
agent/ShopDbEndpointAgent.Tray/TrayIcon.cs
Normal 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);
|
||||
}
|
||||
5
agent/ShopDbEndpointAgent.slnx
Normal file
5
agent/ShopDbEndpointAgent.slnx
Normal 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>
|
||||
164
agent/ShopDbEndpointAgent/Program.cs
Normal file
164
agent/ShopDbEndpointAgent/Program.cs
Normal 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);
|
||||
}
|
||||
40
agent/ShopDbEndpointAgent/ShopDbEndpointAgent.csproj
Normal file
40
agent/ShopDbEndpointAgent/ShopDbEndpointAgent.csproj
Normal 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>
|
||||
26
agent/ShopDbEndpointAgent/appsettings.json
Normal file
26
agent/ShopDbEndpointAgent/appsettings.json
Normal 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"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
337
agent/installer/install-shopdbagent.ps1
Normal file
337
agent/installer/install-shopdbagent.ps1
Normal file
@@ -0,0 +1,337 @@
|
||||
# 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. Defaulted to the test instance;
|
||||
# pass -BaseUrl "http://<host>:<port>" to override.
|
||||
[string] $BaseUrl = "http://H1HWNL84.device.geaerospace.net:5001",
|
||||
|
||||
# Endpoints-only collector key (this instance's .env
|
||||
# COLLECTOR_API_KEY_ENDPOINTS).
|
||||
[string] $CollectorKey = "l755ddRw1iSeOcSTh9N9wXM4gGT7N6WIVN9crOY6uZU",
|
||||
|
||||
[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
|
||||
)
|
||||
|
||||
$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"
|
||||
Reference in New Issue
Block a user