diff --git a/.github/agent-workflow.md b/.github/agent-workflow.md new file mode 100644 index 00000000..59b87aaf --- /dev/null +++ b/.github/agent-workflow.md @@ -0,0 +1,86 @@ +# Agent workflow rules + +Companion to `copilot-instructions.md` (which owns naming/style/architecture). +These cut wasted turns. All are lessons from real sessions. + +## Environment - know these, do not rediscover + +- Shell is **PowerShell**: use `;` not `&&`, `2>$null` not `2>/dev/null`. +- API container `shopdb-flask-wilm-api-1` (Flask, port `5001`). +- DB container `shopdb-flask-wilm-db-1` (MySQL 8, root pw `root_password_123`). +- SPA is **baked into the image** (not bind-mounted). Plugin code is + **bind-mounted read-only**. +- `docs/` is stripped to a public wiki mirror - keep working docs out of it. +- `requirements.txt` is hash-checked - never hand-edit. + +## Verify, don't react + +- The terminal "Command failed" banner fires on any non-zero exit or stderr + output and is **usually a false negative** (`curl --fail` on a 400, + pytest/npm/git writing to stderr). Judge success from real output. +- Read container health via + `docker inspect --format '{{.State.Health.Status}}'` plus a probe **without** + `--fail` - never eyeball the exit code. + +## Container runs the image, not your edits + +Before running anything in the container that must reflect a change: + +1. Frontend: `npm run build`, then + `docker cp frontend/dist/. shopdb-flask-wilm-api-1:/app/frontend/dist`. +2. Backend/scripts/templates: `docker cp` the files (or rebuild) first. + +If a result contradicts a change you just made, suspect stale container code +before re-deriving the fix. + +## Testing + +- Confirm the working directory once, up front. +- Run the smallest test that proves the point; find the minimal polluter for + isolation bugs. Run the full suite only at the end. +- Clear stale local state before plugin/migration tests: + `if (Test-Path instance/plugins.json) { Remove-Item instance/plugins.json -Force }`. +- Naming-gate hits inside local venvs (`.ci-venv/`, `env/`) are not repo code - + do not "fix" vendored libraries. +- Final gates, exactly as CI: `python -m pytest tests/ -q`; + `cd frontend; npx vitest run; npm run build; cd ..`; + `bash scripts/check-naming-and-style.sh`. + +## Migrations and imports + +- Every migration: idempotent guard + real downgrade; must run clean on strict + MySQL 8. If you rename indexes in `upgrade()`, update `downgrade()` to match. +- Avoid duplicate indexes: `index=True` plus an explicit `db.Index` on the same + column creates two. +- Importers/seeders match by natural key and must be idempotent - prove it by + running twice (expect `updated`, not `created`). Watch all-or-nothing writes. + +## Don't offer rollbacks you don't need + +State current state plainly; surface a rollback path only for a real regression. + +## Wilmington (wilm) local-run known failures - do not re-investigate + +Running the full `python -m pytest tests/ -q` on a Windows dev box (not CI) +produces a stable set of environmental failures that are NOT code regressions. +Treat these as expected locally; judge your change against CI/Linux instead: + +- `test_core/test_security_headers.py::test_a_served_upload_is_sandboxed` - + `PermissionError [WinError 32]` (served upload file still locked by another + process). Windows file-lock timing, not a headers bug. +- `test_plugin_contract.py::test_import_scan_covers_symlinked_plugins` - + `OSError [WinError 1314]` (symlink privilege not held). Needs admin/Developer + Mode to create symlinks; irrelevant to plugin logic. +- `test_plugin_importguard.py::test_verified_source_signed_ok_tampered_raises` - + CRLF vs LF (`b'# plugin\r\n' == b'# plugin\n'`). Git autocrlf checkout, not a + signing bug. +- `test_docs_publishable.py` and `test_docs_versions.py` - the local `docs/` + tree is not the stripped public wiki mirror, so doc-count / PROJECT-MAP + assertions fail. Expected off-CI. +- `test_core/test_public_read_surface.py::test_every_get_route_is_gated_or_named_public` + - pre-existing ungated-GET inventory drift; only act on it if YOUR change + added a new GET route. + +If a run shows ONLY these, the suite is green for our purposes. Anything outside +this list is real - investigate it. + diff --git a/Dockerfile b/Dockerfile index 034e0492..df0156d7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -23,10 +23,21 @@ FROM node:20-slim AS frontendbuild WORKDIR /build -# Trust the site's corporate root CA so npm's TLS to the registry validates -# behind an inspecting proxy. Without it npm 10.x fails silently and leaves an -# incomplete node_modules. -COPY corp-root-ca.pem /usr/local/share/ca-certificates/corp-root-ca.crt +# Optionally trust the site's corporate root CA so npm's TLS to the registry +# validates behind an inspecting proxy. The .pem is gitignored and absent from a +# clean checkout, so the copy is made optional by pairing it with a file that +# always exists; the cert is installed only when actually provided. Without it +# npm 10.x behind an inspecting proxy fails silently and leaves an incomplete +# node_modules; a clean build with no proxy is unaffected. +COPY corp-root-ca.pem* frontend/package.json /tmp/corpca/ +# node:20-slim ships without the ca-certificates package (no update-ca-certificates +# and no /usr/local/share/ca-certificates), so install it before trusting the cert. +RUN if [ -f /tmp/corpca/corp-root-ca.pem ]; then \ + apt-get update && apt-get install -y --no-install-recommends ca-certificates && \ + rm -rf /var/lib/apt/lists/* && \ + cp /tmp/corpca/corp-root-ca.pem /usr/local/share/ca-certificates/corp-root-ca.crt && \ + update-ca-certificates; \ + fi ENV NODE_EXTRA_CA_CERTS=/usr/local/share/ca-certificates/corp-root-ca.crt # Copy only the manifests first so `npm ci` caches on dependency changes. diff --git a/agent/.gitignore b/agent/.gitignore new file mode 100644 index 00000000..aa65026a --- /dev/null +++ b/agent/.gitignore @@ -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 diff --git a/agent/README.md b/agent/README.md new file mode 100644 index 00000000..0c43efe5 --- /dev/null +++ b/agent/README.md @@ -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 '' +``` + +### 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. diff --git a/agent/ShopDbEndpointAgent.Core/AgentConfig.cs b/agent/ShopDbEndpointAgent.Core/AgentConfig.cs new file mode 100644 index 00000000..f8a8b9f9 --- /dev/null +++ b/agent/ShopDbEndpointAgent.Core/AgentConfig.cs @@ -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 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 DefaultLaunchPaths = + new Dictionary(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 ResolveLaunchPaths() + { + var resolved = new Dictionary(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(); + + [JsonPropertyName("services")] public MonitorTarget[] Services { get; set; } + = Array.Empty(); +} + +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; } = ""; +} diff --git a/agent/ShopDbEndpointAgent.Core/AgentConfigProvider.cs b/agent/ShopDbEndpointAgent.Core/AgentConfigProvider.cs new file mode 100644 index 00000000..ede80f88 --- /dev/null +++ b/agent/ShopDbEndpointAgent.Core/AgentConfigProvider.cs @@ -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(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); + } + } + } +} diff --git a/agent/ShopDbEndpointAgent.Core/AgentFaultMonitor.cs b/agent/ShopDbEndpointAgent.Core/AgentFaultMonitor.cs new file mode 100644 index 00000000..a46e98df --- /dev/null +++ b/agent/ShopDbEndpointAgent.Core/AgentFaultMonitor.cs @@ -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; + } + } +} diff --git a/agent/ShopDbEndpointAgent.Core/CollectSignal.cs b/agent/ShopDbEndpointAgent.Core/CollectSignal.cs new file mode 100644 index 00000000..4deea78b --- /dev/null +++ b/agent/ShopDbEndpointAgent.Core/CollectSignal.cs @@ -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 WaitAsync(TimeSpan delay, CancellationToken token) + { + return await gate.WaitAsync(delay, token); + } +} diff --git a/agent/ShopDbEndpointAgent.Core/CollectorClient.cs b/agent/ShopDbEndpointAgent.Core/CollectorClient.cs new file mode 100644 index 00000000..4c4d30d9 --- /dev/null +++ b/agent/ShopDbEndpointAgent.Core/CollectorClient.cs @@ -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 logger; + private readonly int maxAttempts; + private readonly Random jitter = new(); + + public CollectorClient(AgentConfig config, ILogger 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 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 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(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; + } +} diff --git a/agent/ShopDbEndpointAgent.Core/CommandClient.cs b/agent/ShopDbEndpointAgent.Core/CommandClient.cs new file mode 100644 index 00000000..296dc280 --- /dev/null +++ b/agent/ShopDbEndpointAgent.Core/CommandClient.cs @@ -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 logger; + + public CommandClient(AgentConfig config, ILogger 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 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( + 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(); +} diff --git a/agent/ShopDbEndpointAgent.Core/CommandExecutor.cs b/agent/ShopDbEndpointAgent.Core/CommandExecutor.cs new file mode 100644 index 00000000..c739bdc7 --- /dev/null +++ b/agent/ShopDbEndpointAgent.Core/CommandExecutor.cs @@ -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 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 launchPaths; + + public CommandExecutor(ILogger logger, + AgentConfig config, TimeSpan? startTimeout = null) + { + this.logger = logger; + this.startTimeout = startTimeout ?? TimeSpan.FromSeconds(30); + this.launchPaths = (config ?? new AgentConfig()).ResolveLaunchPaths(); + } + + public async Task 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 ServiceDetail(ServiceController controller) + { + controller.Refresh(); + return new Dictionary + { + ["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(); + 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 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 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 FindProcessesByImage(string image) + { + var bare = image; + if (bare.EndsWith(".exe", StringComparison.OrdinalIgnoreCase)) + bare = bare[..^4]; + + var found = new List(); + 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 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; + } + } +} diff --git a/agent/ShopDbEndpointAgent.Core/CommandWorker.cs b/agent/ShopDbEndpointAgent.Core/CommandWorker.cs new file mode 100644 index 00000000..9389d805 --- /dev/null +++ b/agent/ShopDbEndpointAgent.Core/CommandWorker.cs @@ -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 logger; + private readonly CollectSignal? collectSignal; + private readonly string hostname; + + public CommandWorker(CommandClient client, CommandExecutor executor, + ILogger 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. + } + } +} diff --git a/agent/ShopDbEndpointAgent.Core/Contracts.cs b/agent/ShopDbEndpointAgent.Core/Contracts.cs new file mode 100644 index 00000000..cb504a36 --- /dev/null +++ b/agent/ShopDbEndpointAgent.Core/Contracts.cs @@ -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? 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? Detail { get; set; } +} diff --git a/agent/ShopDbEndpointAgent.Core/CuratedEventCatalog.cs b/agent/ShopDbEndpointAgent.Core/CuratedEventCatalog.cs new file mode 100644 index 00000000..57b44d7f --- /dev/null +++ b/agent/ShopDbEndpointAgent.Core/CuratedEventCatalog.cs @@ -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 Entries = new List + { + 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 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 IdsForChannel(string channel) + => Entries.Where(entry => + string.Equals(entry.Channel, channel, StringComparison.OrdinalIgnoreCase)) + .Select(entry => entry.EventId) + .ToHashSet(); +} diff --git a/agent/ShopDbEndpointAgent.Core/EndpointCollector.cs b/agent/ShopDbEndpointAgent.Core/EndpointCollector.cs new file mode 100644 index 00000000..4154db63 --- /dev/null +++ b/agent/ShopDbEndpointAgent.Core/EndpointCollector.cs @@ -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 logger; + private readonly AgentConfig config; + private readonly IAgentUtilityRunner? utilities; + + public EndpointCollector(ILogger 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(); + result.AddRange(GetMonitoredProcesses()); + result.AddRange(GetMonitoredServices()); + return result.ToArray(); + } + + private IEnumerable GetMonitoredProcesses() + { + var targets = config.Monitors?.Processes ?? Array.Empty(); + if (targets.Length == 0) + return Array.Empty(); + + var running = new Dictionary(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(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 GetMonitoredServices() + { + var targets = config.Monitors?.Services ?? Array.Empty(); + if (targets.Length == 0) + return Array.Empty(); + + var states = new Dictionary(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(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(); + 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(); + 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]; + } +} diff --git a/agent/ShopDbEndpointAgent.Core/FileLoggerProvider.cs b/agent/ShopDbEndpointAgent.Core/FileLoggerProvider.cs new file mode 100644 index 00000000..fa374105 --- /dev/null +++ b/agent/ShopDbEndpointAgent.Core/FileLoggerProvider.cs @@ -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 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 state) where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => provider.IsEnabled(logLevel); + + public void Log(LogLevel logLevel, EventId eventId, TState state, + Exception? exception, Func formatter) + { + if (!IsEnabled(logLevel)) + return; + provider.Write(category, logLevel, formatter(state, exception), exception); + } + } +} diff --git a/agent/ShopDbEndpointAgent.Core/ICollector.cs b/agent/ShopDbEndpointAgent.Core/ICollector.cs new file mode 100644 index 00000000..7be48930 --- /dev/null +++ b/agent/ShopDbEndpointAgent.Core/ICollector.cs @@ -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 RunAll(CancellationToken token); +} diff --git a/agent/ShopDbEndpointAgent.Core/InteractiveProcessLauncher.cs b/agent/ShopDbEndpointAgent.Core/InteractiveProcessLauncher.cs new file mode 100644 index 00000000..3b028bcb --- /dev/null +++ b/agent/ShopDbEndpointAgent.Core/InteractiveProcessLauncher.cs @@ -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); +} diff --git a/agent/ShopDbEndpointAgent.Core/PowerShellUtilityRunner.cs b/agent/ShopDbEndpointAgent.Core/PowerShellUtilityRunner.cs new file mode 100644 index 00000000..e7c624b3 --- /dev/null +++ b/agent/ShopDbEndpointAgent.Core/PowerShellUtilityRunner.cs @@ -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 logger; + private readonly TimeSpan perScriptTimeout; + + public PowerShellUtilityRunner(string utilitiesPath, + ILogger logger, TimeSpan? perScriptTimeout = null) + { + this.utilitiesPath = utilitiesPath; + this.logger = logger; + this.perScriptTimeout = perScriptTimeout ?? TimeSpan.FromSeconds(30); + } + + public IReadOnlyDictionary RunAll(CancellationToken token) + { + var results = new Dictionary(); + 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(stdout); + } + catch (JsonException) + { + logger.LogWarning("Agent utility {Script} produced non-JSON output", + scriptPath); + return null; + } + } +} diff --git a/agent/ShopDbEndpointAgent.Core/ShopDbEndpointAgent.Core.csproj b/agent/ShopDbEndpointAgent.Core/ShopDbEndpointAgent.Core.csproj new file mode 100644 index 00000000..013080b0 --- /dev/null +++ b/agent/ShopDbEndpointAgent.Core/ShopDbEndpointAgent.Core.csproj @@ -0,0 +1,22 @@ + + + + net8.0-windows + enable + enable + ShopDbEndpointAgent.Core + ShopDbEndpointAgent.Core + true + + + + + + + + + + + + + diff --git a/agent/ShopDbEndpointAgent.Core/TelemetryWorker.cs b/agent/ShopDbEndpointAgent.Core/TelemetryWorker.cs new file mode 100644 index 00000000..1fcd3412 --- /dev/null +++ b/agent/ShopDbEndpointAgent.Core/TelemetryWorker.cs @@ -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 logger; + private readonly CollectSignal? collectSignal; + private readonly Random jitter = new(); + + public TelemetryWorker(ICollector collector, CollectorClient client, + AgentConfig config, string agentVersion, ILogger 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 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()) + 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); + } +} diff --git a/agent/ShopDbEndpointAgent.Tray/Program.cs b/agent/ShopDbEndpointAgent.Tray/Program.cs new file mode 100644 index 00000000..80e0f0cd --- /dev/null +++ b/agent/ShopDbEndpointAgent.Tray/Program.cs @@ -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()); + } +} diff --git a/agent/ShopDbEndpointAgent.Tray/ShopDbEndpointAgent.Tray.csproj b/agent/ShopDbEndpointAgent.Tray/ShopDbEndpointAgent.Tray.csproj new file mode 100644 index 00000000..365c1e5f --- /dev/null +++ b/agent/ShopDbEndpointAgent.Tray/ShopDbEndpointAgent.Tray.csproj @@ -0,0 +1,24 @@ + + + + WinExe + net8.0-windows + enable + enable + true + ShopDbEndpointAgent.Tray + shopdb-endpoint-tray + true + win-x64 + true + true + true + true + none + + + + + + + diff --git a/agent/ShopDbEndpointAgent.Tray/TicketForm.cs b/agent/ShopDbEndpointAgent.Tray/TicketForm.cs new file mode 100644 index 00000000..392418e2 --- /dev/null +++ b/agent/ShopDbEndpointAgent.Tray/TicketForm.cs @@ -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); + } +} diff --git a/agent/ShopDbEndpointAgent.Tray/TrayContext.cs b/agent/ShopDbEndpointAgent.Tray/TrayContext.cs new file mode 100644 index 00000000..93827706 --- /dev/null +++ b/agent/ShopDbEndpointAgent.Tray/TrayContext.cs @@ -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 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(); + 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); + } +} diff --git a/agent/ShopDbEndpointAgent.Tray/TrayIcon.cs b/agent/ShopDbEndpointAgent.Tray/TrayIcon.cs new file mode 100644 index 00000000..dbc0957b --- /dev/null +++ b/agent/ShopDbEndpointAgent.Tray/TrayIcon.cs @@ -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); +} diff --git a/agent/ShopDbEndpointAgent.slnx b/agent/ShopDbEndpointAgent.slnx new file mode 100644 index 00000000..1383261a --- /dev/null +++ b/agent/ShopDbEndpointAgent.slnx @@ -0,0 +1,5 @@ + + + + + diff --git a/agent/ShopDbEndpointAgent/Program.cs b/agent/ShopDbEndpointAgent/Program.cs new file mode 100644 index 00000000..c97a8839 --- /dev/null +++ b/agent/ShopDbEndpointAgent/Program.cs @@ -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()); + builder.Services.AddHostedService(sp => sp.GetRequiredService()); + + await builder.Build().RunAsync(); +} + +static async Task RunOnceAsync(AgentConfig config, string agentVersion, + string logDirectory, AgentFaultMonitor faultMonitor) +{ + using var services = BuildOnceServices(config, agentVersion, logDirectory); + var worker = services.GetRequiredService(); + var logger = services.GetRequiredService>(); + 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(); + + if (!string.IsNullOrWhiteSpace(config.UtilitiesPath) && + Directory.Exists(config.UtilitiesPath)) + { + services.AddSingleton(sp => + new PowerShellUtilityRunner(config.UtilitiesPath, + sp.GetRequiredService>())); + } + + services.AddSingleton(sp => new EndpointCollector( + sp.GetRequiredService>(), + config, + sp.GetService())); + + services.AddSingleton(sp => new CollectorClient( + config, sp.GetRequiredService>())); + + services.AddSingleton(sp => new TelemetryWorker( + sp.GetRequiredService(), + sp.GetRequiredService(), + config, + agentVersion, + sp.GetRequiredService>(), + sp.GetService(), + sp.GetRequiredService())); + + // 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>())); + + services.AddSingleton(sp => new CommandExecutor( + sp.GetRequiredService>(), + config)); + + services.AddSingleton(sp => new CommandWorker( + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService>(), + sp.GetRequiredService())); +} + +static void ConfigureLogging(ILoggingBuilder logging, string logDirectory) +{ + logging.ClearProviders(); + logging.AddProvider(new FileLoggerProvider(logDirectory)); + logging.SetMinimumLevel(LogLevel.Information); +} diff --git a/agent/ShopDbEndpointAgent/ShopDbEndpointAgent.csproj b/agent/ShopDbEndpointAgent/ShopDbEndpointAgent.csproj new file mode 100644 index 00000000..3612a79a --- /dev/null +++ b/agent/ShopDbEndpointAgent/ShopDbEndpointAgent.csproj @@ -0,0 +1,40 @@ + + + + Exe + net8.0-windows + enable + enable + ShopDbEndpointAgent + shopdbagent + + 1.3.0 + true + win-x64 + true + true + true + true + none + + + + + + + + + + + + + + + PreserveNewest + + + + diff --git a/agent/ShopDbEndpointAgent/appsettings.json b/agent/ShopDbEndpointAgent/appsettings.json new file mode 100644 index 00000000..162913e3 --- /dev/null +++ b/agent/ShopDbEndpointAgent/appsettings.json @@ -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" + } +} + + diff --git a/agent/installer/install-shopdbagent.ps1 b/agent/installer/install-shopdbagent.ps1 new file mode 100644 index 00000000..4fcbea71 --- /dev/null +++ b/agent/installer/install-shopdbagent.ps1 @@ -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://:" 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://:' 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 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" diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index 31daa2b3..715161b3 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -153,6 +153,17 @@ export const endpointsApi = { }, get(assetId) { return api.get(`/endpoints/${assetId}`) + }, + enqueueCommand(assetId, action, target = null) { + const body = { action } + if (target) body.target = target + return api.post(`/endpoints/${assetId}/commands`, body) + }, + listCommands(assetId) { + return api.get(`/endpoints/${assetId}/commands`) + }, + telemetryMeta(assetId) { + return api.get(`/endpoints/asset/${assetId}/telemetrymeta`) } } diff --git a/frontend/src/components/PluginAssetPanels.vue b/frontend/src/components/PluginAssetPanels.vue index 4a4d1725..f8e4d3ea 100644 --- a/frontend/src/components/PluginAssetPanels.vue +++ b/frontend/src/components/PluginAssetPanels.vue @@ -150,12 +150,18 @@ const tabs = ref({}) function activeTab(panel) { return tabs.value[panel.id] || 0 } function setTab(panel, index) { tabs.value = { ...tabs.value, [panel.id]: index } } -// Collapsed state per panel id (default expanded), kept here for the same -// reason as tabs: panels are replaced wholesale on refetch. +// Collapsed state per panel id, kept here for the same reason as tabs: panels +// are replaced wholesale on refetch. A panel may declare collapsedDefault to +// start collapsed (for example a low-priority events card); once the user +// toggles it, their explicit choice (stored by id) wins. const collapsed = ref({}) -function isCollapsed(panel) { return !!collapsed.value[panel.id] } +function isCollapsed(panel) { + const state = collapsed.value[panel.id] + if (state === undefined) return !!panel.collapsedDefault + return !!state +} function toggleCollapse(panel) { - collapsed.value = { ...collapsed.value, [panel.id]: !collapsed.value[panel.id] } + collapsed.value = { ...collapsed.value, [panel.id]: !isCollapsed(panel) } } // Per-panel action state: busy guards a double-click while an enqueue POST is @@ -179,7 +185,26 @@ async function runRowAction(panel, item) { try { const body = { action: info.toggle } if (info.target) body.target = info.target - await api.post(toApiPath(info.endpoint, props.assetid), body) + // Baseline telemetry freshness before queuing so we can tell a genuinely + // new snapshot from the stale cached one after the command settles. + const baseline = await currentTelemetryStamp() + const response = await api.post(toApiPath(info.endpoint, props.assetid), body) + const commandId = response?.data?.data?.endpointcommandid + // The POST only queues the command (201). The agent runs it asynchronously, + // so poll for the terminal result and surface the agent's own message when + // it fails (e.g. a bad launch path), rather than reporting a false success. + const settled = await waitForCommandResult(commandId) + if (settled && settled.status === 'failed') { + actionError.value = { + ...actionError.value, + [panel.id]: settled.resultmessage || 'The endpoint agent could not run the command.', + } + } else if (settled && settled.status === 'succeeded') { + // The command ran, but the fresh run state only lands when the agent's + // follow-up telemetry post arrives. Wait for lastreportedat to advance so + // the reloaded row reflects reality instead of the pre-command snapshot. + await waitForFreshTelemetry(baseline) + } await load() } catch (err) { const message = err?.response?.data?.message || 'Command failed to queue.' @@ -189,6 +214,53 @@ async function runRowAction(panel, item) { } } +// Poll the endpoint's recent commands until the given command reaches a terminal +// state (succeeded/failed) or the attempt budget runs out. Returns the command +// row or null. Uses the same commands endpoint the operator view reads. +async function waitForCommandResult(commandId, { attempts = 15, delayMs = 1000 } = {}) { + if (!commandId || !props.assetid) return null + for (let i = 0; i < attempts; i += 1) { + await new Promise((resolve) => setTimeout(resolve, delayMs)) + try { + const response = await api.get(`/endpoints/${props.assetid}/commands`) + const command = (response?.data?.data || []).find( + (row) => row.endpointcommandid === commandId) + if (command && (command.status === 'succeeded' || command.status === 'failed')) { + return command + } + } catch { + // Transient poll failure: keep trying until the attempt budget runs out. + } + } + return null +} + +// Read the endpoint's current lastreportedat (ISO string or null) so a row +// action can baseline freshness before it forces a state change. +async function currentTelemetryStamp() { + if (!props.assetid) return null + try { + const response = await api.get(`/endpoints/asset/${props.assetid}/telemetrymeta`) + return response?.data?.data?.lastreportedat || null + } catch { + return null + } +} + +// Poll lastreportedat until it advances past the baseline (proof the agent has +// posted a fresh snapshot reflecting the command) or the attempt budget runs +// out. Bounded so an idle/offline agent never hangs the row button; the caller +// reloads regardless once this returns. +async function waitForFreshTelemetry(baseline, { attempts = 15, delayMs = 1000 } = {}) { + const baseMs = baseline ? Date.parse(baseline) : 0 + for (let i = 0; i < attempts; i += 1) { + await new Promise((resolve) => setTimeout(resolve, delayMs)) + const stamp = await currentTelemetryStamp() + if (stamp && Date.parse(stamp) > baseMs) return true + } + return false +} + // Fetch the declared panels for this asset, then each panel's data endpoint. async function load() { panels.value = [] @@ -216,6 +288,10 @@ async function load() { } watch(() => props.assetid, load, { immediate: true }) + +// Let a parent detail page (for example PCDetail's header Refresh button) +// trigger a panel reload without duplicating the fetch plumbing. +defineExpose({ refresh: load })