Gate endpoint UI refresh on fresh telemetry and land endpoint agent
A command reaching 'succeeded' only proves the agent ran the action, not that it posted a new telemetry snapshot. Reloading panels on command success alone re-rendered stale service/process rows, so a stopped process could still show running right after Refresh Status. Add a lightweight telemetrymeta probe exposing lastreportedat and gate both the header Refresh Status action and inline row Start/Stop on that stamp advancing past a pre-action baseline. Polling is bounded so an offline or idle agent never hangs the button, and messaging distinguishes a confirmed refresh from a queued one. Bring the .NET endpoint agent source (service, tray, installer) into the repo so Cameron can merge and test, with an agent/.gitignore keeping bin/obj/publish out. Includes the DncMain launch-path fix, tray single-file host env scrub, and best-effort file logger.
This commit is contained in:
86
.github/agent-workflow.md
vendored
Normal file
86
.github/agent-workflow.md
vendored
Normal file
@@ -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.
|
||||
|
||||
19
Dockerfile
19
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.
|
||||
|
||||
7
agent/.gitignore
vendored
Normal file
7
agent/.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
# .NET build output - never tracked; produced by dotnet build/publish.
|
||||
bin/
|
||||
obj/
|
||||
publish/
|
||||
|
||||
# Local publish scratch and command-output captures.
|
||||
*.user
|
||||
166
agent/README.md
Normal file
166
agent/README.md
Normal file
@@ -0,0 +1,166 @@
|
||||
# ShopDB Endpoint Agent
|
||||
|
||||
A Windows telemetry agent that reports endpoint state to a ShopDB server's
|
||||
`endpoints` plugin. It runs as a Windows service in production and as a console
|
||||
app for development and testing.
|
||||
|
||||
## Projects
|
||||
|
||||
- `ShopDbEndpointAgent.Core` - contracts, config, logging, collection, and the
|
||||
resilient collector HTTP client. No host concerns; unit-testable.
|
||||
- `ShopDbEndpointAgent` - the service host (generic host + Windows service
|
||||
lifetime). Composes Core through DI. This is the telemetry runtime.
|
||||
- `ShopDbEndpointAgent.Tray` - interactive user-session tray app: a system-tray
|
||||
icon with "Send Status now" (on-demand report) and the 5-click issue reporter
|
||||
(ServiceNow flow is still a scaffold). Separate process from the service,
|
||||
because a SYSTEM service cannot draw a tray icon in a user session.
|
||||
|
||||
## Configuration
|
||||
|
||||
Runtime config is layered. Non-secret defaults live in `appsettings.json`
|
||||
(shipped with the build). The collector key and base URL are provisioned out of
|
||||
band in the registry and win over `appsettings.json`, so a distributed build
|
||||
never carries the secret and the site can rotate the key without a rebuild.
|
||||
|
||||
### Registry (production)
|
||||
|
||||
Create these values under `HKLM\SOFTWARE\GE\ShopDB` (64-bit view):
|
||||
|
||||
| Value | Type | Meaning |
|
||||
|----------------|--------|--------------------------------------------------|
|
||||
| `BaseUrl` | String | `https://shopdb.example.net` (server root) |
|
||||
| `CollectorKey` | String | The endpoint agent key from Settings -> Endpoints -> Key management |
|
||||
|
||||
Example (elevated PowerShell):
|
||||
|
||||
```
|
||||
New-Item -Path 'HKLM:\SOFTWARE\GE\ShopDB' -Force | Out-Null
|
||||
Set-ItemProperty -Path 'HKLM:\SOFTWARE\GE\ShopDB' -Name 'BaseUrl' -Value 'https://shopdb.example.net'
|
||||
Set-ItemProperty -Path 'HKLM:\SOFTWARE\GE\ShopDB' -Name 'CollectorKey' -Value '<paste the generated key>'
|
||||
```
|
||||
|
||||
### appsettings.json (all environments)
|
||||
|
||||
| Key | Default | Meaning |
|
||||
|-------------------|-------------|------------------------------------------------|
|
||||
| `baseurl` | "" | Dev fallback for the server root (registry wins). |
|
||||
| `apikey` | placeholder | Dev fallback for the collector key (registry wins). |
|
||||
| `cadenceseconds` | 900 | Report interval (15 min). |
|
||||
| `jitterseconds` | 60 | Random +/- spread on the interval (anti-herd). |
|
||||
| `adoptonfirstrun` | true | Send `adopt=true` on first contact only. |
|
||||
| `utilitiespath` | "" | Directory of site PowerShell "agent utilities". |
|
||||
| `logpath` | "" | Override the rolling log directory. |
|
||||
|
||||
For a dev box, set `baseurl` and `apikey` here and skip the registry.
|
||||
|
||||
## Running
|
||||
|
||||
Console (dev), one report then exit:
|
||||
|
||||
```
|
||||
dotnet run --project ShopDbEndpointAgent -- --once
|
||||
```
|
||||
|
||||
Console (dev), continuous cadence loop:
|
||||
|
||||
```
|
||||
dotnet run --project ShopDbEndpointAgent -- --console
|
||||
```
|
||||
|
||||
## Install as a Windows service
|
||||
|
||||
Publish self-contained, then register with `sc.exe` (elevated):
|
||||
|
||||
```
|
||||
dotnet publish ShopDbEndpointAgent -c Release -r win-x64 --self-contained -o C:\ProgramData\ShopDB\EndpointAgent\bin
|
||||
sc.exe create "ShopDBEndpointAgent" binPath= "C:\ProgramData\ShopDB\EndpointAgent\bin\shopdb-endpoint-agent.exe" start= auto DisplayName= "ShopDB Endpoint Agent"
|
||||
sc.exe description "ShopDBEndpointAgent" "Reports endpoint telemetry to ShopDB."
|
||||
sc.exe start "ShopDBEndpointAgent"
|
||||
```
|
||||
|
||||
The service runs as LocalSystem by default, which it needs to read the Security
|
||||
event channel and system WMI.
|
||||
|
||||
## Tray app (system-tray icon)
|
||||
|
||||
The tray is a per-user process, not a service, so it starts at sign-in rather
|
||||
than under `sc.exe`. `install-shopdbagent.ps1` handles it: it copies
|
||||
`shopdb-endpoint-tray.exe` next to the agent, registers an HKCU logon autostart
|
||||
(`Software\Microsoft\Windows\CurrentVersion\Run\ShopDbEndpointTray`), and
|
||||
launches it for the current user. Run the installer from the operator's own
|
||||
session so the autostart lands in the right user hive. Pass `-NoTray` for a
|
||||
headless, service-only install.
|
||||
|
||||
Right-click the tray icon for:
|
||||
|
||||
- `Send Status now` - reports this PC to ShopDB immediately so the monitor page
|
||||
refreshes without waiting for the next cadence tick. It shells out to
|
||||
`shopdbagent.exe --once`, reusing the exact production collection path; the
|
||||
tray holds no collector key. A balloon tip reflects success or failure (on
|
||||
failure, see the rolling agent log).
|
||||
- `Report an issue...` - the ServiceNow ticket flow (scaffold; submission path
|
||||
is an open decision).
|
||||
|
||||
A desktop shortcut to `shopdb-endpoint-tray.exe` can be added later for an
|
||||
explicitly launchable ServiceNow surface.
|
||||
|
||||
## WebUI command execution model (security context)
|
||||
|
||||
The WebUI can send an endpoint an allowlisted command (server source of truth:
|
||||
`plugins/endpoints/commands.py`; agent handler: `CommandExecutor`). The agent
|
||||
service runs as LocalSystem, but not every command runs in that context. The
|
||||
launch context is chosen per action so processes surface to the operator while
|
||||
privileged operations keep the token they need:
|
||||
|
||||
- Generic `process_start` (for example DncMain, UDC) launches into the ACTIVE
|
||||
CONSOLE USER's session at medium integrity, so the app is visible on the
|
||||
operator's desktop. CyberArk/EPM brokers any elevation afterward.
|
||||
- Generic `process_stop` / `process_status` run as SYSTEM (kill and probe).
|
||||
- `service_start` / `service_stop` / `service_status` run as SYSTEM, because
|
||||
service control requires the admin/SYSTEM token.
|
||||
- `mtconnect_start` / `mtconnect_stop` / `mtconnect_status` all run as SYSTEM,
|
||||
because MTConnect is the "UDC MTConnect Agent" Windows service and service
|
||||
control requires the admin/SYSTEM token. `mtconnect_start` runs `install.bat`
|
||||
(`Agent.exe install`) to install or repair the service, then ensures it is
|
||||
running; `mtconnect_stop` stops the service; `mtconnect_status` queries it.
|
||||
`run.bat` (`agent.exe run agent.cfg`) is a foreground TESTING launcher and is
|
||||
deliberately NOT used by the agent.
|
||||
- `system_restart` runs as SYSTEM and reboots the machine immediately via
|
||||
`shutdown.exe /r /t 0` (no grace timer). It is a fixed action with no target,
|
||||
gated server-side by the dedicated `endpoints.restart` permission on top of
|
||||
`endpoints.command`, and fronted by a WebUI confirmation gate.
|
||||
|
||||
Do not route MTConnect through the generic `process_start` user-session path or
|
||||
service control breaks. MTConnect is intentionally absent from the
|
||||
`process_start` launch map for the same reason; the fixed `mtconnect_*` handler
|
||||
owns it.
|
||||
|
||||
Behavior note for redeploys: the MTConnect service-control model is a runtime
|
||||
change, so the agent must be rebuilt and redeployed for it to take effect.
|
||||
Server-side command/allowlist/RBAC changes are picked up by the container
|
||||
rebuild; rebuild and redeploy the agent whenever the runtime launch/control
|
||||
logic itself changes.
|
||||
|
||||
## Diagnostics
|
||||
|
||||
- Rolling file log: `%ProgramData%\ShopDB\EndpointAgent\logs\agent-YYYYMMDD.log`
|
||||
(14-day retention).
|
||||
- Windows Event Log: agent runtime failures and unhandled exceptions are written
|
||||
to the `Application` log under source `ShopDB Endpoint Agent`. The source is
|
||||
created on first elevated run (or by the installer). Filter Event Viewer by
|
||||
that source to see agent/system interop failures.
|
||||
|
||||
## Agent utilities (PowerShell seam)
|
||||
|
||||
If `utilitiespath` points at a directory, the runtime runs each `*.ps1` there
|
||||
per report, parses its JSON stdout, and merges the result into the report. This
|
||||
is the seam the future server-side "Agent configuration" settings page uses to
|
||||
push site-authored collectors down. Scripts must be code-signed and
|
||||
allow-listed before this is enabled in the field (rollout concern).
|
||||
|
||||
## Logging extensibility
|
||||
|
||||
All runtime code logs through `Microsoft.Extensions.Logging` (`ILogger`). Adding
|
||||
a sink later (a ShopDB HTTP sink, Seq, syslog) is a host-builder change in
|
||||
`Program.cs::ConfigureLogging` with no changes to the worker, collector, or
|
||||
client.
|
||||
109
agent/ShopDbEndpointAgent.Core/AgentConfig.cs
Normal file
109
agent/ShopDbEndpointAgent.Core/AgentConfig.cs
Normal file
@@ -0,0 +1,109 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace ShopDbEndpointAgent.Core;
|
||||
|
||||
// Runtime configuration for the agent. Non-secret values come from
|
||||
// appsettings.json; the collector key and base URL are provisioned out of band
|
||||
// in the registry (HKLM:\SOFTWARE\GE\ShopDB) and layered on top - see
|
||||
// RegistryConfigProvider. The registry wins so a distributed appsettings.json
|
||||
// never carries the secret and the site can rotate the key without a rebuild.
|
||||
public sealed class AgentConfig
|
||||
{
|
||||
[JsonPropertyName("baseurl")] public string BaseUrl { get; set; } = "";
|
||||
[JsonPropertyName("apikey")] public string ApiKey { get; set; } = "";
|
||||
[JsonPropertyName("cadenceseconds")] public int CadenceSeconds { get; set; } = 900;
|
||||
|
||||
// Jitter spreads a fleet's reports so they do not arrive in lockstep
|
||||
// (thundering herd). Actual delay = cadence +/- up to jitterseconds.
|
||||
[JsonPropertyName("jitterseconds")] public int JitterSeconds { get; set; } = 60;
|
||||
|
||||
[JsonPropertyName("adoptonfirstrun")] public bool AdoptOnFirstRun { get; set; } = true;
|
||||
|
||||
// Directory holding site-provided PowerShell "agent utilities" the runtime
|
||||
// may invoke through the documented collection seam. Empty = none loaded.
|
||||
[JsonPropertyName("utilitiespath")] public string UtilitiesPath { get; set; } = "";
|
||||
|
||||
// Absolute path for the rolling diagnostics log. Empty = the default under
|
||||
// %ProgramData%\ShopDB\EndpointAgent\logs.
|
||||
[JsonPropertyName("logpath")] public string LogPath { get; set; } = "";
|
||||
|
||||
// What this endpoint watches. Shipped in appsettings.json (non-secret) so a
|
||||
// site can retarget monitors without a rebuild. Processes are interactive
|
||||
// apps watched by image name; services are Windows services watched by their
|
||||
// service key name. Empty lists = nothing watched.
|
||||
[JsonPropertyName("monitors")] public MonitorConfig Monitors { get; set; } = new();
|
||||
|
||||
// Per-image launchable paths for the generic process_start action. The wire
|
||||
// never carries a path (that would be a code-execution hole); Start sends
|
||||
// only the image name and the agent maps it here to a known-good executable
|
||||
// (or a .bat launcher). Anything not in the resolved map fails closed. A
|
||||
// site overrides or extends the shipped defaults in appsettings.json without
|
||||
// a rebuild. Keyed by process image name (case-insensitive, .exe optional).
|
||||
[JsonPropertyName("launchpaths")]
|
||||
public Dictionary<string, string> LaunchPaths { get; set; } = new();
|
||||
|
||||
// Shipped defaults for the org's common shop-floor apps. A site's
|
||||
// appsettings.json LaunchPaths entries win over these on a per-image basis.
|
||||
// MTConnect is intentionally absent here: it is a Windows service controlled
|
||||
// by the fixed mtconnect_* handler (install.bat + service control), not the
|
||||
// generic process_start path. eDNC and UDC launch their runtime executables
|
||||
// directly.
|
||||
public static readonly IReadOnlyDictionary<string, string> DefaultLaunchPaths =
|
||||
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["DncMain.exe"] = @"C:\Program Files (x86)\Dnc\bin\DncMain.exe",
|
||||
["UDC.exe"] = @"C:\Program Files\UDC\UDC.exe",
|
||||
};
|
||||
|
||||
// The effective launch map: shipped defaults overlaid with appsettings.json
|
||||
// entries (site wins). Keys are normalized to bare image names so a lookup
|
||||
// tolerates 'DncMain' or 'DncMain.exe'.
|
||||
public IReadOnlyDictionary<string, string> ResolveLaunchPaths()
|
||||
{
|
||||
var resolved = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var pair in DefaultLaunchPaths)
|
||||
resolved[NormalizeImage(pair.Key)] = pair.Value;
|
||||
foreach (var pair in LaunchPaths)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(pair.Key) || string.IsNullOrWhiteSpace(pair.Value))
|
||||
continue;
|
||||
resolved[NormalizeImage(pair.Key)] = pair.Value;
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
private static string NormalizeImage(string image)
|
||||
{
|
||||
var bare = (image ?? "").Trim();
|
||||
if (bare.EndsWith(".exe", StringComparison.OrdinalIgnoreCase))
|
||||
bare = bare[..^4];
|
||||
return bare;
|
||||
}
|
||||
|
||||
public bool IsUsable()
|
||||
=> !string.IsNullOrWhiteSpace(BaseUrl)
|
||||
&& !string.IsNullOrWhiteSpace(ApiKey)
|
||||
&& ApiKey != "REPLACE_WITH_COLLECTOR_KEY";
|
||||
}
|
||||
|
||||
// Watched targets for this endpoint. Both lists carry entries with a wire name
|
||||
// (process image name or Windows service key name) and an optional friendly app
|
||||
// label the server/UI can show (for example "eDNC" for dncMain.exe).
|
||||
public sealed class MonitorConfig
|
||||
{
|
||||
[JsonPropertyName("processes")] public MonitorTarget[] Processes { get; set; }
|
||||
= Array.Empty<MonitorTarget>();
|
||||
|
||||
[JsonPropertyName("services")] public MonitorTarget[] Services { get; set; }
|
||||
= Array.Empty<MonitorTarget>();
|
||||
}
|
||||
|
||||
public sealed class MonitorTarget
|
||||
{
|
||||
// Process image name (for example "udc.exe") or Windows service key name
|
||||
// (for example "MTConnectAgent"). Matched case-insensitively.
|
||||
[JsonPropertyName("name")] public string Name { get; set; } = "";
|
||||
|
||||
// Human-facing application label, optional. Falls back to Name when empty.
|
||||
[JsonPropertyName("app")] public string App { get; set; } = "";
|
||||
}
|
||||
110
agent/ShopDbEndpointAgent.Core/AgentConfigProvider.cs
Normal file
110
agent/ShopDbEndpointAgent.Core/AgentConfigProvider.cs
Normal file
@@ -0,0 +1,110 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace ShopDbEndpointAgent.Core;
|
||||
|
||||
// Builds the effective AgentConfig by layering two sources:
|
||||
// 1. appsettings.json - non-secret runtime defaults, shipped with the build.
|
||||
// 2. HKLM\SOFTWARE\GE\ShopDB - the collector key and base URL, provisioned
|
||||
// out of band (Intune/DSC/GE-Enforce). Registry values WIN so a
|
||||
// distributed appsettings.json never carries the secret and the site can
|
||||
// rotate the key without a rebuild.
|
||||
//
|
||||
// Reading the registry never throws out to the caller: a missing hive or value
|
||||
// simply leaves the appsettings value in place, which is what makes dev boxes
|
||||
// (appsettings only) and fielded machines (registry provisioned) both work.
|
||||
public static class AgentConfigProvider
|
||||
{
|
||||
public const string RegistryPath = @"SOFTWARE\GE\ShopDB";
|
||||
|
||||
// Registry value names. These mirror the names the computers reporter uses
|
||||
// (see plugins/endpoints/DESIGN-DECISIONS-agent.md section 1).
|
||||
public const string BaseUrlValue = "BaseUrl";
|
||||
public const string CollectorKeyValue = "CollectorKey";
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
};
|
||||
|
||||
public static AgentConfig Load(string? appSettingsPath = null,
|
||||
Microsoft.Extensions.Logging.ILogger? logger = null)
|
||||
{
|
||||
var config = LoadAppSettings(appSettingsPath) ?? new AgentConfig();
|
||||
ApplyRegistryOverrides(config, logger);
|
||||
return config;
|
||||
}
|
||||
|
||||
private static AgentConfig? LoadAppSettings(string? path)
|
||||
{
|
||||
path ??= Path.Combine(AppContext.BaseDirectory, "appsettings.json");
|
||||
if (!File.Exists(path))
|
||||
return null;
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(path);
|
||||
return JsonSerializer.Deserialize<AgentConfig>(json, JsonOptions);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Malformed appsettings: fall back to defaults + registry so the
|
||||
// agent can still run if the secret is provisioned in the registry.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static void ApplyRegistryOverrides(AgentConfig config,
|
||||
Microsoft.Extensions.Logging.ILogger? logger = null)
|
||||
{
|
||||
// Try 64-bit view first, then the default/32-bit view. On a locked-down
|
||||
// box (CyberArk/EPM gating regedit and hive reads) the read can throw
|
||||
// SecurityException/UnauthorizedAccessException even when the values
|
||||
// exist. We log the exact outcome per view instead of swallowing it, so
|
||||
// "Config incomplete" can be told apart from "access denied".
|
||||
RegistryView[] views = { RegistryView.Registry64, RegistryView.Registry32 };
|
||||
foreach (var view in views)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var machine = RegistryKey.OpenBaseKey(
|
||||
RegistryHive.LocalMachine, view);
|
||||
using var key = machine.OpenSubKey(RegistryPath);
|
||||
if (key is null)
|
||||
{
|
||||
logger?.LogWarning(
|
||||
"Registry key HKLM\\{Path} not found in {View} view.",
|
||||
RegistryPath, view);
|
||||
continue;
|
||||
}
|
||||
|
||||
var baseUrl = key.GetValue(BaseUrlValue) as string;
|
||||
if (!string.IsNullOrWhiteSpace(baseUrl))
|
||||
config.BaseUrl = baseUrl.Trim();
|
||||
|
||||
var collectorKey = key.GetValue(CollectorKeyValue) as string;
|
||||
if (!string.IsNullOrWhiteSpace(collectorKey))
|
||||
config.ApiKey = collectorKey.Trim();
|
||||
|
||||
logger?.LogInformation(
|
||||
"Registry {View}: BaseUrl {BaseUrlState}, CollectorKey {KeyState}.",
|
||||
view,
|
||||
string.IsNullOrWhiteSpace(baseUrl) ? "MISSING" : "present",
|
||||
string.IsNullOrWhiteSpace(collectorKey) ? "MISSING" : "present");
|
||||
|
||||
// Success reading this view; stop unless nothing was found.
|
||||
if (!string.IsNullOrWhiteSpace(baseUrl) ||
|
||||
!string.IsNullOrWhiteSpace(collectorKey))
|
||||
return;
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
logger?.LogError(error,
|
||||
"Registry read of HKLM\\{Path} in {View} view was blocked " +
|
||||
"({ErrorType}). If CyberArk/EPM is gating the hive, grant the " +
|
||||
"agent read access or use appsettings.json for this test.",
|
||||
RegistryPath, view, error.GetType().Name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
91
agent/ShopDbEndpointAgent.Core/AgentFaultMonitor.cs
Normal file
91
agent/ShopDbEndpointAgent.Core/AgentFaultMonitor.cs
Normal file
@@ -0,0 +1,91 @@
|
||||
using System.Diagnostics;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ShopDbEndpointAgent.Core;
|
||||
|
||||
// Agent self-monitoring (WER-style). Routes otherwise-unobserved failures -
|
||||
// unhandled exceptions on any thread and faulted tasks nobody awaited - to the
|
||||
// ILogger (so they land in the rolling file) AND directly to a dedicated
|
||||
// Windows Event Log source so agent runtime failures and agent/system interop
|
||||
// errors are visible in Event Viewer alongside the OS's own records.
|
||||
//
|
||||
// The Event Log source is created on first use; creating a source needs admin,
|
||||
// so if it is absent and cannot be created we degrade to the file log only and
|
||||
// never throw from a fault handler (that would mask the original failure).
|
||||
public sealed class AgentFaultMonitor
|
||||
{
|
||||
public const string EventLogSource = "ShopDB Endpoint Agent";
|
||||
public const string EventLogName = "Application";
|
||||
|
||||
private readonly ILogger logger;
|
||||
private readonly bool eventLogReady;
|
||||
|
||||
public AgentFaultMonitor(ILogger logger)
|
||||
{
|
||||
this.logger = logger;
|
||||
eventLogReady = EnsureEventLogSource();
|
||||
}
|
||||
|
||||
// Wire the process-wide fault hooks. Call once at startup.
|
||||
public void Install()
|
||||
{
|
||||
AppDomain.CurrentDomain.UnhandledException += OnUnhandledException;
|
||||
TaskScheduler.UnobservedTaskException += OnUnobservedTaskException;
|
||||
}
|
||||
|
||||
public void ReportFatal(string context, Exception error)
|
||||
=> Report(EventLogEntryType.Error, 1000, $"{context}: {error}");
|
||||
|
||||
private void OnUnhandledException(object sender, UnhandledExceptionEventArgs args)
|
||||
{
|
||||
var error = args.ExceptionObject as Exception;
|
||||
var message = error?.ToString() ?? "unknown unhandled exception";
|
||||
logger.LogCritical(error, "Unhandled exception (terminating={Terminating})",
|
||||
args.IsTerminating);
|
||||
Report(EventLogEntryType.Error, 1001,
|
||||
$"Unhandled exception (terminating={args.IsTerminating}): {message}");
|
||||
}
|
||||
|
||||
private void OnUnobservedTaskException(object? sender,
|
||||
UnobservedTaskExceptionEventArgs args)
|
||||
{
|
||||
logger.LogError(args.Exception, "Unobserved task exception");
|
||||
Report(EventLogEntryType.Warning, 1002,
|
||||
$"Unobserved task exception: {args.Exception}");
|
||||
// Mark observed so it does not escalate to a process-level failure.
|
||||
args.SetObserved();
|
||||
}
|
||||
|
||||
private void Report(EventLogEntryType type, int eventId, string message)
|
||||
{
|
||||
if (!eventLogReady)
|
||||
return;
|
||||
try
|
||||
{
|
||||
EventLog.WriteEntry(EventLogSource, message, type, eventId);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Event Log write failed (source removed, log full): the file log
|
||||
// already has this entry; a fault handler must never throw.
|
||||
}
|
||||
}
|
||||
|
||||
private static bool EnsureEventLogSource()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (EventLog.SourceExists(EventLogSource))
|
||||
return true;
|
||||
EventLog.CreateEventSource(new EventSourceCreationData(
|
||||
EventLogSource, EventLogName));
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Needs admin the first time; the installer creates the source, so
|
||||
// in the field this succeeds. In dev without admin we log to file.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
26
agent/ShopDbEndpointAgent.Core/CollectSignal.cs
Normal file
26
agent/ShopDbEndpointAgent.Core/CollectSignal.cs
Normal file
@@ -0,0 +1,26 @@
|
||||
namespace ShopDbEndpointAgent.Core;
|
||||
|
||||
// A one-shot wake signal shared between the command worker and the telemetry
|
||||
// worker. After a command runs (e.g. MTConnect start/stop), the command worker
|
||||
// calls Request() so the telemetry loop collects immediately instead of waiting
|
||||
// out the remaining cadence, and the WebUI card reflects the new state within a
|
||||
// single report cycle rather than up to 15 minutes later.
|
||||
public sealed class CollectSignal
|
||||
{
|
||||
private readonly SemaphoreSlim gate = new(0, 1);
|
||||
|
||||
// Wake the telemetry loop for one off-cadence collection. Extra calls while
|
||||
// one is already pending collapse into a single wake (the gate caps at 1).
|
||||
public void Request()
|
||||
{
|
||||
try { gate.Release(); }
|
||||
catch (SemaphoreFullException) { /* a wake is already pending */ }
|
||||
}
|
||||
|
||||
// Wait until either the cadence delay elapses or a collect is requested.
|
||||
// Returns true when woken by a request, false on a normal cadence timeout.
|
||||
public async Task<bool> WaitAsync(TimeSpan delay, CancellationToken token)
|
||||
{
|
||||
return await gate.WaitAsync(delay, token);
|
||||
}
|
||||
}
|
||||
118
agent/ShopDbEndpointAgent.Core/CollectorClient.cs
Normal file
118
agent/ShopDbEndpointAgent.Core/CollectorClient.cs
Normal file
@@ -0,0 +1,118 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ShopDbEndpointAgent.Core;
|
||||
|
||||
// Posts one EndpointReport to POST {baseurl}/api/collector/endpoints with the
|
||||
// X-API-Key header (plugins/endpoints/AGENT-CONTRACT.md section 1), unwrapping
|
||||
// the ShopDB envelope { status, data, meta } to return the CollectorResponse.
|
||||
//
|
||||
// Resilience: transient failures (network errors, timeouts, 5xx, 429) are
|
||||
// retried with exponential backoff plus jitter, bounded by maxAttempts. A 4xx
|
||||
// other than 429 is a permanent error (bad key, unknown host in strict mode)
|
||||
// and is surfaced immediately without retry - hammering the server changes
|
||||
// nothing.
|
||||
public sealed class CollectorClient : IDisposable
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
DefaultIgnoreCondition = System.Text.Json.Serialization
|
||||
.JsonIgnoreCondition.WhenWritingNull,
|
||||
};
|
||||
|
||||
private readonly HttpClient http;
|
||||
private readonly ILogger<CollectorClient> logger;
|
||||
private readonly int maxAttempts;
|
||||
private readonly Random jitter = new();
|
||||
|
||||
public CollectorClient(AgentConfig config, ILogger<CollectorClient> logger,
|
||||
int maxAttempts = 4)
|
||||
{
|
||||
this.logger = logger;
|
||||
this.maxAttempts = Math.Max(1, maxAttempts);
|
||||
http = new HttpClient
|
||||
{
|
||||
BaseAddress = new Uri(config.BaseUrl.TrimEnd('/') + "/"),
|
||||
Timeout = TimeSpan.FromSeconds(30),
|
||||
};
|
||||
http.DefaultRequestHeaders.Add("X-API-Key", config.ApiKey);
|
||||
}
|
||||
|
||||
public async Task<CollectorResponse> SendAsync(
|
||||
EndpointReport report, CancellationToken token)
|
||||
{
|
||||
Exception? last = null;
|
||||
for (int attempt = 1; attempt <= maxAttempts; attempt++)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
try
|
||||
{
|
||||
return await SendOnceAsync(report, token);
|
||||
}
|
||||
catch (CollectorException error) when (error.IsPermanent)
|
||||
{
|
||||
// Bad credential or rejected payload: no retry.
|
||||
throw;
|
||||
}
|
||||
catch (Exception error) when (
|
||||
error is CollectorException or HttpRequestException or TaskCanceledException
|
||||
&& !token.IsCancellationRequested)
|
||||
{
|
||||
last = error;
|
||||
if (attempt == maxAttempts)
|
||||
break;
|
||||
var delay = BackoffDelay(attempt);
|
||||
logger.LogWarning(
|
||||
"Collector send attempt {Attempt}/{Max} failed: {Message}; retrying in {Delay}s",
|
||||
attempt, maxAttempts, error.Message, delay.TotalSeconds);
|
||||
await Task.Delay(delay, token);
|
||||
}
|
||||
}
|
||||
throw last ?? new CollectorException(0, "collector send failed");
|
||||
}
|
||||
|
||||
private async Task<CollectorResponse> SendOnceAsync(
|
||||
EndpointReport report, CancellationToken token)
|
||||
{
|
||||
using var response = await http.PostAsJsonAsync(
|
||||
"api/collector/endpoints", report, JsonOptions, token);
|
||||
|
||||
var body = await response.Content.ReadAsStringAsync(token);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
throw new CollectorException((int)response.StatusCode, body);
|
||||
|
||||
var envelope = JsonSerializer.Deserialize<CollectorEnvelope>(body, JsonOptions);
|
||||
if (envelope?.Data is null)
|
||||
throw new CollectorException((int)response.StatusCode, "empty response body");
|
||||
return envelope.Data;
|
||||
}
|
||||
|
||||
private TimeSpan BackoffDelay(int attempt)
|
||||
{
|
||||
// 2^(attempt-1) seconds, capped, plus up to 1s jitter.
|
||||
double seconds = Math.Min(30, Math.Pow(2, attempt - 1));
|
||||
return TimeSpan.FromSeconds(seconds + jitter.NextDouble());
|
||||
}
|
||||
|
||||
public void Dispose() => http.Dispose();
|
||||
}
|
||||
|
||||
public sealed class CollectorException : Exception
|
||||
{
|
||||
public int StatusCode { get; }
|
||||
|
||||
// A permanent failure is a 4xx that is not 429 (rate limit): retrying with
|
||||
// the same key and payload cannot succeed, so the worker should back off to
|
||||
// its normal cadence rather than fast-retry.
|
||||
public bool IsPermanent =>
|
||||
StatusCode >= 400 && StatusCode < 500
|
||||
&& StatusCode != (int)HttpStatusCode.TooManyRequests;
|
||||
|
||||
public CollectorException(int statusCode, string body)
|
||||
: base($"collector returned HTTP {statusCode}: {body}")
|
||||
{
|
||||
StatusCode = statusCode;
|
||||
}
|
||||
}
|
||||
83
agent/ShopDbEndpointAgent.Core/CommandClient.cs
Normal file
83
agent/ShopDbEndpointAgent.Core/CommandClient.cs
Normal file
@@ -0,0 +1,83 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ShopDbEndpointAgent.Core;
|
||||
|
||||
// Talks to the endpoint command channel (plugins/endpoints/AGENT-CONTRACT.md
|
||||
// section 7). The agent opens no inbound port: it long-polls
|
||||
// GET /api/endpoints/commands/next, which the server holds open until a command
|
||||
// is queued (HTTP 200) or the hold window elapses (HTTP 204). After running the
|
||||
// claimed command locally the agent posts the outcome to
|
||||
// POST /api/endpoints/commands/{id}/result. Both share the endpoints collector
|
||||
// key via X-API-Key, so the command channel rides the same kill switch as
|
||||
// telemetry and adds no second secret.
|
||||
public sealed class CommandClient : IDisposable
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
DefaultIgnoreCondition = System.Text.Json.Serialization
|
||||
.JsonIgnoreCondition.WhenWritingNull,
|
||||
};
|
||||
|
||||
private readonly HttpClient http;
|
||||
private readonly ILogger<CommandClient> logger;
|
||||
|
||||
public CommandClient(AgentConfig config, ILogger<CommandClient> logger)
|
||||
{
|
||||
this.logger = logger;
|
||||
http = new HttpClient
|
||||
{
|
||||
BaseAddress = new Uri(config.BaseUrl.TrimEnd('/') + "/"),
|
||||
// Longer than the collector client: the claim request is held open
|
||||
// server-side (~25s), so the read timeout must clear that plus slack.
|
||||
Timeout = TimeSpan.FromSeconds(60),
|
||||
};
|
||||
http.DefaultRequestHeaders.Add("X-API-Key", config.ApiKey);
|
||||
}
|
||||
|
||||
// Claim the next pending command for this hostname, or null when the server
|
||||
// returned 204 (nothing queued within the hold window). A 4xx other than 429
|
||||
// is surfaced so the worker can back off instead of hot-looping a bad key or
|
||||
// an unknown host.
|
||||
public async Task<CommandClaim?> ClaimNextAsync(
|
||||
string hostname, CancellationToken token)
|
||||
{
|
||||
var url = "api/endpoints/commands/next?hostname="
|
||||
+ Uri.EscapeDataString(hostname);
|
||||
using var response = await http.GetAsync(url, token);
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.NoContent)
|
||||
return null;
|
||||
|
||||
var body = await response.Content.ReadAsStringAsync(token);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
throw new CollectorException((int)response.StatusCode, body);
|
||||
|
||||
var envelope = JsonSerializer.Deserialize<CommandClaimEnvelope>(
|
||||
body, JsonOptions);
|
||||
return envelope?.Data;
|
||||
}
|
||||
|
||||
// Report a claimed command's outcome. Best-effort: a failure to post the
|
||||
// result is logged and swallowed so a transient network blip does not crash
|
||||
// the worker; the command simply stays 'running' server-side until a later
|
||||
// operator retry supersedes it.
|
||||
public async Task ReportResultAsync(
|
||||
int commandId, CommandResult result, CancellationToken token)
|
||||
{
|
||||
var url = $"api/endpoints/commands/{commandId}/result";
|
||||
using var response = await http.PostAsJsonAsync(
|
||||
url, result, JsonOptions, token);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var body = await response.Content.ReadAsStringAsync(token);
|
||||
logger.LogWarning(
|
||||
"Reporting result for command {CommandId} returned HTTP {Status}: {Body}",
|
||||
commandId, (int)response.StatusCode, body);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose() => http.Dispose();
|
||||
}
|
||||
581
agent/ShopDbEndpointAgent.Core/CommandExecutor.cs
Normal file
581
agent/ShopDbEndpointAgent.Core/CommandExecutor.cs
Normal file
@@ -0,0 +1,581 @@
|
||||
using System.Diagnostics;
|
||||
using System.ServiceProcess;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ShopDbEndpointAgent.Core;
|
||||
|
||||
// Runs an allowlisted endpoint command locally and returns the result to post
|
||||
// back. The allowlist here mirrors the server's (plugins/endpoints/commands.py):
|
||||
// the wire carries an action NAME plus, for generic actions, a TARGET, and this
|
||||
// is the only place that maps a name to concrete behavior. An unknown name
|
||||
// fails closed - it is never treated as a command string to execute.
|
||||
//
|
||||
// Two families:
|
||||
// Generic primitives (data-driven; NO agent rebuild for a new app/policy):
|
||||
// service_start / service_stop / service_status - act on claim.Target, a
|
||||
// Windows service name.
|
||||
// process_stop / process_status - act on claim.Target, a process image name
|
||||
// (for example 'DncMain.exe'). process_start also acts on the image name
|
||||
// but launches ONLY through a per-image allowlist of known-good paths
|
||||
// (AgentConfig.LaunchPaths, shipped defaults overridable per site); an
|
||||
// unmapped target fails closed so the wire can never launch an arbitrary
|
||||
// binary. process_start launches into the ACTIVE CONSOLE USER'S session
|
||||
// (not session 0) at the user's medium integrity so the app is visible
|
||||
// on their desktop; CyberArk/EPM brokers any elevation afterward. Stop
|
||||
// and status stay as SYSTEM.
|
||||
// Fixed MTConnect actions (target pinned in the agent, never on the wire):
|
||||
// mtconnect_start / mtconnect_stop / mtconnect_status.
|
||||
// MTConnect is a Windows service ("UDC MTConnect Agent"), so all three run
|
||||
// as SYSTEM (service control needs the admin/SYSTEM token). Start runs
|
||||
// install.bat (Agent.exe install) to install or repair the service, then
|
||||
// ensures it is running; stop stops the service; status queries it. NOTE:
|
||||
// run.bat (agent.exe run agent.cfg) is a foreground TESTING launcher and is
|
||||
// deliberately NOT used - do not route MTConnect through the generic
|
||||
// process_start user-session path or the service control breaks.
|
||||
//
|
||||
// Every action probes and reports the resulting run state (MtConnectRunning for
|
||||
// MTConnect; the generic probes report via Message/Detail) so the WebUI toggle
|
||||
// reflects reality rather than the action's intent.
|
||||
public sealed class CommandExecutor
|
||||
{
|
||||
// The UDC MTConnect agent is a Windows service. Its name is fixed here (not
|
||||
// sent over the wire) so a compromised WebUI cannot redirect service control.
|
||||
// A later per-site override belongs in AgentConfig, not the command payload.
|
||||
private const string MtConnectServiceName = "UDC MTConnect Agent";
|
||||
|
||||
// The MTConnect install/repair seam: Agent.exe install. run.bat (agent.exe
|
||||
// run agent.cfg) is a foreground TESTING launcher and is deliberately NOT
|
||||
// used here. install.bat registers or repairs the service; the start handler
|
||||
// runs it, then ensures the service is running.
|
||||
private const string MtConnectInstallBat =
|
||||
@"C:\ProgramData\UDC\MTConnect_UDC\Agent\install.bat";
|
||||
|
||||
private readonly ILogger<CommandExecutor> logger;
|
||||
private readonly TimeSpan startTimeout;
|
||||
|
||||
// Resolved per-image launchable paths for process_start (shipped defaults
|
||||
// overlaid with any appsettings.json overrides). A target absent from this
|
||||
// map cannot be started - process_start fails closed.
|
||||
private readonly IReadOnlyDictionary<string, string> launchPaths;
|
||||
|
||||
public CommandExecutor(ILogger<CommandExecutor> logger,
|
||||
AgentConfig config, TimeSpan? startTimeout = null)
|
||||
{
|
||||
this.logger = logger;
|
||||
this.startTimeout = startTimeout ?? TimeSpan.FromSeconds(30);
|
||||
this.launchPaths = (config ?? new AgentConfig()).ResolveLaunchPaths();
|
||||
}
|
||||
|
||||
public async Task<CommandResult> ExecuteAsync(
|
||||
CommandClaim claim, CancellationToken token)
|
||||
{
|
||||
var action = claim.Action;
|
||||
var target = (claim.Target ?? "").Trim();
|
||||
switch (action)
|
||||
{
|
||||
// Fixed MTConnect actions: target is pinned in the agent.
|
||||
case "mtconnect_start":
|
||||
return await MtConnectStartAsync(token);
|
||||
case "mtconnect_stop":
|
||||
return MtConnectStop();
|
||||
case "mtconnect_status":
|
||||
return MtConnectStatus();
|
||||
|
||||
// Generic service primitives: act on the wire target.
|
||||
case "service_start":
|
||||
return RequireTarget(action, target)
|
||||
?? ServiceControl(target, ServiceControllerStatus.Running);
|
||||
case "service_stop":
|
||||
return RequireTarget(action, target)
|
||||
?? ServiceControl(target, ServiceControllerStatus.Stopped);
|
||||
case "service_status":
|
||||
return RequireTarget(action, target)
|
||||
?? ServiceStatus(target);
|
||||
|
||||
// Generic process primitives: act on the wire target image name.
|
||||
case "process_stop":
|
||||
return RequireTarget(action, target)
|
||||
?? ProcessStop(target);
|
||||
case "process_status":
|
||||
return RequireTarget(action, target)
|
||||
?? ProcessStatus(target);
|
||||
case "process_start":
|
||||
return RequireTarget(action, target)
|
||||
?? await ProcessStart(target, token);
|
||||
|
||||
// Fixed destructive action: reboot this endpoint immediately. Runs
|
||||
// as the SYSTEM agent, so shutdown does not need a user session.
|
||||
case "system_restart":
|
||||
return SystemRestart();
|
||||
|
||||
// Force an immediate off-cadence telemetry report. The handler is a
|
||||
// no-op success: CommandWorker fires collectSignal.Request() after
|
||||
// every command, which wakes the telemetry loop to collect now, so
|
||||
// the WebUI reflects live state instead of waiting for cadence.
|
||||
case "collect_now":
|
||||
logger.LogInformation("collect_now requested: forcing off-cadence telemetry");
|
||||
return new CommandResult
|
||||
{
|
||||
Succeeded = true,
|
||||
Message = "Fresh telemetry collection requested",
|
||||
};
|
||||
|
||||
default:
|
||||
logger.LogWarning("Rejected unknown command action {Action}", action);
|
||||
return new CommandResult
|
||||
{
|
||||
Succeeded = false,
|
||||
Message = $"Unknown or disallowed action: {action}",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Guard: a generic action with no target has nothing to act on. Returns a
|
||||
// failed result to short-circuit, or null when the target is present.
|
||||
private CommandResult? RequireTarget(string action, string target)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(target))
|
||||
return null;
|
||||
logger.LogWarning("Action {Action} arrived with no target", action);
|
||||
return new CommandResult
|
||||
{
|
||||
Succeeded = false,
|
||||
Message = $"Action {action} requires a target",
|
||||
};
|
||||
}
|
||||
|
||||
// ---- Fixed destructive actions ----
|
||||
|
||||
// Reboot the machine now. shutdown.exe with /t 0 gives no grace timer; the
|
||||
// WebUI confirmation gate is the safety, not a countdown. As SYSTEM the
|
||||
// agent already holds the privilege, so no session or elevation dance.
|
||||
private CommandResult SystemRestart()
|
||||
{
|
||||
try
|
||||
{
|
||||
var restart = new ProcessStartInfo
|
||||
{
|
||||
FileName = "shutdown.exe",
|
||||
Arguments = "/r /t 0",
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
};
|
||||
using var process = Process.Start(restart);
|
||||
logger.LogWarning("system_restart issued: rebooting now");
|
||||
return new CommandResult
|
||||
{
|
||||
Succeeded = true,
|
||||
Message = "Restart initiated",
|
||||
};
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
logger.LogError(error, "system_restart failed");
|
||||
return new CommandResult
|
||||
{
|
||||
Succeeded = false,
|
||||
Message = $"Failed to initiate restart: {error.Message}",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Generic service primitives ----
|
||||
|
||||
private CommandResult ServiceControl(
|
||||
string serviceName, ServiceControllerStatus desired)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var controller = new ServiceController(serviceName);
|
||||
|
||||
if (controller.Status == desired)
|
||||
{
|
||||
return new CommandResult
|
||||
{
|
||||
Succeeded = true,
|
||||
Message = $"Service {serviceName} already {desired}",
|
||||
Detail = ServiceDetail(controller),
|
||||
};
|
||||
}
|
||||
|
||||
if (desired == ServiceControllerStatus.Running)
|
||||
controller.Start();
|
||||
else
|
||||
controller.Stop();
|
||||
|
||||
controller.WaitForStatus(desired, startTimeout);
|
||||
controller.Refresh();
|
||||
var reached = controller.Status == desired;
|
||||
return new CommandResult
|
||||
{
|
||||
Succeeded = reached,
|
||||
Message = reached
|
||||
? $"Service {serviceName} {desired}"
|
||||
: $"Service {serviceName} did not reach {desired} within timeout",
|
||||
Detail = ServiceDetail(controller),
|
||||
};
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
logger.LogWarning(error, "Failed to control service {Service}", serviceName);
|
||||
return new CommandResult
|
||||
{
|
||||
Succeeded = false,
|
||||
Message = $"Failed to control service {serviceName}: {error.Message}",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private CommandResult ServiceStatus(string serviceName)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var controller = new ServiceController(serviceName);
|
||||
return new CommandResult
|
||||
{
|
||||
Succeeded = true,
|
||||
Message = $"Service {serviceName} is {controller.Status}",
|
||||
Detail = ServiceDetail(controller),
|
||||
};
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
logger.LogWarning(error, "Failed to read service {Service}", serviceName);
|
||||
return new CommandResult
|
||||
{
|
||||
Succeeded = false,
|
||||
Message = $"Failed to read service {serviceName}: {error.Message}",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private static Dictionary<string, object> ServiceDetail(ServiceController controller)
|
||||
{
|
||||
controller.Refresh();
|
||||
return new Dictionary<string, object>
|
||||
{
|
||||
["kind"] = "service",
|
||||
["name"] = controller.ServiceName,
|
||||
["status"] = controller.Status.ToString(),
|
||||
["running"] = controller.Status == ServiceControllerStatus.Running,
|
||||
};
|
||||
}
|
||||
|
||||
// ---- Generic process primitives ----
|
||||
|
||||
private CommandResult ProcessStop(string image)
|
||||
{
|
||||
var processes = FindProcessesByImage(image);
|
||||
if (processes.Count == 0)
|
||||
{
|
||||
return new CommandResult
|
||||
{
|
||||
Succeeded = true,
|
||||
Message = $"Process {image} already stopped",
|
||||
Detail = ProcessDetail(image, false),
|
||||
};
|
||||
}
|
||||
|
||||
var errors = new List<string>();
|
||||
foreach (var process in processes)
|
||||
{
|
||||
try
|
||||
{
|
||||
process.Kill(entireProcessTree: true);
|
||||
process.WaitForExit(10000);
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
errors.Add(error.Message);
|
||||
logger.LogWarning(error, "Failed to stop process {Pid}", process.Id);
|
||||
}
|
||||
finally
|
||||
{
|
||||
process.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
var running = IsProcessRunning(image);
|
||||
return new CommandResult
|
||||
{
|
||||
Succeeded = !running,
|
||||
Message = running
|
||||
? $"Process {image} still running after stop: {string.Join("; ", errors)}"
|
||||
: $"Process {image} stopped",
|
||||
Detail = ProcessDetail(image, running),
|
||||
};
|
||||
}
|
||||
|
||||
private CommandResult ProcessStatus(string image)
|
||||
{
|
||||
var running = IsProcessRunning(image);
|
||||
return new CommandResult
|
||||
{
|
||||
Succeeded = true,
|
||||
Message = running ? $"Process {image} running" : $"Process {image} stopped",
|
||||
Detail = ProcessDetail(image, running),
|
||||
};
|
||||
}
|
||||
|
||||
private static Dictionary<string, object> ProcessDetail(string image, bool running)
|
||||
=> new()
|
||||
{
|
||||
["kind"] = "process",
|
||||
["name"] = image,
|
||||
["running"] = running,
|
||||
};
|
||||
|
||||
// Start a process by image name, but ONLY if it resolves to a known-good
|
||||
// launchable path (shipped defaults + appsettings overrides). The wire never
|
||||
// carries a path, so a compromised WebUI cannot launch an arbitrary binary:
|
||||
// an unmapped target fails closed. A mapped .bat is launched from its own
|
||||
// directory; a mapped .exe is launched
|
||||
// directly. Either way we do not wait for the launcher to exit - we poll for
|
||||
// the image to appear and report the resulting run state.
|
||||
private async Task<CommandResult> ProcessStart(string image, CancellationToken token)
|
||||
{
|
||||
if (IsProcessRunning(image))
|
||||
{
|
||||
return new CommandResult
|
||||
{
|
||||
Succeeded = true,
|
||||
Message = $"Process {image} already running",
|
||||
Detail = ProcessDetail(image, true),
|
||||
};
|
||||
}
|
||||
|
||||
var bare = image;
|
||||
if (bare.EndsWith(".exe", StringComparison.OrdinalIgnoreCase))
|
||||
bare = bare[..^4];
|
||||
|
||||
if (!launchPaths.TryGetValue(bare, out var launchPath)
|
||||
|| string.IsNullOrWhiteSpace(launchPath))
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Rejected process_start for {Image}: no launchable path configured",
|
||||
image);
|
||||
return new CommandResult
|
||||
{
|
||||
Succeeded = false,
|
||||
Message = $"process_start is not permitted for {image}: no launch path configured",
|
||||
Detail = ProcessDetail(image, false),
|
||||
};
|
||||
}
|
||||
|
||||
if (!File.Exists(launchPath))
|
||||
{
|
||||
logger.LogWarning(
|
||||
"process_start for {Image}: configured launch path does not exist: {Path}",
|
||||
image, launchPath);
|
||||
return new CommandResult
|
||||
{
|
||||
Succeeded = false,
|
||||
Message = $"Launch path not found for {image}: {launchPath}",
|
||||
Detail = ProcessDetail(image, false),
|
||||
};
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Launch into the ACTIVE CONSOLE USER'S session, not session 0. The
|
||||
// agent is a SYSTEM service; a plain Process.Start here would put the
|
||||
// GUI app on the hidden session-0 desktop where the operator never
|
||||
// sees it. The app starts at the user's medium integrity and
|
||||
// CyberArk/EPM brokers any elevation afterward.
|
||||
var workingDirectory = Path.GetDirectoryName(launchPath) ?? "";
|
||||
var outcome = InteractiveProcessLauncher.LaunchInActiveSession(
|
||||
launchPath, workingDirectory);
|
||||
if (!outcome.Launched)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"process_start could not launch {Image} in the user session: {Error}",
|
||||
image, outcome.Error);
|
||||
return new CommandResult
|
||||
{
|
||||
Succeeded = false,
|
||||
Message = $"Failed to start {image}: {outcome.Error}",
|
||||
Detail = ProcessDetail(image, false),
|
||||
};
|
||||
}
|
||||
|
||||
// The launcher (an .exe or a possibly long-lived run.bat) may not be
|
||||
// the watched image itself, so poll for the target image to appear
|
||||
// rather than waiting on the launcher to exit.
|
||||
var deadline = DateTime.UtcNow + startTimeout;
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
if (IsProcessRunning(image))
|
||||
return new CommandResult
|
||||
{
|
||||
Succeeded = true,
|
||||
Message = $"Process {image} started",
|
||||
Detail = ProcessDetail(image, true),
|
||||
};
|
||||
await Task.Delay(1000, token);
|
||||
}
|
||||
|
||||
return new CommandResult
|
||||
{
|
||||
Succeeded = false,
|
||||
Message = $"Process {image} did not report running within the timeout",
|
||||
Detail = ProcessDetail(image, IsProcessRunning(image)),
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "process_start failed for {Image} via {Path}",
|
||||
image, launchPath);
|
||||
return new CommandResult
|
||||
{
|
||||
Succeeded = false,
|
||||
Message = $"Failed to start {image}: {ex.Message}",
|
||||
Detail = ProcessDetail(image, false),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private bool IsProcessRunning(string image)
|
||||
{
|
||||
var processes = FindProcessesByImage(image);
|
||||
try
|
||||
{
|
||||
return processes.Count > 0;
|
||||
}
|
||||
finally
|
||||
{
|
||||
foreach (var process in processes)
|
||||
process.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// Match a target image name against running processes. Accepts 'DncMain.exe'
|
||||
// or 'DncMain' - Process.GetProcessesByName wants the bare name, so strip a
|
||||
// trailing .exe before matching.
|
||||
private static List<Process> FindProcessesByImage(string image)
|
||||
{
|
||||
var bare = image;
|
||||
if (bare.EndsWith(".exe", StringComparison.OrdinalIgnoreCase))
|
||||
bare = bare[..^4];
|
||||
|
||||
var found = new List<Process>();
|
||||
try
|
||||
{
|
||||
found.AddRange(Process.GetProcessesByName(bare));
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Access-denied on a foreign session's process: not our agent's to
|
||||
// see, so treat it as absent rather than failing the probe.
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
// ---- Fixed MTConnect actions ----
|
||||
|
||||
private async Task<CommandResult> MtConnectStartAsync(CancellationToken token)
|
||||
{
|
||||
if (MtConnectServiceRunning())
|
||||
{
|
||||
return new CommandResult
|
||||
{
|
||||
Succeeded = true,
|
||||
Message = "MTConnect already running",
|
||||
MtConnectRunning = true,
|
||||
};
|
||||
}
|
||||
|
||||
// install.bat (Agent.exe install) installs or repairs the service. Run it
|
||||
// as SYSTEM and wait for it to exit - unlike the long-lived run.bat, the
|
||||
// installer is a quick one-shot. Missing installer is not fatal on its
|
||||
// own: the service may already be registered, so fall through to start.
|
||||
if (File.Exists(MtConnectInstallBat))
|
||||
{
|
||||
try
|
||||
{
|
||||
var install = new ProcessStartInfo
|
||||
{
|
||||
FileName = MtConnectInstallBat,
|
||||
WorkingDirectory = Path.GetDirectoryName(MtConnectInstallBat) ?? "",
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
};
|
||||
using var installer = Process.Start(install);
|
||||
if (installer is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await installer.WaitForExitAsync(token);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Shutting down; leave the service in whatever state it
|
||||
// reached and report the current status below.
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
logger.LogWarning(error, "MTConnect install.bat failed");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.LogWarning(
|
||||
"MTConnect installer not found: {Installer}; attempting service start anyway",
|
||||
MtConnectInstallBat);
|
||||
}
|
||||
|
||||
// install.bat may or may not leave the service started; ensure it is
|
||||
// running via the same service-control path the generic primitives use.
|
||||
var result = ServiceControl(MtConnectServiceName, ServiceControllerStatus.Running);
|
||||
return new CommandResult
|
||||
{
|
||||
Succeeded = result.Succeeded,
|
||||
Message = result.Succeeded ? "MTConnect started" : result.Message,
|
||||
MtConnectRunning = MtConnectServiceRunning(),
|
||||
Detail = result.Detail,
|
||||
};
|
||||
}
|
||||
|
||||
private CommandResult MtConnectStop()
|
||||
{
|
||||
var result = ServiceControl(MtConnectServiceName, ServiceControllerStatus.Stopped);
|
||||
var running = MtConnectServiceRunning();
|
||||
return new CommandResult
|
||||
{
|
||||
Succeeded = result.Succeeded,
|
||||
Message = running ? result.Message : "MTConnect stopped",
|
||||
MtConnectRunning = running,
|
||||
Detail = result.Detail,
|
||||
};
|
||||
}
|
||||
|
||||
private CommandResult MtConnectStatus()
|
||||
{
|
||||
var result = ServiceStatus(MtConnectServiceName);
|
||||
var running = MtConnectServiceRunning();
|
||||
return new CommandResult
|
||||
{
|
||||
Succeeded = result.Succeeded,
|
||||
Message = running ? "MTConnect running" : "MTConnect stopped",
|
||||
MtConnectRunning = running,
|
||||
Detail = result.Detail,
|
||||
};
|
||||
}
|
||||
|
||||
// Probe the MTConnect Windows service state. A missing/unqueryable service
|
||||
// (not installed, access denied on a foreign session) reports not running
|
||||
// rather than throwing, so the WebUI toggle degrades to "stopped".
|
||||
private bool MtConnectServiceRunning()
|
||||
{
|
||||
try
|
||||
{
|
||||
using var controller = new ServiceController(MtConnectServiceName);
|
||||
return controller.Status == ServiceControllerStatus.Running;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
143
agent/ShopDbEndpointAgent.Core/CommandWorker.cs
Normal file
143
agent/ShopDbEndpointAgent.Core/CommandWorker.cs
Normal file
@@ -0,0 +1,143 @@
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ShopDbEndpointAgent.Core;
|
||||
|
||||
// The command loop as a hosted BackgroundService, separate from TelemetryWorker
|
||||
// so a slow or held-open command poll never delays a telemetry report and vice
|
||||
// versa. It long-polls the server for an allowlisted command
|
||||
// (plugins/endpoints/AGENT-CONTRACT.md section 7), runs it through
|
||||
// CommandExecutor, and posts the result back. The server holds each poll open
|
||||
// briefly, so this loop is effectively "wait for work, do it, immediately wait
|
||||
// again" rather than a fixed-cadence poll.
|
||||
//
|
||||
// Like TelemetryWorker, one bad iteration never kills the loop: a permanent
|
||||
// error (bad key, unknown host) backs off to a slow retry so it does not hammer
|
||||
// the server, and any other failure is logged and retried after a short pause.
|
||||
public sealed class CommandWorker : BackgroundService
|
||||
{
|
||||
// Pause after a transient failure before re-polling, so a flapping network
|
||||
// or a briefly-down server does not spin this loop.
|
||||
private static readonly TimeSpan TransientRetryDelay = TimeSpan.FromSeconds(10);
|
||||
|
||||
// Longer back-off after a permanent error (bad key, host removed): the
|
||||
// operator has to fix something, so re-polling fast changes nothing.
|
||||
private static readonly TimeSpan PermanentRetryDelay = TimeSpan.FromSeconds(300);
|
||||
|
||||
private readonly CommandClient client;
|
||||
private readonly CommandExecutor executor;
|
||||
private readonly ILogger<CommandWorker> logger;
|
||||
private readonly CollectSignal? collectSignal;
|
||||
private readonly string hostname;
|
||||
|
||||
public CommandWorker(CommandClient client, CommandExecutor executor,
|
||||
ILogger<CommandWorker> logger, CollectSignal? collectSignal = null)
|
||||
{
|
||||
this.client = client;
|
||||
this.executor = executor;
|
||||
this.logger = logger;
|
||||
this.collectSignal = collectSignal;
|
||||
hostname = Environment.MachineName;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
logger.LogInformation("Command channel started for host {Host}", hostname);
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
var claim = await client.ClaimNextAsync(hostname, stoppingToken);
|
||||
if (claim is not null)
|
||||
await RunClaimAsync(claim, stoppingToken);
|
||||
// On a 204 (no work) the server already consumed the hold
|
||||
// window, so re-poll immediately.
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (CollectorException error) when (error.IsPermanent)
|
||||
{
|
||||
logger.LogError(error,
|
||||
"Permanent command-channel error; backing off");
|
||||
await SafeDelay(PermanentRetryDelay, stoppingToken);
|
||||
}
|
||||
catch (Exception error) when (IsIdlePollClose(error))
|
||||
{
|
||||
// The server (or a proxy) closed the held-open long poll without
|
||||
// a command. That is the normal end of a hold window, not a
|
||||
// failure: log quietly at debug and re-poll immediately.
|
||||
logger.LogDebug(error, "Command poll connection closed; re-polling");
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
logger.LogWarning(error, "Command poll failed; retrying shortly");
|
||||
await SafeDelay(TransientRetryDelay, stoppingToken);
|
||||
}
|
||||
}
|
||||
|
||||
logger.LogInformation("Command channel stopped");
|
||||
}
|
||||
|
||||
private async Task RunClaimAsync(CommandClaim claim, CancellationToken token)
|
||||
{
|
||||
logger.LogInformation("Running command {Id} action={Action}",
|
||||
claim.EndpointCommandId, claim.Action);
|
||||
|
||||
CommandResult result;
|
||||
try
|
||||
{
|
||||
result = await executor.ExecuteAsync(claim, token);
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
logger.LogError(error, "Command {Id} action={Action} threw",
|
||||
claim.EndpointCommandId, claim.Action);
|
||||
result = new CommandResult
|
||||
{
|
||||
Succeeded = false,
|
||||
Message = $"Command failed: {error.Message}",
|
||||
};
|
||||
}
|
||||
|
||||
await client.ReportResultAsync(claim.EndpointCommandId, result, token);
|
||||
logger.LogInformation("Command {Id} finished succeeded={Succeeded}",
|
||||
claim.EndpointCommandId, result.Succeeded);
|
||||
|
||||
// A command changed something on the endpoint (e.g. MTConnect start/stop);
|
||||
// wake the telemetry loop to collect now so the WebUI card reflects the
|
||||
// new state within one report cycle instead of after the full cadence.
|
||||
collectSignal?.Request();
|
||||
}
|
||||
|
||||
// A long poll that the server or an intermediary closes without sending a
|
||||
// command surfaces as an HttpRequestException wrapping an HttpIOException
|
||||
// (ResponseEnded), or a bare IOException. That is the expected end of a hold
|
||||
// window, so it should not be treated as a transient failure.
|
||||
private static bool IsIdlePollClose(Exception error)
|
||||
{
|
||||
for (Exception? current = error; current is not null; current = current.InnerException)
|
||||
{
|
||||
if (current is System.Net.Http.HttpIOException httpIo
|
||||
&& httpIo.HttpRequestError == System.Net.Http.HttpRequestError.ResponseEnded)
|
||||
return true;
|
||||
if (current is System.IO.IOException)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static async Task SafeDelay(TimeSpan delay, CancellationToken token)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(delay, token);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Shutting down: let the loop condition end it.
|
||||
}
|
||||
}
|
||||
}
|
||||
92
agent/ShopDbEndpointAgent.Core/Contracts.cs
Normal file
92
agent/ShopDbEndpointAgent.Core/Contracts.cs
Normal file
@@ -0,0 +1,92 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace ShopDbEndpointAgent.Core;
|
||||
|
||||
// Wire keys MUST stay lowercase/no-underscore to match the ShopDB collector
|
||||
// schema (plugins/endpoints/AGENT-CONTRACT.md). PascalCase C# properties are
|
||||
// mapped back with JsonPropertyName so the serializer does not rename them.
|
||||
|
||||
public sealed class EndpointReport
|
||||
{
|
||||
[JsonPropertyName("hostname")] public string Hostname { get; set; } = "";
|
||||
[JsonPropertyName("adopt")] public bool? Adopt { get; set; }
|
||||
[JsonPropertyName("loggedinuser")] public string? LoggedInUser { get; set; }
|
||||
[JsonPropertyName("serialnumber")] public string? SerialNumber { get; set; }
|
||||
[JsonPropertyName("pctype")] public string? PcType { get; set; }
|
||||
[JsonPropertyName("vendorname")] public string? VendorName { get; set; }
|
||||
[JsonPropertyName("modelnumber")] public string? ModelNumber { get; set; }
|
||||
[JsonPropertyName("osname")] public string? OsName { get; set; }
|
||||
[JsonPropertyName("ipaddress")] public string? IpAddress { get; set; }
|
||||
[JsonPropertyName("machinenumber")] public string? MachineNumber { get; set; }
|
||||
[JsonPropertyName("agentversion")] public string? AgentVersion { get; set; }
|
||||
[JsonPropertyName("lastboottime")] public DateTimeOffset? LastBootTime { get; set; }
|
||||
[JsonPropertyName("uptimeseconds")] public long? UptimeSeconds { get; set; }
|
||||
[JsonPropertyName("idleseconds")] public long? IdleSeconds { get; set; }
|
||||
[JsonPropertyName("cpupercent")] public double? CpuPercent { get; set; }
|
||||
[JsonPropertyName("memorypercent")] public double? MemoryPercent { get; set; }
|
||||
[JsonPropertyName("services")] public object[]? Services { get; set; }
|
||||
[JsonPropertyName("ports")] public object[]? Ports { get; set; }
|
||||
[JsonPropertyName("interfaces")] public object[]? Interfaces { get; set; }
|
||||
[JsonPropertyName("scheduledtasks")] public object[]? ScheduledTasks { get; set; }
|
||||
[JsonPropertyName("events")] public EndpointEventRecord[]? Events { get; set; }
|
||||
}
|
||||
|
||||
public sealed class EndpointEventRecord
|
||||
{
|
||||
[JsonPropertyName("eventid")] public int EventId { get; set; }
|
||||
[JsonPropertyName("eventchannel")] public string? EventChannel { get; set; }
|
||||
[JsonPropertyName("eventsource")] public string? EventSource { get; set; }
|
||||
[JsonPropertyName("eventtime")] public DateTimeOffset? EventTime { get; set; }
|
||||
[JsonPropertyName("eventmessage")] public string? EventMessage { get; set; }
|
||||
[JsonPropertyName("fields")] public Dictionary<string, object>? Fields { get; set; }
|
||||
}
|
||||
|
||||
public sealed class CollectorResponse
|
||||
{
|
||||
[JsonPropertyName("status")] public string Status { get; set; } = "";
|
||||
[JsonPropertyName("action")] public string? Action { get; set; }
|
||||
[JsonPropertyName("assetid")] public int? AssetId { get; set; }
|
||||
[JsonPropertyName("identityvalue")] public string? IdentityValue { get; set; }
|
||||
[JsonPropertyName("warnings")] public string[]? Warnings { get; set; }
|
||||
}
|
||||
|
||||
// The collector wraps the plugin payload in the ShopDB envelope
|
||||
// { status, data: { action, assetid, identityvalue, warnings }, meta }.
|
||||
public sealed class CollectorEnvelope
|
||||
{
|
||||
[JsonPropertyName("status")] public string Status { get; set; } = "";
|
||||
[JsonPropertyName("data")] public CollectorResponse? Data { get; set; }
|
||||
}
|
||||
|
||||
// A command the server claimed for this endpoint on the long-poll. The
|
||||
// allowlisted action name crosses the wire (see AGENT-CONTRACT.md section 7);
|
||||
// generic actions (service_* / process_*) also carry the subject to act on -
|
||||
// Target is a Windows service name or a process image (for example
|
||||
// 'DncMain.exe') and TargetKind is 'service' or 'process'. Fixed-target actions
|
||||
// (mtconnect_*) leave both null. Carrying the target on the wire is what lets a
|
||||
// site build the agent once and drive every future policy/app from server data.
|
||||
public sealed class CommandClaim
|
||||
{
|
||||
[JsonPropertyName("endpointcommandid")] public int EndpointCommandId { get; set; }
|
||||
[JsonPropertyName("action")] public string Action { get; set; } = "";
|
||||
[JsonPropertyName("target")] public string? Target { get; set; }
|
||||
[JsonPropertyName("targetkind")] public string? TargetKind { get; set; }
|
||||
}
|
||||
|
||||
// Envelope for the claim long-poll: { status, data: { endpointcommandid, action } }.
|
||||
public sealed class CommandClaimEnvelope
|
||||
{
|
||||
[JsonPropertyName("status")] public string Status { get; set; } = "";
|
||||
[JsonPropertyName("data")] public CommandClaim? Data { get; set; }
|
||||
}
|
||||
|
||||
// The outcome the agent posts back after running a claimed command. When the
|
||||
// action probed MTConnect, MtConnectRunning carries the observed state so the
|
||||
// server can cache it for the WebUI toggle.
|
||||
public sealed class CommandResult
|
||||
{
|
||||
[JsonPropertyName("succeeded")] public bool Succeeded { get; set; }
|
||||
[JsonPropertyName("message")] public string? Message { get; set; }
|
||||
[JsonPropertyName("mtconnectrunning")] public bool? MtConnectRunning { get; set; }
|
||||
[JsonPropertyName("detail")] public Dictionary<string, object>? Detail { get; set; }
|
||||
}
|
||||
44
agent/ShopDbEndpointAgent.Core/CuratedEventCatalog.cs
Normal file
44
agent/ShopDbEndpointAgent.Core/CuratedEventCatalog.cs
Normal file
@@ -0,0 +1,44 @@
|
||||
namespace ShopDbEndpointAgent.Core;
|
||||
|
||||
// The curated Windows Event catalog, LOCKED to the server list in
|
||||
// plugins/endpoints/plugin.py (CURATED_EVENT_IDS) and documented in
|
||||
// plugins/endpoints/DESIGN-DECISIONS-agent.md section 4. The server stores ONLY
|
||||
// these IDs; anything else in a report's `events` is ignored (not an error).
|
||||
// Sending only these keeps the payload small and mirrors the catalog intent.
|
||||
//
|
||||
// Channels matter: an ID is read from its own log, so the collector reads
|
||||
// Security, System, and Application - not System alone. IDs are namespaced by
|
||||
// channel here because the same numeric ID can mean different things across
|
||||
// logs; we only harvest the (channel, id) pairs the server curates.
|
||||
public static class CuratedEventCatalog
|
||||
{
|
||||
public sealed record Entry(int EventId, string Channel, string Meaning);
|
||||
|
||||
public static readonly IReadOnlyList<Entry> Entries = new List<Entry>
|
||||
{
|
||||
new(4624, "Security", "Successful logon"),
|
||||
new(4625, "Security", "Failed logon"),
|
||||
new(4634, "Security", "Logoff"),
|
||||
new(4672, "Security", "Special privileges assigned to new logon"),
|
||||
new(4697, "Security", "Service installed"),
|
||||
new(4698, "Security", "Scheduled task created"),
|
||||
new(4618, "Security", "Monitored security event pattern"),
|
||||
new(41, "System", "Kernel-Power, unexpected shutdown"),
|
||||
new(55, "System", "NTFS file system corruption"),
|
||||
new(7011, "System", "Service Control Manager timeout"),
|
||||
new(10016, "System", "DCOM permission error"),
|
||||
new(1001, "Application", "Application error / bugcheck report"),
|
||||
};
|
||||
|
||||
// The distinct channels the collector must read.
|
||||
public static readonly IReadOnlyList<string> Channels =
|
||||
Entries.Select(entry => entry.Channel).Distinct().ToList();
|
||||
|
||||
// The set of curated IDs for a given channel, for a fast membership check
|
||||
// while walking that channel's log newest-first.
|
||||
public static IReadOnlySet<int> IdsForChannel(string channel)
|
||||
=> Entries.Where(entry =>
|
||||
string.Equals(entry.Channel, channel, StringComparison.OrdinalIgnoreCase))
|
||||
.Select(entry => entry.EventId)
|
||||
.ToHashSet();
|
||||
}
|
||||
489
agent/ShopDbEndpointAgent.Core/EndpointCollector.cs
Normal file
489
agent/ShopDbEndpointAgent.Core/EndpointCollector.cs
Normal file
@@ -0,0 +1,489 @@
|
||||
using System.Diagnostics;
|
||||
using System.Management;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace ShopDbEndpointAgent.Core;
|
||||
|
||||
// Pure-C# endpoint collector implementing ICollector. Mirrors the ShopDB
|
||||
// PowerShell asset-collector data set (minus the CSV emit): hostname, logged-in
|
||||
// user, serial number, pc type, boot time, monitored processes, plus a curated
|
||||
// Windows Event Log tail across the Security/System/Application channels the
|
||||
// server curates (CuratedEventCatalog). WMI/registry/event access can throw or
|
||||
// return null on a locked-down box, so every probe is defensive and never
|
||||
// aborts the report.
|
||||
public sealed class EndpointCollector : ICollector
|
||||
{
|
||||
// Max curated events harvested per channel per report, newest-first, to
|
||||
// keep the payload bounded on a chatty Security log.
|
||||
private const int MaxEventsPerChannel = 25;
|
||||
|
||||
private readonly ILogger<EndpointCollector> logger;
|
||||
private readonly AgentConfig config;
|
||||
private readonly IAgentUtilityRunner? utilities;
|
||||
|
||||
public EndpointCollector(ILogger<EndpointCollector> logger, AgentConfig config,
|
||||
IAgentUtilityRunner? utilities = null)
|
||||
{
|
||||
this.logger = logger;
|
||||
this.config = config;
|
||||
this.utilities = utilities;
|
||||
}
|
||||
|
||||
public EndpointReport Collect(string agentVersion, bool adopt)
|
||||
{
|
||||
var report = new EndpointReport
|
||||
{
|
||||
Hostname = Environment.MachineName,
|
||||
AgentVersion = agentVersion,
|
||||
Adopt = adopt ? true : null,
|
||||
LoggedInUser = GetLoggedInUser(),
|
||||
SerialNumber = GetSerialNumber(),
|
||||
PcType = GetPcType(),
|
||||
VendorName = GetVendor(),
|
||||
ModelNumber = GetModelNumber(),
|
||||
OsName = GetOsName(),
|
||||
IpAddress = GetPrimaryIpAddress(),
|
||||
MachineNumber = GetMachineNumber(),
|
||||
LastBootTime = GetLastBootTime(),
|
||||
Services = GetMonitoredTargets(),
|
||||
Interfaces = GetInterfaces(),
|
||||
Events = GetCuratedEvents(),
|
||||
};
|
||||
|
||||
if (report.LastBootTime is DateTimeOffset boot)
|
||||
report.UptimeSeconds = (long)(DateTimeOffset.UtcNow - boot).TotalSeconds;
|
||||
|
||||
return report;
|
||||
}
|
||||
|
||||
// Combined watch list: interactive processes (by image name) and Windows
|
||||
// services (by service key name). Both land in report.Services with a shared
|
||||
// {name, app, kind, state, running, count} shape so the server/UI renders
|
||||
// one list of health badges.
|
||||
private object[] GetMonitoredTargets()
|
||||
{
|
||||
var result = new List<object>();
|
||||
result.AddRange(GetMonitoredProcesses());
|
||||
result.AddRange(GetMonitoredServices());
|
||||
return result.ToArray();
|
||||
}
|
||||
|
||||
private IEnumerable<object> GetMonitoredProcesses()
|
||||
{
|
||||
var targets = config.Monitors?.Processes ?? Array.Empty<MonitorTarget>();
|
||||
if (targets.Length == 0)
|
||||
return Array.Empty<object>();
|
||||
|
||||
var running = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
||||
try
|
||||
{
|
||||
foreach (var proc in Process.GetProcesses())
|
||||
{
|
||||
string image;
|
||||
try { image = proc.ProcessName + ".exe"; }
|
||||
catch { continue; }
|
||||
finally { proc.Dispose(); }
|
||||
|
||||
if (Array.FindIndex(targets, t =>
|
||||
string.Equals(t.Name, image, StringComparison.OrdinalIgnoreCase)) < 0)
|
||||
continue;
|
||||
|
||||
running.TryGetValue(image, out int count);
|
||||
running[image] = count + 1;
|
||||
}
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
logger.LogDebug(error, "Process enumeration blocked");
|
||||
}
|
||||
|
||||
var result = new List<object>(targets.Length);
|
||||
foreach (var target in targets)
|
||||
{
|
||||
bool found = running.TryGetValue(target.Name, out int count);
|
||||
result.Add(new
|
||||
{
|
||||
name = target.Name,
|
||||
app = string.IsNullOrWhiteSpace(target.App) ? target.Name : target.App,
|
||||
kind = "process",
|
||||
// state string so the UI badge can color it; missing = unknown.
|
||||
state = found ? "running" : "stopped",
|
||||
running = found,
|
||||
count = found ? count : 0,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Windows services watched by service key name. Reports the reported state
|
||||
// (running/stopped/etc.) from Win32_Service so the server can flag a
|
||||
// collector service (MTConnect and the like) that has stopped or gone
|
||||
// missing. Never aborts: a locked-down WMI hive leaves entries "unknown".
|
||||
private IEnumerable<object> GetMonitoredServices()
|
||||
{
|
||||
var targets = config.Monitors?.Services ?? Array.Empty<MonitorTarget>();
|
||||
if (targets.Length == 0)
|
||||
return Array.Empty<object>();
|
||||
|
||||
var states = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
try
|
||||
{
|
||||
using var searcher = new ManagementObjectSearcher(
|
||||
"SELECT Name, DisplayName, State FROM Win32_Service");
|
||||
foreach (var mo in searcher.Get())
|
||||
{
|
||||
var state = mo["State"]?.ToString()?.Trim() ?? "";
|
||||
// Index by both service key name and display name so a config
|
||||
// that used either one still matches.
|
||||
var name = mo["Name"]?.ToString();
|
||||
if (!string.IsNullOrWhiteSpace(name))
|
||||
states[name.Trim()] = state;
|
||||
var display = mo["DisplayName"]?.ToString();
|
||||
if (!string.IsNullOrWhiteSpace(display))
|
||||
states[display.Trim()] = state;
|
||||
}
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
logger.LogDebug(error, "Service enumeration blocked");
|
||||
}
|
||||
|
||||
var result = new List<object>(targets.Length);
|
||||
foreach (var target in targets)
|
||||
{
|
||||
bool found = states.TryGetValue(target.Name, out var raw);
|
||||
bool running = string.Equals(raw, "Running", StringComparison.OrdinalIgnoreCase);
|
||||
result.Add(new
|
||||
{
|
||||
name = target.Name,
|
||||
app = string.IsNullOrWhiteSpace(target.App) ? target.Name : target.App,
|
||||
kind = "service",
|
||||
// "unknown" = not installed / WMI unreadable; else lowercased
|
||||
// Win32_Service state (running, stopped, start pending, ...).
|
||||
state = found ? (raw ?? "").ToLowerInvariant() : "unknown",
|
||||
running,
|
||||
count = running ? 1 : 0,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private string? GetLoggedInUser()
|
||||
{
|
||||
var user = QueryFirst("SELECT UserName FROM Win32_ComputerSystem", "UserName");
|
||||
return string.IsNullOrWhiteSpace(user)
|
||||
? $"{Environment.UserDomainName}\\{Environment.UserName}"
|
||||
: user;
|
||||
}
|
||||
|
||||
private string? GetSerialNumber()
|
||||
=> QueryFirst("SELECT SerialNumber FROM Win32_BIOS", "SerialNumber");
|
||||
|
||||
private string? GetPcType()
|
||||
{
|
||||
// Raw WMI model; the ShopDB pctype map normalizes it server-side, so the
|
||||
// current site's catalog stays the single source of that mapping.
|
||||
var model = QueryFirst("SELECT Model FROM Win32_ComputerSystem", "Model");
|
||||
return string.IsNullOrWhiteSpace(model) ? null : model;
|
||||
}
|
||||
|
||||
// Manufacturer for the Asset vendor/model rails (Win32_ComputerSystem
|
||||
// Manufacturer, for example "Dell Inc.", "HP"). Server upserts Vendor.
|
||||
private string? GetVendor()
|
||||
=> QueryFirst("SELECT Manufacturer FROM Win32_ComputerSystem", "Manufacturer");
|
||||
|
||||
// Hardware model number. Prefer the human-readable Win32_ComputerSystem
|
||||
// Model (for example "OptiPlex 7090", "Latitude 5540") - the same value
|
||||
// pctype classifies on. SystemSKUNumber is an OEM SKU code (for example
|
||||
// Dell's "0BCC"), not a model name, so it is only a last resort when Model
|
||||
// is blank.
|
||||
private string? GetModelNumber()
|
||||
{
|
||||
var model = QueryFirst("SELECT Model FROM Win32_ComputerSystem", "Model");
|
||||
if (!string.IsNullOrWhiteSpace(model))
|
||||
return model;
|
||||
return QueryFirst("SELECT SystemSKUNumber FROM Win32_ComputerSystem",
|
||||
"SystemSKUNumber");
|
||||
}
|
||||
|
||||
// OS caption, for example "Microsoft Windows 10 Enterprise". The server
|
||||
// matches this against its controlled OperatingSystem vocab (warns if the
|
||||
// exact string is unknown), so it may need a catalog entry to bind.
|
||||
private string? GetOsName()
|
||||
=> QueryFirst("SELECT Caption FROM Win32_OperatingSystem", "Caption");
|
||||
|
||||
// Primary IPv4 address of the first enabled, non-loopback adapter with a
|
||||
// default gateway. Feeds the Communication (IP) row server-side.
|
||||
private string? GetPrimaryIpAddress()
|
||||
{
|
||||
try
|
||||
{
|
||||
using var searcher = new ManagementObjectSearcher(
|
||||
"SELECT IPAddress, DefaultIPGateway, IPEnabled " +
|
||||
"FROM Win32_NetworkAdapterConfiguration WHERE IPEnabled = True");
|
||||
string? firstEnabled = null;
|
||||
foreach (var mo in searcher.Get())
|
||||
{
|
||||
var addresses = mo["IPAddress"] as string[];
|
||||
if (addresses is null)
|
||||
continue;
|
||||
var ipv4 = Array.Find(addresses, a =>
|
||||
!string.IsNullOrWhiteSpace(a) && a.IndexOf(':') < 0);
|
||||
if (string.IsNullOrWhiteSpace(ipv4))
|
||||
continue;
|
||||
|
||||
firstEnabled ??= ipv4;
|
||||
// Prefer an adapter that actually has a default gateway (the
|
||||
// real LAN NIC, not a virtual/VPN interface).
|
||||
if (mo["DefaultIPGateway"] is string[] gw && gw.Length > 0)
|
||||
return ipv4;
|
||||
}
|
||||
return firstEnabled;
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
logger.LogDebug(error, "IP address query failed");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// All hardware NICs, not just the primary. DESIGN-DECISIONS-agent.md 9.3:
|
||||
// upwards of two-thirds of shop-floor PCs carry a SECOND, static, gatewayless
|
||||
// NIC whose only job is to talk to the machine's onboard IPS. That interface
|
||||
// is the physical PC-to-machine link and is exactly the one a single
|
||||
// primary-IP probe throws away, so we report every enabled adapter with its
|
||||
// alias/ipv4/mac/gateway/subnet and let the server keep the whole picture.
|
||||
// Stored verbatim as JSON server-side (AGENT-CONTRACT.md), so keys are
|
||||
// lowercase/no-underscore.
|
||||
private object[] GetInterfaces()
|
||||
{
|
||||
var result = new List<object>();
|
||||
try
|
||||
{
|
||||
using var searcher = new ManagementObjectSearcher(
|
||||
"SELECT Description, IPAddress, IPSubnet, DefaultIPGateway, " +
|
||||
"MACAddress, DHCPEnabled FROM Win32_NetworkAdapterConfiguration " +
|
||||
"WHERE IPEnabled = True");
|
||||
foreach (var mo in searcher.Get())
|
||||
{
|
||||
var mac = mo["MACAddress"] as string;
|
||||
// No MAC = not a real hardware adapter (some tunnels/virtuals).
|
||||
if (string.IsNullOrWhiteSpace(mac))
|
||||
continue;
|
||||
|
||||
var addresses = mo["IPAddress"] as string[];
|
||||
var ipv4 = addresses is null ? null : Array.Find(addresses,
|
||||
a => !string.IsNullOrWhiteSpace(a) && a.IndexOf(':') < 0);
|
||||
|
||||
var subnets = mo["IPSubnet"] as string[];
|
||||
var subnet = subnets is null ? null : Array.Find(subnets,
|
||||
s => !string.IsNullOrWhiteSpace(s) && s.IndexOf(':') < 0);
|
||||
|
||||
var gateways = mo["DefaultIPGateway"] as string[];
|
||||
var gateway = gateways is null || gateways.Length == 0
|
||||
? null : gateways[0];
|
||||
|
||||
result.Add(new
|
||||
{
|
||||
alias = mo["Description"]?.ToString()?.Trim() ?? "",
|
||||
ipv4 = ipv4 ?? "",
|
||||
mac = mac.Trim(),
|
||||
// A gatewayless NIC is the likely machine-IPS link; the
|
||||
// gateway NIC is the corporate LAN. Server/UI can tell them
|
||||
// apart from these two fields.
|
||||
gateway = gateway ?? "",
|
||||
subnet = subnet ?? "",
|
||||
dhcp = mo["DHCPEnabled"] as bool? ?? false,
|
||||
});
|
||||
}
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
logger.LogDebug(error, "Interface enumeration blocked");
|
||||
}
|
||||
return result.ToArray();
|
||||
}
|
||||
|
||||
// The four-digit ShopDB Machine Number, discovered from on-box app config.
|
||||
// DESIGN-DECISIONS-agent.md 9.4/9.5: when eDNC or UDC is installed its config
|
||||
// carries the number that binds this PC to a ShopDB machines row. Source
|
||||
// priority: eDNC live registry first, then UDC settings JSON. "" or "9999"
|
||||
// means none (AGENT-CONTRACT.md). Never throws out to the caller.
|
||||
private string? GetMachineNumber()
|
||||
{
|
||||
var fromRegistry = GetMachineNumberFromEdncRegistry();
|
||||
if (IsUsableMachineNumber(fromRegistry))
|
||||
return fromRegistry!.Trim();
|
||||
|
||||
var fromUdc = GetMachineNumberFromUdcSettings();
|
||||
if (IsUsableMachineNumber(fromUdc))
|
||||
return fromUdc!.Trim();
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool IsUsableMachineNumber(string? value)
|
||||
=> !string.IsNullOrWhiteSpace(value) && value.Trim() != "9999";
|
||||
|
||||
// eDNC writes MachineNo under the GE Aircraft Engines\DNC\General key. Try
|
||||
// the 64-bit view then the 32-bit (WOW6432Node) view, mirroring
|
||||
// AgentConfigProvider so a locked-down or 32-bit-installed box still reads.
|
||||
private string? GetMachineNumberFromEdncRegistry()
|
||||
{
|
||||
const string subKey = @"SOFTWARE\GE Aircraft Engines\DNC\General";
|
||||
RegistryView[] views = { RegistryView.Registry64, RegistryView.Registry32 };
|
||||
foreach (var view in views)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var machine = RegistryKey.OpenBaseKey(
|
||||
RegistryHive.LocalMachine, view);
|
||||
using var key = machine.OpenSubKey(subKey);
|
||||
var value = key?.GetValue("MachineNo")?.ToString();
|
||||
if (IsUsableMachineNumber(value))
|
||||
return value;
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
logger.LogDebug(error,
|
||||
"eDNC registry read blocked in {View} view", view);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// UDC stores GeneralSettings.MachineNumber in udc_settings_*.json under
|
||||
// %ProgramData%\udc. Scan for the newest settings file and read the number.
|
||||
private string? GetMachineNumberFromUdcSettings()
|
||||
{
|
||||
try
|
||||
{
|
||||
var root = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
|
||||
"udc");
|
||||
if (!Directory.Exists(root))
|
||||
return null;
|
||||
|
||||
var files = Directory.EnumerateFiles(
|
||||
root, "udc_settings*.json", SearchOption.AllDirectories)
|
||||
.OrderByDescending(File.GetLastWriteTimeUtc);
|
||||
|
||||
foreach (var file in files)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(File.ReadAllText(file));
|
||||
if (doc.RootElement.TryGetProperty("GeneralSettings", out var general)
|
||||
&& general.TryGetProperty("MachineNumber", out var number))
|
||||
{
|
||||
var value = number.ValueKind == JsonValueKind.String
|
||||
? number.GetString()
|
||||
: number.ToString();
|
||||
if (IsUsableMachineNumber(value))
|
||||
return value;
|
||||
}
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
logger.LogDebug(error, "UDC settings parse failed: {File}", file);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
logger.LogDebug(error, "UDC settings scan failed");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private DateTimeOffset? GetLastBootTime()
|
||||
{
|
||||
var raw = QueryFirst(
|
||||
"SELECT LastBootUpTime FROM Win32_OperatingSystem", "LastBootUpTime");
|
||||
if (string.IsNullOrWhiteSpace(raw))
|
||||
return null;
|
||||
try
|
||||
{
|
||||
return ManagementDateTimeConverter.ToDateTime(raw).ToUniversalTime();
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
logger.LogDebug(error, "LastBootUpTime parse failed");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Walk each curated channel newest-first, keep only the curated IDs for
|
||||
// that channel, cap per channel. Reads Security/System/Application - the
|
||||
// old scaffold read System only and filtered by the wrong IDs.
|
||||
private EndpointEventRecord[] GetCuratedEvents()
|
||||
{
|
||||
var records = new List<EndpointEventRecord>();
|
||||
foreach (var channel in CuratedEventCatalog.Channels)
|
||||
{
|
||||
var wanted = CuratedEventCatalog.IdsForChannel(channel);
|
||||
try
|
||||
{
|
||||
using var log = new EventLog(channel);
|
||||
int kept = 0;
|
||||
for (int i = log.Entries.Count - 1;
|
||||
i >= 0 && kept < MaxEventsPerChannel; i--)
|
||||
{
|
||||
EventLogEntry entry;
|
||||
try { entry = log.Entries[i]; } catch { continue; }
|
||||
|
||||
int id = (int)(entry.InstanceId & 0xFFFF);
|
||||
if (!wanted.Contains(id))
|
||||
continue;
|
||||
|
||||
records.Add(new EndpointEventRecord
|
||||
{
|
||||
EventId = id,
|
||||
EventChannel = channel,
|
||||
EventSource = entry.Source,
|
||||
EventTime = new DateTimeOffset(
|
||||
entry.TimeGenerated.ToUniversalTime(), TimeSpan.Zero),
|
||||
EventMessage = Truncate(entry.Message, 1000),
|
||||
});
|
||||
kept++;
|
||||
}
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
// Channel unreadable (Security needs elevation; a log may be
|
||||
// off): log and continue with the other channels.
|
||||
logger.LogDebug(error, "Event channel {Channel} unreadable", channel);
|
||||
}
|
||||
}
|
||||
return records.ToArray();
|
||||
}
|
||||
|
||||
private string? QueryFirst(string wql, string property)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var searcher = new ManagementObjectSearcher(wql);
|
||||
foreach (var mo in searcher.Get())
|
||||
{
|
||||
var value = mo[property]?.ToString();
|
||||
if (!string.IsNullOrWhiteSpace(value))
|
||||
return value.Trim();
|
||||
}
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
logger.LogDebug(error, "WMI query failed: {Query}", wql);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string Truncate(string? text, int max)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text))
|
||||
return "";
|
||||
text = text.Trim();
|
||||
return text.Length <= max ? text : text[..max];
|
||||
}
|
||||
}
|
||||
164
agent/ShopDbEndpointAgent.Core/FileLoggerProvider.cs
Normal file
164
agent/ShopDbEndpointAgent.Core/FileLoggerProvider.cs
Normal file
@@ -0,0 +1,164 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ShopDbEndpointAgent.Core;
|
||||
|
||||
// A dependency-free rolling-file ILoggerProvider. The runtime code only ever
|
||||
// talks to ILogger, so swapping or adding sinks later (a ShopDB HTTP sink, Seq,
|
||||
// syslog) is a host-builder change with zero worker changes. This provider
|
||||
// writes one line per entry to a daily file under the configured log directory
|
||||
// and prunes files older than the retention window.
|
||||
public sealed class FileLoggerProvider : ILoggerProvider
|
||||
{
|
||||
private readonly string directory;
|
||||
private readonly LogLevel minLevel;
|
||||
private readonly int retentionDays;
|
||||
private readonly object gate = new();
|
||||
private readonly ConcurrentDictionary<string, FileLogger> loggers = new();
|
||||
private string currentDateStamp = "";
|
||||
private StreamWriter? writer;
|
||||
|
||||
public FileLoggerProvider(string directory, LogLevel minLevel = LogLevel.Information,
|
||||
int retentionDays = 14)
|
||||
{
|
||||
this.directory = directory;
|
||||
this.minLevel = minLevel;
|
||||
this.retentionDays = retentionDays;
|
||||
// Best-effort: creating the log directory can fail on a locked-down host
|
||||
// or when a non-privileged user targets a SYSTEM-owned path. Never let
|
||||
// that abort construction; Write() degrades to a no-op if the sink is
|
||||
// unusable.
|
||||
try { Directory.CreateDirectory(directory); }
|
||||
catch (Exception error) when (error is IOException or UnauthorizedAccessException) { }
|
||||
}
|
||||
|
||||
// The default log directory when config leaves LogPath empty:
|
||||
// %ProgramData%\ShopDB\EndpointAgent\logs
|
||||
public static string DefaultDirectory()
|
||||
=> Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
|
||||
"ShopDB", "EndpointAgent", "logs");
|
||||
|
||||
public ILogger CreateLogger(string categoryName)
|
||||
=> loggers.GetOrAdd(categoryName, name => new FileLogger(this, name));
|
||||
|
||||
internal bool IsEnabled(LogLevel level) => level >= minLevel && level != LogLevel.None;
|
||||
|
||||
internal void Write(string category, LogLevel level, string message, Exception? error)
|
||||
{
|
||||
var now = DateTimeOffset.Now;
|
||||
var line = new StringBuilder()
|
||||
.Append(now.ToString("yyyy-MM-dd HH:mm:ss.fff zzz"))
|
||||
.Append(" [").Append(LevelTag(level)).Append("] ")
|
||||
.Append(ShortCategory(category)).Append(": ")
|
||||
.Append(message);
|
||||
if (error is not null)
|
||||
line.Append(Environment.NewLine).Append(error);
|
||||
|
||||
lock (gate)
|
||||
{
|
||||
// Logging must never take down the agent. If the log file cannot be
|
||||
// opened or written (for example a non-privileged user running the
|
||||
// exe against a logs directory owned by the SYSTEM service, which
|
||||
// yields UnauthorizedAccessException on open), swallow it and drop
|
||||
// the line rather than letting it unwind through bootstrap. A failed
|
||||
// sink is a degraded state, not a fatal one.
|
||||
try
|
||||
{
|
||||
RollIfNeeded(now);
|
||||
writer?.WriteLine(line.ToString());
|
||||
}
|
||||
catch (Exception sinkError) when (sinkError is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
writer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void RollIfNeeded(DateTimeOffset now)
|
||||
{
|
||||
var stamp = now.ToString("yyyyMMdd");
|
||||
if (stamp == currentDateStamp && writer is not null)
|
||||
return;
|
||||
|
||||
writer?.Flush();
|
||||
writer?.Dispose();
|
||||
currentDateStamp = stamp;
|
||||
var path = Path.Combine(directory, $"agent-{stamp}.log");
|
||||
writer = new StreamWriter(new FileStream(
|
||||
path, FileMode.Append, FileAccess.Write, FileShare.ReadWrite))
|
||||
{
|
||||
AutoFlush = true,
|
||||
};
|
||||
PruneOldFiles();
|
||||
}
|
||||
|
||||
private void PruneOldFiles()
|
||||
{
|
||||
try
|
||||
{
|
||||
var cutoff = DateTime.Now.AddDays(-retentionDays);
|
||||
foreach (var file in Directory.EnumerateFiles(directory, "agent-*.log"))
|
||||
{
|
||||
if (File.GetLastWriteTime(file) < cutoff)
|
||||
File.Delete(file);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Pruning is best-effort; never let housekeeping break logging.
|
||||
}
|
||||
}
|
||||
|
||||
private static string LevelTag(LogLevel level) => level switch
|
||||
{
|
||||
LogLevel.Trace => "TRC",
|
||||
LogLevel.Debug => "DBG",
|
||||
LogLevel.Information => "INF",
|
||||
LogLevel.Warning => "WRN",
|
||||
LogLevel.Error => "ERR",
|
||||
LogLevel.Critical => "CRT",
|
||||
_ => "---",
|
||||
};
|
||||
|
||||
private static string ShortCategory(string category)
|
||||
{
|
||||
var dot = category.LastIndexOf('.');
|
||||
return dot >= 0 && dot < category.Length - 1 ? category[(dot + 1)..] : category;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
writer?.Flush();
|
||||
writer?.Dispose();
|
||||
writer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FileLogger : ILogger
|
||||
{
|
||||
private readonly FileLoggerProvider provider;
|
||||
private readonly string category;
|
||||
|
||||
public FileLogger(FileLoggerProvider provider, string category)
|
||||
{
|
||||
this.provider = provider;
|
||||
this.category = category;
|
||||
}
|
||||
|
||||
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
|
||||
|
||||
public bool IsEnabled(LogLevel logLevel) => provider.IsEnabled(logLevel);
|
||||
|
||||
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state,
|
||||
Exception? exception, Func<TState, Exception?, string> formatter)
|
||||
{
|
||||
if (!IsEnabled(logLevel))
|
||||
return;
|
||||
provider.Write(category, logLevel, formatter(state, exception), exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
24
agent/ShopDbEndpointAgent.Core/ICollector.cs
Normal file
24
agent/ShopDbEndpointAgent.Core/ICollector.cs
Normal file
@@ -0,0 +1,24 @@
|
||||
namespace ShopDbEndpointAgent.Core;
|
||||
|
||||
// The collection surface. The runtime worker depends only on this, so the
|
||||
// concrete implementation (pure C# today, possibly C# + PowerShell utilities
|
||||
// later) can change without touching the worker or the HTTP client.
|
||||
public interface ICollector
|
||||
{
|
||||
EndpointReport Collect(string agentVersion, bool adopt);
|
||||
}
|
||||
|
||||
// The documented seam for site-provided PowerShell "agent utilities". The
|
||||
// agent configuration settings page (server-side, future) lets site staff load
|
||||
// their own .ps1 scripts as utilities; the runtime discovers them under the
|
||||
// configured utilities directory and merges their JSON output into the report.
|
||||
//
|
||||
// This iteration ships the interface and a discovery contract only - the
|
||||
// concrete runner is added when the settings page can push utilities down.
|
||||
public interface IAgentUtilityRunner
|
||||
{
|
||||
// Run every discovered utility and return a map of utility name -> parsed
|
||||
// JSON result, to be attached to the report. Never throws: a failing
|
||||
// utility is logged and skipped so it cannot break the telemetry run.
|
||||
IReadOnlyDictionary<string, object> RunAll(CancellationToken token);
|
||||
}
|
||||
200
agent/ShopDbEndpointAgent.Core/InteractiveProcessLauncher.cs
Normal file
200
agent/ShopDbEndpointAgent.Core/InteractiveProcessLauncher.cs
Normal file
@@ -0,0 +1,200 @@
|
||||
using System.ComponentModel;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace ShopDbEndpointAgent.Core;
|
||||
|
||||
// Launches a program in the ACTIVE CONSOLE USER'S session at that user's
|
||||
// integrity, from the agent's LocalSystem service (session 0).
|
||||
//
|
||||
// Why this exists: the agent runs as a session-0 service. A plain
|
||||
// Process.Start() there launches GUI apps (DncMain.exe, UDC.exe) onto the hidden
|
||||
// session-0 desktop, so the logged-in operator never sees a window even though
|
||||
// the process is running. process_start needs the app to appear on the user's
|
||||
// desktop, so we obtain the interactive user's token and CreateProcessAsUser.
|
||||
//
|
||||
// Elevation is deliberately NOT handled here: the process starts at the user's
|
||||
// normal (medium) integrity and CyberArk/EPM brokers any elevation afterward.
|
||||
//
|
||||
// Only process_start uses this. Stop/status/service actions stay as SYSTEM.
|
||||
public static class InteractiveProcessLauncher
|
||||
{
|
||||
public readonly record struct LaunchOutcome(bool Launched, string? Error);
|
||||
|
||||
private const int TokenDuplicate = 0x0002;
|
||||
private const int TokenQuery = 0x0008;
|
||||
private const int TokenAssignPrimary = 0x0001;
|
||||
private const int TokenAdjustPrivileges = 0x0020;
|
||||
private const int TokenAdjustDefault = 0x0080;
|
||||
private const int TokenAdjustSessionId = 0x0100;
|
||||
|
||||
private const uint CreateUnicodeEnvironment = 0x00000400;
|
||||
private const uint CreateNoWindow = 0x08000000;
|
||||
|
||||
private const uint InvalidSessionId = 0xFFFFFFFF;
|
||||
|
||||
// Returns Launched=true when CreateProcessAsUser succeeds. Never throws;
|
||||
// failures come back as Launched=false with a descriptive Error so the
|
||||
// caller can surface a clean CommandResult.
|
||||
public static LaunchOutcome LaunchInActiveSession(
|
||||
string applicationPath, string? workingDirectory)
|
||||
{
|
||||
var sessionId = WTSGetActiveConsoleSessionId();
|
||||
if (sessionId == InvalidSessionId)
|
||||
return new LaunchOutcome(false,
|
||||
"No active console session (no interactive user is signed in).");
|
||||
|
||||
if (!WTSQueryUserToken(sessionId, out var userToken))
|
||||
return new LaunchOutcome(false,
|
||||
"Could not obtain the interactive user token (WTSQueryUserToken failed): "
|
||||
+ new Win32Exception(Marshal.GetLastWin32Error()).Message);
|
||||
|
||||
var duplicatedToken = IntPtr.Zero;
|
||||
var environmentBlock = IntPtr.Zero;
|
||||
try
|
||||
{
|
||||
var desiredAccess = TokenAssignPrimary | TokenDuplicate | TokenQuery
|
||||
| TokenAdjustPrivileges | TokenAdjustDefault | TokenAdjustSessionId;
|
||||
if (!DuplicateTokenEx(userToken, (uint)desiredAccess, IntPtr.Zero,
|
||||
SecurityImpersonationLevel.SecurityImpersonation,
|
||||
TokenType.TokenPrimary, out duplicatedToken))
|
||||
{
|
||||
return new LaunchOutcome(false,
|
||||
"Could not duplicate the user token (DuplicateTokenEx failed): "
|
||||
+ new Win32Exception(Marshal.GetLastWin32Error()).Message);
|
||||
}
|
||||
|
||||
// A per-user environment block so the launched app sees the user's
|
||||
// profile variables rather than SYSTEM's.
|
||||
if (!CreateEnvironmentBlock(out environmentBlock, duplicatedToken, false))
|
||||
environmentBlock = IntPtr.Zero;
|
||||
|
||||
var startupInfo = new STARTUPINFO();
|
||||
startupInfo.cb = Marshal.SizeOf(startupInfo);
|
||||
// Target the interactive desktop so the window is visible.
|
||||
startupInfo.lpDesktop = @"winsta0\default";
|
||||
|
||||
var creationFlags = CreateUnicodeEnvironment | CreateNoWindow;
|
||||
|
||||
var launched = CreateProcessAsUser(
|
||||
duplicatedToken,
|
||||
applicationPath,
|
||||
null,
|
||||
IntPtr.Zero,
|
||||
IntPtr.Zero,
|
||||
false,
|
||||
creationFlags,
|
||||
environmentBlock,
|
||||
workingDirectory,
|
||||
ref startupInfo,
|
||||
out var processInformation);
|
||||
|
||||
if (!launched)
|
||||
{
|
||||
return new LaunchOutcome(false,
|
||||
"CreateProcessAsUser failed: "
|
||||
+ new Win32Exception(Marshal.GetLastWin32Error()).Message);
|
||||
}
|
||||
|
||||
if (processInformation.hProcess != IntPtr.Zero)
|
||||
CloseHandle(processInformation.hProcess);
|
||||
if (processInformation.hThread != IntPtr.Zero)
|
||||
CloseHandle(processInformation.hThread);
|
||||
|
||||
return new LaunchOutcome(true, null);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (environmentBlock != IntPtr.Zero)
|
||||
DestroyEnvironmentBlock(environmentBlock);
|
||||
if (duplicatedToken != IntPtr.Zero)
|
||||
CloseHandle(duplicatedToken);
|
||||
if (userToken != IntPtr.Zero)
|
||||
CloseHandle(userToken);
|
||||
}
|
||||
}
|
||||
|
||||
private enum SecurityImpersonationLevel
|
||||
{
|
||||
SecurityAnonymous,
|
||||
SecurityIdentification,
|
||||
SecurityImpersonation,
|
||||
SecurityDelegation,
|
||||
}
|
||||
|
||||
private enum TokenType
|
||||
{
|
||||
TokenPrimary = 1,
|
||||
TokenImpersonation,
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct PROCESS_INFORMATION
|
||||
{
|
||||
public IntPtr hProcess;
|
||||
public IntPtr hThread;
|
||||
public uint dwProcessId;
|
||||
public uint dwThreadId;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
|
||||
private struct STARTUPINFO
|
||||
{
|
||||
public int cb;
|
||||
public string? lpReserved;
|
||||
public string? lpDesktop;
|
||||
public string? lpTitle;
|
||||
public uint dwX;
|
||||
public uint dwY;
|
||||
public uint dwXSize;
|
||||
public uint dwYSize;
|
||||
public uint dwXCountChars;
|
||||
public uint dwYCountChars;
|
||||
public uint dwFillAttribute;
|
||||
public uint dwFlags;
|
||||
public ushort wShowWindow;
|
||||
public ushort cbReserved2;
|
||||
public IntPtr lpReserved2;
|
||||
public IntPtr hStdInput;
|
||||
public IntPtr hStdOutput;
|
||||
public IntPtr hStdError;
|
||||
}
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
private static extern uint WTSGetActiveConsoleSessionId();
|
||||
|
||||
[DllImport("wtsapi32.dll", SetLastError = true)]
|
||||
private static extern bool WTSQueryUserToken(uint sessionId, out IntPtr token);
|
||||
|
||||
[DllImport("advapi32.dll", SetLastError = true)]
|
||||
private static extern bool DuplicateTokenEx(
|
||||
IntPtr existingToken,
|
||||
uint desiredAccess,
|
||||
IntPtr tokenAttributes,
|
||||
SecurityImpersonationLevel impersonationLevel,
|
||||
TokenType tokenType,
|
||||
out IntPtr newToken);
|
||||
|
||||
[DllImport("userenv.dll", SetLastError = true)]
|
||||
private static extern bool CreateEnvironmentBlock(
|
||||
out IntPtr environment, IntPtr token, bool inherit);
|
||||
|
||||
[DllImport("userenv.dll", SetLastError = true)]
|
||||
private static extern bool DestroyEnvironmentBlock(IntPtr environment);
|
||||
|
||||
[DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
|
||||
private static extern bool CreateProcessAsUser(
|
||||
IntPtr token,
|
||||
string? applicationName,
|
||||
string? commandLine,
|
||||
IntPtr processAttributes,
|
||||
IntPtr threadAttributes,
|
||||
bool inheritHandles,
|
||||
uint creationFlags,
|
||||
IntPtr environment,
|
||||
string? currentDirectory,
|
||||
ref STARTUPINFO startupInfo,
|
||||
out PROCESS_INFORMATION processInformation);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
private static extern bool CloseHandle(IntPtr handle);
|
||||
}
|
||||
114
agent/ShopDbEndpointAgent.Core/PowerShellUtilityRunner.cs
Normal file
114
agent/ShopDbEndpointAgent.Core/PowerShellUtilityRunner.cs
Normal file
@@ -0,0 +1,114 @@
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ShopDbEndpointAgent.Core;
|
||||
|
||||
// Site-provided PowerShell "agent utilities" runner. Discovers *.ps1 under the
|
||||
// configured utilities directory and runs each with the OS powershell.exe,
|
||||
// parsing its stdout as JSON. This is the documented collection seam: the
|
||||
// future agent-configuration settings page lets site staff push their own
|
||||
// scripts down, and their JSON output is merged into the report under
|
||||
// `scheduledtasks`-adjacent extension keys without the runtime knowing what any
|
||||
// script means.
|
||||
//
|
||||
// Safety posture for this iteration:
|
||||
// - Disabled unless a utilities directory is configured AND exists.
|
||||
// - Each script is bounded by a timeout and its failure is isolated.
|
||||
// - Output must be a single JSON value; non-JSON output is logged and dropped.
|
||||
// Script SIGNING/allow-listing is a rollout concern (code signing + Intune) and
|
||||
// is called out in the design doc; do not enable this in production until that
|
||||
// is in place.
|
||||
public sealed class PowerShellUtilityRunner : IAgentUtilityRunner
|
||||
{
|
||||
private readonly string utilitiesPath;
|
||||
private readonly ILogger<PowerShellUtilityRunner> logger;
|
||||
private readonly TimeSpan perScriptTimeout;
|
||||
|
||||
public PowerShellUtilityRunner(string utilitiesPath,
|
||||
ILogger<PowerShellUtilityRunner> logger, TimeSpan? perScriptTimeout = null)
|
||||
{
|
||||
this.utilitiesPath = utilitiesPath;
|
||||
this.logger = logger;
|
||||
this.perScriptTimeout = perScriptTimeout ?? TimeSpan.FromSeconds(30);
|
||||
}
|
||||
|
||||
public IReadOnlyDictionary<string, object> RunAll(CancellationToken token)
|
||||
{
|
||||
var results = new Dictionary<string, object>();
|
||||
if (string.IsNullOrWhiteSpace(utilitiesPath) || !Directory.Exists(utilitiesPath))
|
||||
return results;
|
||||
|
||||
foreach (var script in Directory.EnumerateFiles(utilitiesPath, "*.ps1"))
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var name = Path.GetFileNameWithoutExtension(script);
|
||||
try
|
||||
{
|
||||
var parsed = RunOne(script, token);
|
||||
if (parsed is not null)
|
||||
results[name] = parsed;
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
logger.LogWarning(error, "Agent utility {Utility} failed", name);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
private object? RunOne(string scriptPath, CancellationToken token)
|
||||
{
|
||||
var start = new ProcessStartInfo
|
||||
{
|
||||
FileName = "powershell.exe",
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
};
|
||||
start.ArgumentList.Add("-NoProfile");
|
||||
start.ArgumentList.Add("-NonInteractive");
|
||||
start.ArgumentList.Add("-ExecutionPolicy");
|
||||
start.ArgumentList.Add("Bypass");
|
||||
start.ArgumentList.Add("-File");
|
||||
start.ArgumentList.Add(scriptPath);
|
||||
|
||||
using var process = new Process { StartInfo = start };
|
||||
if (!process.Start())
|
||||
return null;
|
||||
|
||||
var stdout = process.StandardOutput.ReadToEnd();
|
||||
var stderr = process.StandardError.ReadToEnd();
|
||||
|
||||
if (!process.WaitForExit((int)perScriptTimeout.TotalMilliseconds))
|
||||
{
|
||||
try { process.Kill(entireProcessTree: true); } catch { }
|
||||
logger.LogWarning("Agent utility {Script} timed out", scriptPath);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (process.ExitCode != 0)
|
||||
{
|
||||
logger.LogWarning("Agent utility {Script} exit {Code}: {Error}",
|
||||
scriptPath, process.ExitCode, stderr.Trim());
|
||||
return null;
|
||||
}
|
||||
|
||||
stdout = stdout.Trim();
|
||||
if (stdout.Length == 0)
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
using var document = JsonDocument.Parse(stdout);
|
||||
return JsonSerializer.Deserialize<object>(stdout);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
logger.LogWarning("Agent utility {Script} produced non-JSON output",
|
||||
scriptPath);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0-windows</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>ShopDbEndpointAgent.Core</RootNamespace>
|
||||
<AssemblyName>ShopDbEndpointAgent.Core</AssemblyName>
|
||||
<InvariantGlobalization>true</InvariantGlobalization>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Win32.Registry" Version="5.0.0" />
|
||||
<PackageReference Include="System.Management" Version="8.0.0" />
|
||||
<PackageReference Include="System.Diagnostics.EventLog" Version="8.0.0" />
|
||||
<PackageReference Include="System.ServiceProcess.ServiceController" Version="8.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
129
agent/ShopDbEndpointAgent.Core/TelemetryWorker.cs
Normal file
129
agent/ShopDbEndpointAgent.Core/TelemetryWorker.cs
Normal file
@@ -0,0 +1,129 @@
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ShopDbEndpointAgent.Core;
|
||||
|
||||
// The telemetry loop as a hosted BackgroundService. On first contact it sends
|
||||
// adopt=true (once) so the server's computers collector creates the PC; every
|
||||
// run after that omits adopt and relies on strict hostname resolution. Between
|
||||
// runs it waits the configured cadence plus/minus jitter so a fleet does not
|
||||
// report in lockstep (thundering herd).
|
||||
//
|
||||
// The worker never lets one bad run kill the loop: a transient failure is
|
||||
// already retried inside CollectorClient, and a permanent failure (bad key,
|
||||
// PC removed server-side) is logged and the loop simply waits for the next
|
||||
// cadence tick rather than hammering the server.
|
||||
public sealed class TelemetryWorker : BackgroundService
|
||||
{
|
||||
private readonly ICollector collector;
|
||||
private readonly CollectorClient client;
|
||||
private readonly IAgentUtilityRunner? utilities;
|
||||
private readonly AgentConfig config;
|
||||
private readonly string agentVersion;
|
||||
private readonly ILogger<TelemetryWorker> logger;
|
||||
private readonly CollectSignal? collectSignal;
|
||||
private readonly Random jitter = new();
|
||||
|
||||
public TelemetryWorker(ICollector collector, CollectorClient client,
|
||||
AgentConfig config, string agentVersion, ILogger<TelemetryWorker> logger,
|
||||
IAgentUtilityRunner? utilities = null, CollectSignal? collectSignal = null)
|
||||
{
|
||||
this.collector = collector;
|
||||
this.client = client;
|
||||
this.config = config;
|
||||
this.agentVersion = agentVersion;
|
||||
this.logger = logger;
|
||||
this.utilities = utilities;
|
||||
this.collectSignal = collectSignal;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"Endpoint agent {Version} -> {BaseUrl}, cadence {Cadence}s +/-{Jitter}s",
|
||||
agentVersion, config.BaseUrl, config.CadenceSeconds, config.JitterSeconds);
|
||||
|
||||
bool firstRun = true;
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
bool adopt = firstRun && config.AdoptOnFirstRun;
|
||||
try
|
||||
{
|
||||
await RunOnceAsync(adopt, stoppingToken);
|
||||
firstRun = false;
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (CollectorException error) when (error.IsPermanent)
|
||||
{
|
||||
// Bad key or PC removed server-side: surface it, do not retry
|
||||
// faster than cadence. If it was a first-run adopt, keep trying
|
||||
// to adopt next tick (the operator may be provisioning the PC).
|
||||
logger.LogError(error, "Permanent collector error; waiting for next cadence");
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
logger.LogError(error, "Telemetry run failed");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (collectSignal is not null)
|
||||
{
|
||||
// Wake early if a command asked for an immediate re-collect,
|
||||
// otherwise fall through when the cadence delay elapses.
|
||||
bool woken = await collectSignal.WaitAsync(NextDelay(), stoppingToken);
|
||||
if (woken)
|
||||
logger.LogInformation("Off-cadence collect requested after a command");
|
||||
}
|
||||
else
|
||||
{
|
||||
await Task.Delay(NextDelay(), stoppingToken);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
logger.LogInformation("Endpoint agent stopped");
|
||||
}
|
||||
|
||||
// Public so a --once console run and future on-demand triggers can reuse the
|
||||
// exact production collection path.
|
||||
public async Task<CollectorResponse> RunOnceAsync(bool adopt, CancellationToken token)
|
||||
{
|
||||
var report = collector.Collect(agentVersion, adopt);
|
||||
|
||||
if (utilities is not null)
|
||||
{
|
||||
var extra = utilities.RunAll(token);
|
||||
if (extra.Count > 0)
|
||||
logger.LogDebug("Loaded {Count} agent utility result(s)", extra.Count);
|
||||
}
|
||||
|
||||
logger.LogInformation(
|
||||
"Reporting (adopt={Adopt}) user={User} serial={Serial} events={Events}",
|
||||
adopt, report.LoggedInUser, report.SerialNumber, report.Events?.Length ?? 0);
|
||||
|
||||
var result = await client.SendAsync(report, token);
|
||||
|
||||
logger.LogInformation("OK action={Action} assetid={AssetId} identity={Identity}",
|
||||
result.Action, result.AssetId, result.IdentityValue);
|
||||
foreach (var warning in result.Warnings ?? Array.Empty<string>())
|
||||
logger.LogWarning("Server warning: {Warning}", warning);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private TimeSpan NextDelay()
|
||||
{
|
||||
int spread = Math.Max(0, config.JitterSeconds);
|
||||
int offset = spread == 0 ? 0 : jitter.Next(-spread, spread + 1);
|
||||
int seconds = Math.Max(1, config.CadenceSeconds + offset);
|
||||
return TimeSpan.FromSeconds(seconds);
|
||||
}
|
||||
}
|
||||
17
agent/ShopDbEndpointAgent.Tray/Program.cs
Normal file
17
agent/ShopDbEndpointAgent.Tray/Program.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
namespace ShopDbEndpointAgent.Tray;
|
||||
|
||||
// Interactive user-session tray app (SCAFFOLD). Separate process from the
|
||||
// headless Windows Service: the service owns telemetry, this owns the operator
|
||||
// surface - a system-tray icon and the 5-click-max ServiceNow ticket flow
|
||||
// (DESIGN-DECISIONS-agent.md section 6). This iteration wires the tray and a
|
||||
// stubbed ticket dialog; the ServiceNow submission (direct vs server-proxied)
|
||||
// is deferred until that decision is made server-side.
|
||||
internal static class Program
|
||||
{
|
||||
[STAThread]
|
||||
private static void Main()
|
||||
{
|
||||
ApplicationConfiguration.Initialize();
|
||||
Application.Run(new TrayContext());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net8.0-windows</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<UseWindowsForms>true</UseWindowsForms>
|
||||
<RootNamespace>ShopDbEndpointAgent.Tray</RootNamespace>
|
||||
<AssemblyName>shopdb-endpoint-tray</AssemblyName>
|
||||
<InvariantGlobalization>true</InvariantGlobalization>
|
||||
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||
<SelfContained>true</SelfContained>
|
||||
<PublishSingleFile>true</PublishSingleFile>
|
||||
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
|
||||
<EnableCompressionInSingleFile>true</EnableCompressionInSingleFile>
|
||||
<DebugType>none</DebugType>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ShopDbEndpointAgent.Core\ShopDbEndpointAgent.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
118
agent/ShopDbEndpointAgent.Tray/TicketForm.cs
Normal file
118
agent/ShopDbEndpointAgent.Tray/TicketForm.cs
Normal file
@@ -0,0 +1,118 @@
|
||||
namespace ShopDbEndpointAgent.Tray;
|
||||
|
||||
// The 5-click-max issue reporter (SCAFFOLD). Target flow from
|
||||
// DESIGN-DECISIONS-agent.md section 6:
|
||||
// network precheck -> issue dropdown -> optional screenshot -> submit.
|
||||
// This iteration renders the dropdown of fixed reasons and a submit button but
|
||||
// does NOT talk to ServiceNow yet - the submission path (direct API vs proxied
|
||||
// through a ShopDB server endpoint so ServiceNow creds stay server-side) is an
|
||||
// open decision. Submit currently shows what WOULD be sent.
|
||||
internal sealed class TicketForm : Form
|
||||
{
|
||||
// Fixed dropdown reasons from the source conversation (section 6).
|
||||
private static readonly string[] Reasons =
|
||||
{
|
||||
"Can't logon to CSF",
|
||||
"Data collections down",
|
||||
"Can't download program",
|
||||
"Machine communication failing",
|
||||
"Network drives down",
|
||||
"Other (free text)",
|
||||
};
|
||||
|
||||
private readonly ComboBox reasonBox;
|
||||
private readonly TextBox detailBox;
|
||||
private readonly CheckBox screenshotBox;
|
||||
|
||||
public TicketForm()
|
||||
{
|
||||
Text = "Report an issue";
|
||||
FormBorderStyle = FormBorderStyle.FixedDialog;
|
||||
StartPosition = FormStartPosition.CenterScreen;
|
||||
MinimizeBox = false;
|
||||
MaximizeBox = false;
|
||||
ClientSize = new Size(420, 260);
|
||||
|
||||
var reasonLabel = new Label
|
||||
{
|
||||
Text = "What is happening?",
|
||||
Left = 16,
|
||||
Top = 16,
|
||||
Width = 388,
|
||||
};
|
||||
reasonBox = new ComboBox
|
||||
{
|
||||
Left = 16,
|
||||
Top = 40,
|
||||
Width = 388,
|
||||
DropDownStyle = ComboBoxStyle.DropDownList,
|
||||
};
|
||||
reasonBox.Items.AddRange(Reasons);
|
||||
reasonBox.SelectedIndex = 0;
|
||||
|
||||
var detailLabel = new Label
|
||||
{
|
||||
Text = "Details (optional)",
|
||||
Left = 16,
|
||||
Top = 76,
|
||||
Width = 388,
|
||||
};
|
||||
detailBox = new TextBox
|
||||
{
|
||||
Left = 16,
|
||||
Top = 100,
|
||||
Width = 388,
|
||||
Height = 80,
|
||||
Multiline = true,
|
||||
};
|
||||
|
||||
screenshotBox = new CheckBox
|
||||
{
|
||||
Text = "Attach a screenshot of my screen",
|
||||
Left = 16,
|
||||
Top = 188,
|
||||
Width = 388,
|
||||
};
|
||||
|
||||
var submit = new Button
|
||||
{
|
||||
Text = "Submit",
|
||||
Left = 244,
|
||||
Top = 220,
|
||||
Width = 76,
|
||||
DialogResult = DialogResult.OK,
|
||||
};
|
||||
submit.Click += OnSubmit;
|
||||
|
||||
var cancel = new Button
|
||||
{
|
||||
Text = "Cancel",
|
||||
Left = 328,
|
||||
Top = 220,
|
||||
Width = 76,
|
||||
DialogResult = DialogResult.Cancel,
|
||||
};
|
||||
|
||||
Controls.AddRange(new Control[]
|
||||
{
|
||||
reasonLabel, reasonBox, detailLabel, detailBox, screenshotBox,
|
||||
submit, cancel,
|
||||
});
|
||||
AcceptButton = submit;
|
||||
CancelButton = cancel;
|
||||
}
|
||||
|
||||
private void OnSubmit(object? sender, EventArgs e)
|
||||
{
|
||||
// Placeholder: show what would be submitted. The real path (screenshot
|
||||
// capture to Pictures\Screenshots, then ServiceNow submit) lands once
|
||||
// the direct-vs-proxied decision is made.
|
||||
var summary =
|
||||
$"Reason: {reasonBox.SelectedItem}\n" +
|
||||
$"Details: {(string.IsNullOrWhiteSpace(detailBox.Text) ? "(none)" : detailBox.Text)}\n" +
|
||||
$"Screenshot: {(screenshotBox.Checked ? "yes" : "no")}";
|
||||
MessageBox.Show(
|
||||
"Ticketing is not wired up yet. This would be submitted:\n\n" + summary,
|
||||
"ShopDB agent (scaffold)", MessageBoxButtons.OK, MessageBoxIcon.Information);
|
||||
}
|
||||
}
|
||||
202
agent/ShopDbEndpointAgent.Tray/TrayContext.cs
Normal file
202
agent/ShopDbEndpointAgent.Tray/TrayContext.cs
Normal file
@@ -0,0 +1,202 @@
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace ShopDbEndpointAgent.Tray;
|
||||
|
||||
// Owns the tray icon and its context menu for the app's lifetime. Kept minimal
|
||||
// and headless-friendly: no main window, the icon is the whole UI surface until
|
||||
// the operator opens the ticket dialog.
|
||||
//
|
||||
// "Send Status now" reuses the exact production collection path by shelling out
|
||||
// to the installed shopdbagent.exe with --once. That keeps the collector key and
|
||||
// all collection logic in the service binary: the tray never holds a secret and
|
||||
// never duplicates wiring, it just asks the agent to report on demand so the
|
||||
// ShopDB monitor page reflects the box immediately instead of at next cadence.
|
||||
internal sealed class TrayContext : ApplicationContext
|
||||
{
|
||||
// The headless agent/service executable. The tray ships next to it in the
|
||||
// install directory, so same-dir resolution is the primary path.
|
||||
private const string AgentExeName = "shopdbagent.exe";
|
||||
private const string DefaultInstallDir = @"C:\Program Files\ShopDB\EndpointAgent";
|
||||
|
||||
private readonly NotifyIcon trayIcon;
|
||||
private readonly Icon brandIcon;
|
||||
private bool sending;
|
||||
|
||||
public TrayContext()
|
||||
{
|
||||
var menu = new ContextMenuStrip();
|
||||
menu.Items.Add("Send Status now", null, OnSendStatus);
|
||||
menu.Items.Add("Report an issue...", null, OnReportIssue);
|
||||
menu.Items.Add(new ToolStripSeparator());
|
||||
menu.Items.Add("About ShopDB agent", null, OnAbout);
|
||||
menu.Items.Add("Exit", null, OnExit);
|
||||
|
||||
// Branded tray icon drawn from the ShopDB WebUI favicon (see TrayIcon).
|
||||
brandIcon = TrayIcon.Create();
|
||||
|
||||
trayIcon = new NotifyIcon
|
||||
{
|
||||
Icon = brandIcon,
|
||||
Text = "ShopDB Endpoint Agent",
|
||||
Visible = true,
|
||||
ContextMenuStrip = menu,
|
||||
};
|
||||
trayIcon.DoubleClick += OnSendStatus;
|
||||
}
|
||||
|
||||
private void OnReportIssue(object? sender, EventArgs e)
|
||||
{
|
||||
using var dialog = new TicketForm();
|
||||
dialog.ShowDialog();
|
||||
}
|
||||
|
||||
// Runs shopdbagent.exe --once out of process so the operator can push a
|
||||
// fresh report without waiting for the next cadence tick. The exit code is
|
||||
// the agent's own contract: 0 = reported OK, non-zero = failed (bad key,
|
||||
// host removed, network) and the detail is in the rolling agent log.
|
||||
private async void OnSendStatus(object? sender, EventArgs e)
|
||||
{
|
||||
if (sending)
|
||||
return;
|
||||
|
||||
var agentExe = ResolveAgentExe();
|
||||
if (agentExe is null)
|
||||
{
|
||||
ShowBalloon("Send Status", "Could not find " + AgentExeName +
|
||||
". Is the ShopDB Endpoint Agent installed?", ToolTipIcon.Error);
|
||||
return;
|
||||
}
|
||||
|
||||
sending = true;
|
||||
trayIcon.Text = "ShopDB Endpoint Agent (sending status...)";
|
||||
ShowBalloon("Send Status", "Sending this PC's status to ShopDB...",
|
||||
ToolTipIcon.Info);
|
||||
|
||||
try
|
||||
{
|
||||
int exitCode = await RunAgentOnceAsync(agentExe);
|
||||
if (exitCode == 0)
|
||||
ShowBalloon("Send Status", "Status sent to ShopDB.",
|
||||
ToolTipIcon.Info);
|
||||
else
|
||||
ShowBalloon("Send Status",
|
||||
"Status send failed (exit " + exitCode +
|
||||
"). See the agent log under ProgramData\\ShopDB.",
|
||||
ToolTipIcon.Warning);
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
ShowBalloon("Send Status", "Could not run the agent: " +
|
||||
error.Message, ToolTipIcon.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
sending = false;
|
||||
trayIcon.Text = "ShopDB Endpoint Agent";
|
||||
}
|
||||
}
|
||||
|
||||
private static Task<int> RunAgentOnceAsync(string agentExe)
|
||||
{
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = agentExe,
|
||||
Arguments = "--once",
|
||||
WorkingDirectory = Path.GetDirectoryName(agentExe) ?? Environment.CurrentDirectory,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
};
|
||||
|
||||
// The tray and the agent are both single-file, self-contained apps. When
|
||||
// the tray host started, the .NET single-file bootstrapper set DOTNET_*
|
||||
// and bundle-extraction variables in this process's environment. With
|
||||
// UseShellExecute=false the child inherits those variables, so the
|
||||
// freshly installed agent tries to bootstrap against the tray's stale
|
||||
// extraction state and dies during host startup (exit 0xE0434352) before
|
||||
// any managed code - or the file log - runs. Stripping these inherited
|
||||
// markers gives the child the same clean host environment it gets when
|
||||
// launched from a shell, and it also removes the need to restart the tray
|
||||
// after every agent rebuild. The agent re-derives whatever it needs.
|
||||
ScrubInheritedDotnetHostVariables(startInfo);
|
||||
|
||||
var completion = new TaskCompletionSource<int>();
|
||||
var process = new Process { StartInfo = startInfo, EnableRaisingEvents = true };
|
||||
process.Exited += (_, _) =>
|
||||
{
|
||||
completion.TrySetResult(process.ExitCode);
|
||||
process.Dispose();
|
||||
};
|
||||
|
||||
if (!process.Start())
|
||||
{
|
||||
process.Dispose();
|
||||
completion.TrySetException(
|
||||
new InvalidOperationException("failed to start " + agentExe));
|
||||
}
|
||||
|
||||
return completion.Task;
|
||||
}
|
||||
|
||||
// Remove the .NET host / single-file bundle markers this tray process
|
||||
// inherited at startup so a spawned single-file child bootstraps cleanly
|
||||
// instead of adopting the tray's extraction state. Names are matched by a
|
||||
// DOTNET_/COREHOST_ prefix (covers DOTNET_BUNDLE_EXTRACT_BASE_DIR,
|
||||
// DOTNET_ROOT, the internal startup hooks, etc.) so this stays correct as
|
||||
// the runtime adds new host variables.
|
||||
private static void ScrubInheritedDotnetHostVariables(ProcessStartInfo startInfo)
|
||||
{
|
||||
var toRemove = startInfo.Environment.Keys
|
||||
.Where(key =>
|
||||
key.StartsWith("DOTNET_", StringComparison.OrdinalIgnoreCase) ||
|
||||
key.StartsWith("COREHOST_", StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
foreach (var key in toRemove)
|
||||
startInfo.Environment.Remove(key);
|
||||
}
|
||||
|
||||
// Same directory first (the installer ships the tray next to the agent),
|
||||
// then the known Program Files install directory as a fallback.
|
||||
private static string? ResolveAgentExe()
|
||||
{
|
||||
var beside = Path.Combine(AppContext.BaseDirectory, AgentExeName);
|
||||
if (File.Exists(beside))
|
||||
return beside;
|
||||
|
||||
var installed = Path.Combine(DefaultInstallDir, AgentExeName);
|
||||
if (File.Exists(installed))
|
||||
return installed;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private void ShowBalloon(string title, string text, ToolTipIcon icon)
|
||||
{
|
||||
trayIcon.BalloonTipTitle = title;
|
||||
trayIcon.BalloonTipText = text;
|
||||
trayIcon.BalloonTipIcon = icon;
|
||||
trayIcon.ShowBalloonTip(4000);
|
||||
}
|
||||
|
||||
private void OnAbout(object? sender, EventArgs e)
|
||||
=> MessageBox.Show(
|
||||
"ShopDB Endpoint Agent tray.\n" +
|
||||
"Telemetry runs as the ShopDB Endpoint Agent Windows service.\n" +
|
||||
"Send Status now reports this PC to ShopDB on demand.",
|
||||
"About", MessageBoxButtons.OK, MessageBoxIcon.Information);
|
||||
|
||||
private void OnExit(object? sender, EventArgs e)
|
||||
{
|
||||
trayIcon.Visible = false;
|
||||
ExitThread();
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing)
|
||||
{
|
||||
trayIcon.Dispose();
|
||||
brandIcon.Dispose();
|
||||
}
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
}
|
||||
81
agent/ShopDbEndpointAgent.Tray/TrayIcon.cs
Normal file
81
agent/ShopDbEndpointAgent.Tray/TrayIcon.cs
Normal file
@@ -0,0 +1,81 @@
|
||||
using System.Drawing.Drawing2D;
|
||||
using System.Drawing.Imaging;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace ShopDbEndpointAgent.Tray;
|
||||
|
||||
// Builds the tray icon from the ShopDB WebUI favicon (frontend/public/favicon.svg:
|
||||
// a #4181ff rounded square with a white "S") by drawing it with GDI+ at runtime.
|
||||
// Drawing it here rather than shipping a binary .ico keeps the brand in one
|
||||
// place (these values mirror the SVG) and avoids adding an SVG rasterizer or a
|
||||
// checked-in binary to the build.
|
||||
internal static class TrayIcon
|
||||
{
|
||||
// Favicon colors (frontend/public/favicon.svg).
|
||||
private static readonly Color Background = ColorTranslator.FromHtml("#4181ff");
|
||||
private static readonly Color Foreground = Color.White;
|
||||
|
||||
// Renders the branded icon at a tray-friendly size. 32px covers standard and
|
||||
// most high-DPI tray scaling; the OS downsamples as needed.
|
||||
public static Icon Create(int size = 32)
|
||||
{
|
||||
using var bitmap = new Bitmap(size, size, PixelFormat.Format32bppArgb);
|
||||
using (var graphics = Graphics.FromImage(bitmap))
|
||||
{
|
||||
graphics.SmoothingMode = SmoothingMode.AntiAlias;
|
||||
graphics.TextRenderingHint =
|
||||
System.Drawing.Text.TextRenderingHint.AntiAlias;
|
||||
graphics.Clear(Color.Transparent);
|
||||
|
||||
// Rounded square (SVG rect rx=14 on a 64 viewport -> ~0.22 radius).
|
||||
float inset = size * 0.03f;
|
||||
var rect = new RectangleF(inset, inset,
|
||||
size - inset * 2, size - inset * 2);
|
||||
float radius = size * 0.22f;
|
||||
using (var path = RoundedRect(rect, radius))
|
||||
using (var fill = new SolidBrush(Background))
|
||||
graphics.FillPath(fill, path);
|
||||
|
||||
// White "S", centered.
|
||||
using var font = new Font("Arial", size * 0.58f,
|
||||
FontStyle.Bold, GraphicsUnit.Pixel);
|
||||
using var text = new SolidBrush(Foreground);
|
||||
using var format = new StringFormat
|
||||
{
|
||||
Alignment = StringAlignment.Center,
|
||||
LineAlignment = StringAlignment.Center,
|
||||
};
|
||||
graphics.DrawString("S", font, text, rect, format);
|
||||
}
|
||||
|
||||
// Icon.FromHandle borrows the GDI HICON, so clone into a managed Icon
|
||||
// and free the handle to avoid leaking one per launch.
|
||||
IntPtr hicon = bitmap.GetHicon();
|
||||
try
|
||||
{
|
||||
using var borrowed = Icon.FromHandle(hicon);
|
||||
return (Icon)borrowed.Clone();
|
||||
}
|
||||
finally
|
||||
{
|
||||
DestroyIcon(hicon);
|
||||
}
|
||||
}
|
||||
|
||||
private static GraphicsPath RoundedRect(RectangleF rect, float radius)
|
||||
{
|
||||
float diameter = radius * 2;
|
||||
var path = new GraphicsPath();
|
||||
path.AddArc(rect.Left, rect.Top, diameter, diameter, 180, 90);
|
||||
path.AddArc(rect.Right - diameter, rect.Top, diameter, diameter, 270, 90);
|
||||
path.AddArc(rect.Right - diameter, rect.Bottom - diameter, diameter,
|
||||
diameter, 0, 90);
|
||||
path.AddArc(rect.Left, rect.Bottom - diameter, diameter, diameter, 90, 90);
|
||||
path.CloseFigure();
|
||||
return path;
|
||||
}
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool DestroyIcon(IntPtr handle);
|
||||
}
|
||||
5
agent/ShopDbEndpointAgent.slnx
Normal file
5
agent/ShopDbEndpointAgent.slnx
Normal file
@@ -0,0 +1,5 @@
|
||||
<Solution>
|
||||
<Project Path="ShopDbEndpointAgent.Core/ShopDbEndpointAgent.Core.csproj" />
|
||||
<Project Path="ShopDbEndpointAgent.Tray/ShopDbEndpointAgent.Tray.csproj" />
|
||||
<Project Path="ShopDbEndpointAgent/ShopDbEndpointAgent.csproj" />
|
||||
</Solution>
|
||||
164
agent/ShopDbEndpointAgent/Program.cs
Normal file
164
agent/ShopDbEndpointAgent/Program.cs
Normal file
@@ -0,0 +1,164 @@
|
||||
using System.Reflection;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ShopDbEndpointAgent.Core;
|
||||
|
||||
// Headless Windows Service host for the ShopDB endpoint agent. Composes the
|
||||
// Core runtime (config -> collector -> client -> worker) behind the generic
|
||||
// host, adds durable file logging and a WER-style fault monitor, and supports:
|
||||
// (default) run as a Windows Service (AddWindowsService)
|
||||
// --console run interactively in the foreground for debugging
|
||||
// --once collect and report exactly once, then exit (smoke test / cron)
|
||||
//
|
||||
// Secrets are never read here: AgentConfigProvider layers the registry
|
||||
// (HKLM\SOFTWARE\GE\ShopDB: CollectorKey, BaseUrl) over the shipped, non-secret
|
||||
// appsettings.json. The executable is intentionally named shopdbagent.exe so it
|
||||
// never collides with third-party agent.exe processes/services (for example the
|
||||
// MTConnect agent this same build can monitor).
|
||||
|
||||
var runOnce = args.Any(a => string.Equals(a, "--once", StringComparison.OrdinalIgnoreCase));
|
||||
var forceConsole = args.Any(a => string.Equals(a, "--console", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
var agentVersion = Assembly.GetExecutingAssembly()
|
||||
.GetName().Version?.ToString() ?? "0.0.0";
|
||||
|
||||
// Bootstrap logger first so config loading (including the registry read that
|
||||
// CyberArk/EPM may gate) is fully diagnosable before the host starts.
|
||||
using var bootstrapFileLogger = new FileLoggerProvider(FileLoggerProvider.DefaultDirectory());
|
||||
var bootstrapLogger = bootstrapFileLogger.CreateLogger("Startup");
|
||||
|
||||
var faultMonitor = new AgentFaultMonitor(bootstrapLogger);
|
||||
faultMonitor.Install();
|
||||
|
||||
var config = AgentConfigProvider.Load(logger: bootstrapLogger);
|
||||
|
||||
var logDirectory = string.IsNullOrWhiteSpace(config.LogPath)
|
||||
? FileLoggerProvider.DefaultDirectory()
|
||||
: config.LogPath;
|
||||
|
||||
if (!config.IsUsable())
|
||||
{
|
||||
bootstrapLogger.LogError(
|
||||
"Config incomplete: provision CollectorKey and BaseUrl in " +
|
||||
"HKLM\\SOFTWARE\\GE\\ShopDB (or baseurl/apikey in appsettings.json for dev). " +
|
||||
"See the registry lines above to tell 'missing' from 'access denied'.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (runOnce)
|
||||
return await RunOnceAsync(config, agentVersion, logDirectory, faultMonitor);
|
||||
|
||||
await RunHostAsync(config, agentVersion, logDirectory, forceConsole);
|
||||
return 0;
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
faultMonitor.ReportFatal("host", error);
|
||||
return 1;
|
||||
}
|
||||
|
||||
static async Task RunHostAsync(AgentConfig config, string agentVersion,
|
||||
string logDirectory, bool forceConsole)
|
||||
{
|
||||
var builder = Host.CreateApplicationBuilder();
|
||||
|
||||
if (!forceConsole)
|
||||
builder.Services.AddWindowsService(options =>
|
||||
options.ServiceName = "ShopDbEndpointAgent");
|
||||
|
||||
ConfigureLogging(builder.Logging, logDirectory);
|
||||
RegisterCoreServices(builder.Services, config, agentVersion);
|
||||
builder.Services.AddHostedService(sp => sp.GetRequiredService<TelemetryWorker>());
|
||||
builder.Services.AddHostedService(sp => sp.GetRequiredService<CommandWorker>());
|
||||
|
||||
await builder.Build().RunAsync();
|
||||
}
|
||||
|
||||
static async Task<int> RunOnceAsync(AgentConfig config, string agentVersion,
|
||||
string logDirectory, AgentFaultMonitor faultMonitor)
|
||||
{
|
||||
using var services = BuildOnceServices(config, agentVersion, logDirectory);
|
||||
var worker = services.GetRequiredService<TelemetryWorker>();
|
||||
var logger = services.GetRequiredService<ILogger<TelemetryWorker>>();
|
||||
try
|
||||
{
|
||||
var result = await worker.RunOnceAsync(config.AdoptOnFirstRun, CancellationToken.None);
|
||||
logger.LogInformation("Single report complete: action={Action}", result.Action);
|
||||
return 0;
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
faultMonitor.ReportFatal("--once", error);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
static ServiceProvider BuildOnceServices(AgentConfig config, string agentVersion,
|
||||
string logDirectory)
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging(logging => ConfigureLogging(logging, logDirectory));
|
||||
RegisterCoreServices(services, config, agentVersion);
|
||||
return services.BuildServiceProvider();
|
||||
}
|
||||
|
||||
static void RegisterCoreServices(IServiceCollection services, AgentConfig config,
|
||||
string agentVersion)
|
||||
{
|
||||
services.AddSingleton(config);
|
||||
|
||||
// Shared wake used by the command worker to trigger an immediate telemetry
|
||||
// collect after a command changes endpoint state (e.g. MTConnect toggle).
|
||||
services.AddSingleton<CollectSignal>();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(config.UtilitiesPath) &&
|
||||
Directory.Exists(config.UtilitiesPath))
|
||||
{
|
||||
services.AddSingleton<IAgentUtilityRunner>(sp =>
|
||||
new PowerShellUtilityRunner(config.UtilitiesPath,
|
||||
sp.GetRequiredService<ILogger<PowerShellUtilityRunner>>()));
|
||||
}
|
||||
|
||||
services.AddSingleton<ICollector>(sp => new EndpointCollector(
|
||||
sp.GetRequiredService<ILogger<EndpointCollector>>(),
|
||||
config,
|
||||
sp.GetService<IAgentUtilityRunner>()));
|
||||
|
||||
services.AddSingleton(sp => new CollectorClient(
|
||||
config, sp.GetRequiredService<ILogger<CollectorClient>>()));
|
||||
|
||||
services.AddSingleton(sp => new TelemetryWorker(
|
||||
sp.GetRequiredService<ICollector>(),
|
||||
sp.GetRequiredService<CollectorClient>(),
|
||||
config,
|
||||
agentVersion,
|
||||
sp.GetRequiredService<ILogger<TelemetryWorker>>(),
|
||||
sp.GetService<IAgentUtilityRunner>(),
|
||||
sp.GetRequiredService<CollectSignal>()));
|
||||
|
||||
// Command channel: outbound long-poll for allowlisted WebUI commands, run
|
||||
// through the fail-closed executor. Hosted alongside telemetry but on its
|
||||
// own loop so a held-open poll never delays a report.
|
||||
services.AddSingleton(sp => new CommandClient(
|
||||
config, sp.GetRequiredService<ILogger<CommandClient>>()));
|
||||
|
||||
services.AddSingleton(sp => new CommandExecutor(
|
||||
sp.GetRequiredService<ILogger<CommandExecutor>>(),
|
||||
config));
|
||||
|
||||
services.AddSingleton(sp => new CommandWorker(
|
||||
sp.GetRequiredService<CommandClient>(),
|
||||
sp.GetRequiredService<CommandExecutor>(),
|
||||
sp.GetRequiredService<ILogger<CommandWorker>>(),
|
||||
sp.GetRequiredService<CollectSignal>()));
|
||||
}
|
||||
|
||||
static void ConfigureLogging(ILoggingBuilder logging, string logDirectory)
|
||||
{
|
||||
logging.ClearProviders();
|
||||
logging.AddProvider(new FileLoggerProvider(logDirectory));
|
||||
logging.SetMinimumLevel(LogLevel.Information);
|
||||
}
|
||||
40
agent/ShopDbEndpointAgent/ShopDbEndpointAgent.csproj
Normal file
40
agent/ShopDbEndpointAgent/ShopDbEndpointAgent.csproj
Normal file
@@ -0,0 +1,40 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net8.0-windows</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>ShopDbEndpointAgent</RootNamespace>
|
||||
<AssemblyName>shopdbagent</AssemblyName>
|
||||
<!-- 1.2.0: process_start launches via a per-image path allowlist
|
||||
(AgentConfig.LaunchPaths, shipped defaults overridable per site).
|
||||
1.1.0: generic, data-driven service_*/process_* actions carry their
|
||||
target over the wire, so new policies/apps need no agent rebuild. -->
|
||||
<Version>1.3.0</Version>
|
||||
<InvariantGlobalization>true</InvariantGlobalization>
|
||||
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||
<SelfContained>true</SelfContained>
|
||||
<PublishSingleFile>true</PublishSingleFile>
|
||||
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
|
||||
<EnableCompressionInSingleFile>true</EnableCompressionInSingleFile>
|
||||
<DebugType>none</DebugType>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.EventLog" Version="8.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ShopDbEndpointAgent.Core\ShopDbEndpointAgent.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Update="appsettings.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
26
agent/ShopDbEndpointAgent/appsettings.json
Normal file
26
agent/ShopDbEndpointAgent/appsettings.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"baseurl": "",
|
||||
"apikey": "REPLACE_WITH_COLLECTOR_KEY",
|
||||
"cadenceseconds": 900,
|
||||
"jitterseconds": 60,
|
||||
"adoptonfirstrun": true,
|
||||
"utilitiespath": "",
|
||||
"logpath": "",
|
||||
"monitors": {
|
||||
"processes": [
|
||||
{ "name": "dncMain.exe", "app": "eDNC" },
|
||||
{ "name": "udc.exe", "app": "Universal Data Collector" },
|
||||
{ "name": "agent.exe", "app": "MTConnect" }
|
||||
],
|
||||
"services": [
|
||||
{ "name": "MTConnect UDC Agent", "app": "MTConnect" }
|
||||
]
|
||||
},
|
||||
"launchpaths": {
|
||||
"DncMain.exe": "C:\\Program Files (x86)\\Dnc\\bin\\DncMain.exe",
|
||||
"UDC.exe": "C:\\Program Files\\UDC\\UDC.exe",
|
||||
"agent.exe": "C:\\ProgramData\\udc\\mtconnect_udc\\agent\\run.bat"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
337
agent/installer/install-shopdbagent.ps1
Normal file
337
agent/installer/install-shopdbagent.ps1
Normal file
@@ -0,0 +1,337 @@
|
||||
# install-shopdbagent.ps1
|
||||
# ShopDB Endpoint Agent installer for the test endpoint. Run from an ELEVATED
|
||||
# (admin) PowerShell on the endpoint. Provisions the registry secrets, copies
|
||||
# the agent files to Program Files, runs a one-shot smoke test, then installs
|
||||
# and starts the Windows service.
|
||||
#
|
||||
# Secrets are provisioned to HKLM\SOFTWARE\GE\ShopDB. On locked-down hosts
|
||||
# (CyberArk/EPM) the agent may be blocked from READING that hive, so the same
|
||||
# values are also stamped into the installed appsettings.json as a fallback.
|
||||
#
|
||||
# The agent and tray are published as single-file self-contained executables,
|
||||
# so copying the two .exe files plus appsettings.json is the complete payload.
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
# ShopDB server root URL for this instance. Defaulted to the test instance;
|
||||
# pass -BaseUrl "http://<host>:<port>" to override.
|
||||
[string] $BaseUrl = "http://H1HWNL84.device.geaerospace.net:5001",
|
||||
|
||||
# Endpoints-only collector key (this instance's .env
|
||||
# COLLECTOR_API_KEY_ENDPOINTS).
|
||||
[string] $CollectorKey = "l755ddRw1iSeOcSTh9N9wXM4gGT7N6WIVN9crOY6uZU",
|
||||
|
||||
[string] $InstallDir = "C:\Program Files\ShopDB\EndpointAgent",
|
||||
[string] $ServiceName = "ShopDbEndpointAgent",
|
||||
|
||||
# Interactive tray app that shows the system-tray icon and the on-demand
|
||||
# "Send Status now" action. Separate process from the SYSTEM service (a
|
||||
# service cannot draw a tray icon in a user session). Pass -NoTray to skip.
|
||||
[string] $TrayExeName = "shopdb-endpoint-tray.exe",
|
||||
[switch] $NoTray,
|
||||
|
||||
# Add a Microsoft Defender path exclusion for the install directory. The
|
||||
# agent and tray are unsigned single-file self-contained executables, which
|
||||
# Defender heuristics (and the tray's GDI/user32 icon P/Invoke) can flag as
|
||||
# a false positive and quarantine, killing the tray before its icon appears.
|
||||
# Pass -NoDefenderExclusion to skip (e.g. where policy manages exclusions
|
||||
# centrally or the binaries are code-signed).
|
||||
[switch] $NoDefenderExclusion
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$sourceDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
|
||||
function Assert-Admin {
|
||||
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
|
||||
$principal = New-Object Security.Principal.WindowsPrincipal($identity)
|
||||
if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
|
||||
throw "This script must be run from an elevated (Run as administrator) PowerShell."
|
||||
}
|
||||
}
|
||||
|
||||
Assert-Admin
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($BaseUrl)) {
|
||||
throw "BaseUrl is not set. Pass -BaseUrl 'http://<host>:<port>' when running the script."
|
||||
}
|
||||
|
||||
# Write HKLM values through the explicit 64-bit registry view. If this script
|
||||
# runs under 32-bit PowerShell (some management tooling launches x86), the
|
||||
# PSDrive HKLM: silently redirects writes into WOW6432Node. The 64-bit agent
|
||||
# service and 64-bit Explorer (the logon Run key) then read the 64-bit view and
|
||||
# see nothing, so config "goes missing" and the tray never autostarts. Pinning
|
||||
# the view removes that dependency on the launching shell's bitness.
|
||||
function Set-Hklm64Value {
|
||||
param(
|
||||
[Parameter(Mandatory)] [string] $SubKey,
|
||||
[Parameter(Mandatory)] [string] $Name,
|
||||
[Parameter(Mandatory)] [string] $Value
|
||||
)
|
||||
$base = [Microsoft.Win32.RegistryKey]::OpenBaseKey(
|
||||
[Microsoft.Win32.RegistryHive]::LocalMachine,
|
||||
[Microsoft.Win32.RegistryView]::Registry64)
|
||||
try {
|
||||
$key = $base.CreateSubKey($SubKey)
|
||||
try {
|
||||
$key.SetValue($Name, $Value, [Microsoft.Win32.RegistryValueKind]::String)
|
||||
}
|
||||
finally { $key.Dispose() }
|
||||
}
|
||||
finally { $base.Dispose() }
|
||||
}
|
||||
|
||||
# Tear down any previously installed agent BEFORE touching files. The agent and
|
||||
# tray are single-file self-contained exes; while the old service or tray is
|
||||
# running it holds those .exe files open, so Copy-Item over them fails with a
|
||||
# file-in-use error. Stopping the service, deleting it, and killing any lingering
|
||||
# agent/tray processes up front makes the install a clean overwrite. Best-effort
|
||||
# throughout: a missing service or process is the normal fresh-install case.
|
||||
function Uninstall-PreviousAgent {
|
||||
param(
|
||||
[Parameter(Mandatory)] [string] $Service,
|
||||
[Parameter(Mandatory)] [string] $TrayExe,
|
||||
[Parameter(Mandatory)] [string] $TargetDir
|
||||
)
|
||||
|
||||
$existing = Get-Service -Name $Service -ErrorAction SilentlyContinue
|
||||
if ($existing) {
|
||||
Write-Host " stopping and removing existing service $Service ..."
|
||||
if ($existing.Status -ne "Stopped") {
|
||||
try { Stop-Service -Name $Service -Force -ErrorAction Stop }
|
||||
catch { Write-Warning " could not stop service: $($_.Exception.Message)" }
|
||||
}
|
||||
# sc.exe delete only marks the service for deletion; the SCM finishes the
|
||||
# removal once every handle closes. Give it a moment so the subsequent
|
||||
# sc.exe create does not hit a "marked for deletion" (1072) error.
|
||||
sc.exe delete $Service | Out-Null
|
||||
Start-Sleep -Seconds 2
|
||||
}
|
||||
|
||||
# Kill any lingering worker or tray processes that would keep the exes
|
||||
# locked. Match by the installed exe base names; the service host process is
|
||||
# shopdbagent, the tray is the TrayExe base name.
|
||||
$trayBase = [System.IO.Path]::GetFileNameWithoutExtension($TrayExe)
|
||||
foreach ($name in @("shopdbagent", $trayBase)) {
|
||||
$procs = Get-Process -Name $name -ErrorAction SilentlyContinue
|
||||
foreach ($p in $procs) {
|
||||
Write-Host " killing lingering process $($p.ProcessName) (pid $($p.Id)) ..."
|
||||
try { Stop-Process -Id $p.Id -Force -ErrorAction Stop }
|
||||
catch { Write-Warning " could not kill $($p.ProcessName): $($_.Exception.Message)" }
|
||||
}
|
||||
}
|
||||
# Also kill anything still running out of the target install directory,
|
||||
# regardless of exe name. A tray from an older build (different exe name) or
|
||||
# one launched from a stale path would otherwise survive the name-based sweep
|
||||
# above, keep its exe locked, and leave a duplicate icon once step 5 starts
|
||||
# the fresh tray. Match on the executable path so we only touch our own dir.
|
||||
$targetPrefix = $TargetDir.TrimEnd('\') + '\'
|
||||
foreach ($p in (Get-Process -ErrorAction SilentlyContinue)) {
|
||||
$path = $null
|
||||
try { $path = $p.Path } catch { $path = $null }
|
||||
if ($path -and $path.StartsWith($targetPrefix, [System.StringComparison]::OrdinalIgnoreCase)) {
|
||||
Write-Host " killing process from install dir $($p.ProcessName) (pid $($p.Id)) ..."
|
||||
try { Stop-Process -Id $p.Id -Force -ErrorAction Stop }
|
||||
catch { Write-Warning " could not kill $($p.ProcessName): $($_.Exception.Message)" }
|
||||
}
|
||||
}
|
||||
# Brief settle so file handles are released before the copy overwrites them.
|
||||
Start-Sleep -Milliseconds 500
|
||||
}
|
||||
|
||||
Write-Host "== ShopDB Endpoint Agent install ==" -ForegroundColor Cyan
|
||||
Write-Host "BaseUrl : $BaseUrl"
|
||||
Write-Host "InstallDir : $InstallDir"
|
||||
Write-Host "ServiceName : $ServiceName"
|
||||
|
||||
# 0. Clear the Mark-of-the-Web on the SOURCE payload first. The operator
|
||||
# typically extracts installer.zip into Downloads, so every file carries a
|
||||
# Zone.Identifier ADS. SmartScreen reads that and blocks the unsigned exes with
|
||||
# "Windows protected your PC" the first time they run - including the [3/5]
|
||||
# smoke test below, which launches shopdbagent.exe out of $sourceDir before the
|
||||
# installed copy exists. Unblocking the source folder up front keeps the smoke
|
||||
# test (and any manual run from the extract folder) clean; the installed copies
|
||||
# get unblocked again in step 2a after they land in Program Files. Best-effort:
|
||||
# a file with no MotW is a no-op.
|
||||
Write-Host "`n[0a] Clearing Mark-of-the-Web on the extracted installer files ..."
|
||||
Get-ChildItem -Path $sourceDir -File | ForEach-Object {
|
||||
try { Unblock-File -Path $_.FullName -ErrorAction Stop }
|
||||
catch { Write-Warning " could not unblock $($_.Name): $($_.Exception.Message)" }
|
||||
}
|
||||
|
||||
# 0. Uninstall any previous agent first, so the file copy is a clean overwrite
|
||||
# and does not fail on exes locked by a running service or tray.
|
||||
Write-Host "`n[0/5] Removing any previously installed agent ..."
|
||||
Uninstall-PreviousAgent -Service $ServiceName -TrayExe $TrayExeName -TargetDir $InstallDir
|
||||
|
||||
# 1. Provision registry secrets (64-bit view, see Set-Hklm64Value).
|
||||
$regPath = "HKLM:\SOFTWARE\GE\ShopDB"
|
||||
Write-Host "`n[1/5] Provisioning registry $regPath (Registry64) ..."
|
||||
Set-Hklm64Value -SubKey "SOFTWARE\GE\ShopDB" -Name "BaseUrl" -Value $BaseUrl
|
||||
Set-Hklm64Value -SubKey "SOFTWARE\GE\ShopDB" -Name "CollectorKey" -Value $CollectorKey
|
||||
|
||||
# 2. Copy agent files to the install directory. The agent and tray are
|
||||
# single-file self-contained executables, so the .exe plus appsettings.json is
|
||||
# the whole payload; there are no side-by-side runtime DLLs to copy.
|
||||
Write-Host "[2/5] Copying agent files to $InstallDir ..."
|
||||
New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null
|
||||
$agentSource = Join-Path $sourceDir "shopdbagent.exe"
|
||||
if (-not (Test-Path $agentSource)) {
|
||||
throw "shopdbagent.exe not found in $sourceDir. Unzip the full installer package and run from that folder."
|
||||
}
|
||||
Copy-Item -Path $agentSource -Destination $InstallDir -Force
|
||||
$appsettings = Join-Path $sourceDir "appsettings.json"
|
||||
$installedSettings = Join-Path $InstallDir "appsettings.json"
|
||||
if (Test-Path $appsettings) {
|
||||
Copy-Item -Path $appsettings -Destination $InstallDir -Force
|
||||
}
|
||||
|
||||
# Copy the interactive tray app next to the agent so its same-dir resolution
|
||||
# finds shopdbagent.exe for the "Send Status now" action. Optional: absent on a
|
||||
# service-only (headless) build.
|
||||
$trayInstalled = $null
|
||||
if (-not $NoTray) {
|
||||
$traySource = Join-Path $sourceDir $TrayExeName
|
||||
if (Test-Path $traySource) {
|
||||
Copy-Item -Path $traySource -Destination $InstallDir -Force
|
||||
$trayInstalled = Join-Path $InstallDir $TrayExeName
|
||||
Write-Host " copied tray app $TrayExeName"
|
||||
}
|
||||
else {
|
||||
Write-Warning " tray app $TrayExeName not found in $sourceDir; skipping tray (service still installs)"
|
||||
}
|
||||
}
|
||||
|
||||
# Fallback for locked-down endpoints: some hosts (CyberArk/EPM) gate the HKLM
|
||||
# hive so the agent cannot READ the values the registry step just wrote. Stamp
|
||||
# BaseUrl/CollectorKey into the installed appsettings.json too. The agent reads
|
||||
# appsettings first, then lets the registry override it, so this is a safe
|
||||
# no-op where the registry read works and the rescue where it does not.
|
||||
if (Test-Path $installedSettings) {
|
||||
try {
|
||||
$json = Get-Content $installedSettings -Raw | ConvertFrom-Json
|
||||
$json.baseurl = $BaseUrl
|
||||
$json.apikey = $CollectorKey
|
||||
$json | ConvertTo-Json -Depth 10 | Set-Content -Path $installedSettings -Encoding UTF8
|
||||
Write-Host " stamped BaseUrl/CollectorKey into appsettings.json (registry-read fallback)"
|
||||
}
|
||||
catch {
|
||||
Write-Warning " could not stamp appsettings.json: $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
$exePath = Join-Path $InstallDir "shopdbagent.exe"
|
||||
|
||||
# 2a. Strip the Mark-of-the-Web from the copied files. Windows tags files
|
||||
# extracted from a downloaded zip with a Zone.Identifier alternate data stream;
|
||||
# SmartScreen reads that and blocks the unsigned exe with "Windows protected
|
||||
# your PC" the first time it runs, and the operator sees only a "Don't run"
|
||||
# button. Unblock-File removes the ADS so the smoke test and the tray launch
|
||||
# start clean. Best-effort: a file with no MotW is a no-op.
|
||||
Write-Host "[2a/5] Clearing Mark-of-the-Web on installed files ..."
|
||||
Get-ChildItem -Path $InstallDir -File | ForEach-Object {
|
||||
try { Unblock-File -Path $_.FullName -ErrorAction Stop }
|
||||
catch { Write-Warning " could not unblock $($_.Name): $($_.Exception.Message)" }
|
||||
}
|
||||
|
||||
# 2b. Defender path exclusion for the install dir. Unsigned single-file
|
||||
# self-contained binaries (and the tray's icon P/Invoke) can trip Defender
|
||||
# heuristics and get quarantined, which is why the tray dies before showing an
|
||||
# icon. Excluding the fixed install directory is the least-privilege fix short
|
||||
# of code-signing. Best-effort: Set-MpPreference is absent on servers without
|
||||
# Defender and can be blocked by tamper protection; a failure is not fatal.
|
||||
if (-not $NoDefenderExclusion) {
|
||||
Write-Host "[2b/5] Adding Defender path exclusion for $InstallDir ..."
|
||||
if (Get-Command Add-MpPreference -ErrorAction SilentlyContinue) {
|
||||
try {
|
||||
Add-MpPreference -ExclusionPath $InstallDir -ErrorAction Stop
|
||||
Write-Host " excluded $InstallDir from Defender scanning."
|
||||
}
|
||||
catch {
|
||||
Write-Warning " could not add Defender exclusion (tamper protection or policy?): $($_.Exception.Message)"
|
||||
Write-Warning " if the tray is quarantined, code-sign the binaries or add the exclusion via policy."
|
||||
}
|
||||
}
|
||||
else {
|
||||
Write-Host " Defender cmdlets not present; skipping exclusion."
|
||||
}
|
||||
}
|
||||
|
||||
# 3. One-shot smoke test (exact production collection path, then exits).
|
||||
Write-Host "[3/5] Running one-shot smoke test (--once) ..."
|
||||
$proc = Start-Process -FilePath $exePath -ArgumentList "--once" -WorkingDirectory $InstallDir -Wait -PassThru -NoNewWindow
|
||||
Write-Host " smoke test exit code: $($proc.ExitCode)"
|
||||
|
||||
$logDir = Join-Path $env:ProgramData "ShopDB\EndpointAgent\logs"
|
||||
$logFile = Join-Path $logDir ("agent-{0}.log" -f (Get-Date -Format "yyyyMMdd"))
|
||||
if (Test-Path $logFile) {
|
||||
Write-Host " --- last log lines ---" -ForegroundColor DarkGray
|
||||
Get-Content $logFile -Tail 20 | ForEach-Object { Write-Host " $_" -ForegroundColor DarkGray }
|
||||
}
|
||||
if ($proc.ExitCode -ne 0) {
|
||||
throw "Smoke test failed (exit $($proc.ExitCode)). Check $logFile before installing the service."
|
||||
}
|
||||
|
||||
# 4. Install the Windows service. Any previous instance was already stopped,
|
||||
# deleted, and its processes killed in step 0, so this is a clean create. Guard
|
||||
# against the rare case where the SCM has not finished a pending deletion yet.
|
||||
Write-Host "[4/5] Installing service $ServiceName ..."
|
||||
$existing = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue
|
||||
if ($existing) {
|
||||
Write-Host " service still present (pending deletion); removing again ..."
|
||||
if ($existing.Status -ne "Stopped") { Stop-Service -Name $ServiceName -Force }
|
||||
sc.exe delete $ServiceName | Out-Null
|
||||
Start-Sleep -Seconds 2
|
||||
}
|
||||
$binPath = '"' + $exePath + '"'
|
||||
sc.exe create $ServiceName binPath= $binPath start= auto DisplayName= "ShopDB Endpoint Agent" | Out-Null
|
||||
sc.exe description $ServiceName "Collects endpoint telemetry for ShopDB and runs allowlisted WebUI commands." | Out-Null
|
||||
sc.exe failure $ServiceName reset= 86400 actions= restart/5000/restart/5000/restart/5000 | Out-Null
|
||||
|
||||
Start-Service -Name $ServiceName
|
||||
Write-Host " service started."
|
||||
|
||||
# 5. Register the interactive tray app to launch at user logon (per-machine).
|
||||
Write-Host "[5/5] Registering tray app for logon ..."
|
||||
if ($trayInstalled -and (Test-Path $trayInstalled)) {
|
||||
# Register in the 64-bit Run key so 64-bit Explorer fires it at logon. A
|
||||
# 32-bit write would land in WOW6432Node\...\Run, which Explorer ignores.
|
||||
Set-Hklm64Value -SubKey "SOFTWARE\Microsoft\Windows\CurrentVersion\Run" `
|
||||
-Name "ShopDbEndpointTray" -Value ('"' + $trayInstalled + '"')
|
||||
Write-Host " tray app registered to start at logon (Registry64): $trayInstalled"
|
||||
|
||||
# Start it now so the icon appears without waiting for the next logon. The
|
||||
# Run key only fires at logon, so a fresh install would otherwise show no
|
||||
# tray until the operator signs out and back in.
|
||||
#
|
||||
# The installer runs elevated, so a direct Start-Process would launch the
|
||||
# tray ELEVATED too. That is wrong for a tray app (it should run at the
|
||||
# user's medium integrity) and, on EPM-managed hosts (CyberArk), elevating an
|
||||
# unsigned exe triggers a broker prompt that comes back as Win32 1223
|
||||
# "The operation was canceled by the user" - which is exactly what we hit.
|
||||
# Launching through explorer.exe hands the exe to the already-running user
|
||||
# shell, which spawns it de-elevated in the interactive session, sidestepping
|
||||
# the elevation broker entirely.
|
||||
$interactiveUser = $env:USERNAME
|
||||
if ($interactiveUser -and $interactiveUser -ne "SYSTEM") {
|
||||
try {
|
||||
# explorer.exe <path> re-parents the launch under the user shell at
|
||||
# medium integrity; it returns immediately regardless of the tray's
|
||||
# own lifetime, so there is nothing to wait on here.
|
||||
Start-Process -FilePath "explorer.exe" -ArgumentList "`"$trayInstalled`"" | Out-Null
|
||||
Write-Host " tray app started for the current session (de-elevated via explorer)."
|
||||
}
|
||||
catch {
|
||||
Write-Warning " could not start tray now: $($_.Exception.Message); it will start at next logon."
|
||||
}
|
||||
}
|
||||
else {
|
||||
Write-Host " no interactive user session; tray will start at next logon."
|
||||
}
|
||||
}
|
||||
else {
|
||||
Write-Host " tray skipped (headless install)."
|
||||
}
|
||||
|
||||
Write-Host "`nInstall complete." -ForegroundColor Green
|
||||
Write-Host "Service '$ServiceName' is running and set to auto-start."
|
||||
Write-Host "Logs: $logDir"
|
||||
@@ -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`)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -29,10 +29,20 @@ export function humanize(key) {
|
||||
|
||||
export function rows(panel) {
|
||||
const data = panel._data
|
||||
if (Array.isArray(data)) return data
|
||||
if (data && Array.isArray(data.rows)) return data.rows
|
||||
if (data && Array.isArray(data.items)) return data.items
|
||||
return []
|
||||
let list
|
||||
if (Array.isArray(data)) list = data
|
||||
else if (data && Array.isArray(data.rows)) list = data.rows
|
||||
else if (data && Array.isArray(data.items)) list = data.items
|
||||
else return []
|
||||
// Optional panel-level row filter so two panels can share one endpoint feed
|
||||
// and each show a slice of it (for example Tracked Services vs Tracked
|
||||
// Processes off the same services snapshot). Absent filter = all rows.
|
||||
const filter = panel.filter
|
||||
if (filter && filter.key) {
|
||||
const want = String(filter.equals).toLowerCase()
|
||||
return list.filter((item) => String(item[filter.key]).toLowerCase() === want)
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
export function mapTitle(panel, item) {
|
||||
|
||||
@@ -72,6 +72,34 @@ describe('list render mapping (warranty)', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('list row filtering (tracked services vs processes)', () => {
|
||||
const feedPanel = {
|
||||
id: 'endpoints-tracked-services', render: 'list',
|
||||
filter: { key: 'kind', equals: 'service' },
|
||||
map: { title: 'name' },
|
||||
_data: [
|
||||
{ name: 'UDC MTConnect Agent', kind: 'service' },
|
||||
{ name: 'DncMain', kind: 'process' },
|
||||
{ name: 'Spooler', kind: 'SERVICE' },
|
||||
],
|
||||
}
|
||||
|
||||
it('keeps only rows whose filter key matches, case-insensitively', () => {
|
||||
const kept = rows(feedPanel).map((r) => r.name)
|
||||
expect(kept).toEqual(['UDC MTConnect Agent', 'Spooler'])
|
||||
})
|
||||
|
||||
it('filters the same feed to processes when equals is process', () => {
|
||||
const kept = rows({ ...feedPanel, filter: { key: 'kind', equals: 'process' } })
|
||||
expect(kept.map((r) => r.name)).toEqual(['DncMain'])
|
||||
})
|
||||
|
||||
it('returns every row when no filter is declared', () => {
|
||||
const kept = rows({ ...feedPanel, filter: undefined })
|
||||
expect(kept).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('keyvalue + table + badge', () => {
|
||||
it('keyvalue reads a fields array or a plain object', () => {
|
||||
const fromFields = keyvalueFields({
|
||||
|
||||
@@ -173,7 +173,7 @@
|
||||
<input
|
||||
v-model="azureDeviceCategory"
|
||||
type="text"
|
||||
placeholder="e.g. SFLD_DYN_Device_Group_Wilmington_BUS_NC_COUNTRY:USA"
|
||||
placeholder="e.g. SFLD_DYN_Device_Group_<Site>_BUS_NC_COUNTRY:USA"
|
||||
/>
|
||||
<small class="muted">
|
||||
The Entra group/category these devices belong to. Stored on every
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
<div class="page-header">
|
||||
<h2>Computer Details</h2>
|
||||
<div class="header-actions">
|
||||
<button v-if="canCommand" @click="refreshStatus" class="btn btn-secondary" :disabled="restarting || refreshing">{{ refreshing ? 'Refreshing...' : 'Refresh Status' }}</button>
|
||||
<button v-if="canRestart" @click="confirmRestart" class="btn btn-warning" :disabled="restarting">Restart Computer</button>
|
||||
<router-link :to="`/print/asset-label/computer/${$route.params.id}`" class="btn btn-secondary" target="_blank">Print Label</router-link>
|
||||
<router-link :to="`/pcs/${$route.params.id}/edit`" class="btn btn-primary">Edit</router-link>
|
||||
<button @click="confirmRetire" class="btn btn-danger">Retire</button>
|
||||
@@ -248,7 +250,7 @@
|
||||
<CustomFieldsSection :assetid="computer.assetid" />
|
||||
|
||||
<!-- Warranty -->
|
||||
<PluginAssetPanels :assetid="computer.assetid" />
|
||||
<PluginAssetPanels ref="assetPanels" :assetid="computer.assetid" />
|
||||
|
||||
<!-- All relationships (controls, defaultprinter, ...) -->
|
||||
<AssetRelationships v-if="computer.assetid" :assetid="computer.assetid" />
|
||||
@@ -275,10 +277,10 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { colorStyle } from "@/utils/colorStyle"
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { computersApi, applicationsApi } from '@/api'
|
||||
import { computersApi, applicationsApi, endpointsApi } from '@/api'
|
||||
import { withBase } from '@/utils/basePath'
|
||||
import { useWarrantyBadge } from '@/composables/warrantyBadge'
|
||||
import LocationMapTooltip from '@/components/LocationMapTooltip.vue'
|
||||
@@ -289,15 +291,26 @@ import { useIdentifierFlags } from '@/composables/identifierSettings'
|
||||
import { setRecordTitle } from '@/composables/pageTitle'
|
||||
import { useToast } from '@/composables/toast'
|
||||
import { apiError } from '@/utils/apiError'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
const authStore = useAuthStore()
|
||||
const { isEnabled } = useIdentifierFlags()
|
||||
|
||||
const loading = ref(true)
|
||||
const computer = ref(null)
|
||||
const installedApps = ref([])
|
||||
const assetPanels = ref(null)
|
||||
const restarting = ref(false)
|
||||
const refreshing = ref(false)
|
||||
|
||||
// Header endpoint controls only make sense to users who can act on the agent.
|
||||
// Restart is gated separately because it is a destructive grant (endpoints.restart)
|
||||
// enforced again server-side; hiding the button is convenience, not the guard.
|
||||
const canCommand = computed(() => authStore.hasPermission('endpoints.command'))
|
||||
const canRestart = computed(() => authStore.hasPermission('endpoints.restart'))
|
||||
|
||||
// Warranty fetch + hero badge (shared across asset detail pages).
|
||||
const { heroWarranty, warrantyDate: formatWarrantyDate } = useWarrantyBadge(() => computer.value?.assetid)
|
||||
@@ -338,6 +351,108 @@ function formatDate(dateStr) {
|
||||
return new Date(dateStr).toLocaleString()
|
||||
}
|
||||
|
||||
// Force a live telemetry cycle rather than re-reading the cached snapshot.
|
||||
// Enqueues collect_now (the agent reports off-cadence), waits for the agent to
|
||||
// finish it, then re-fetches the panels so the cards reflect the endpoint's
|
||||
// real current state (e.g. a process the operator just closed).
|
||||
async function refreshStatus() {
|
||||
if (refreshing.value) return
|
||||
refreshing.value = true
|
||||
try {
|
||||
// Baseline the current telemetry timestamp BEFORE forcing a collect. A
|
||||
// command reaching 'succeeded' only means the agent ran collect_now, not
|
||||
// that it has posted the fresh snapshot yet, so we additionally wait for
|
||||
// lastreportedat to move past this baseline before re-rendering the panels.
|
||||
const baseline = await currentTelemetryStamp()
|
||||
const response = await endpointsApi.enqueueCommand(computer.value.assetid, 'collect_now')
|
||||
const commandId = response?.data?.data?.endpointcommandid
|
||||
const outcome = await waitForCommand(commandId)
|
||||
const fresh = outcome === 'succeeded'
|
||||
? await waitForFreshTelemetry(baseline)
|
||||
: false
|
||||
await assetPanels.value?.refresh()
|
||||
if (outcome === 'timeout') {
|
||||
toast.info('Refresh queued; the endpoint agent may be offline or busy')
|
||||
} else if (outcome === 'failed') {
|
||||
toast.error('The endpoint agent could not refresh telemetry')
|
||||
} else if (!fresh) {
|
||||
toast.info('Refresh sent; the endpoint has not reported new data yet')
|
||||
} else {
|
||||
toast.success('Status refreshed')
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(apiError(error, 'Failed to refresh status'))
|
||||
} finally {
|
||||
refreshing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Read the endpoint's current lastreportedat (ISO string or null). Used as the
|
||||
// freshness baseline; a null baseline means the box has never reported, so any
|
||||
// non-null stamp later counts as fresh.
|
||||
async function currentTelemetryStamp() {
|
||||
try {
|
||||
const response = await endpointsApi.telemetryMeta(computer.value.assetid)
|
||||
return response?.data?.data?.lastreportedat || null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Poll lastreportedat until it advances past the baseline (proof a new snapshot
|
||||
// landed) or the attempt budget runs out. Returns true only on a confirmed
|
||||
// newer stamp; a bounded false lets the caller still refresh and message that
|
||||
// the box has not reported yet, rather than hanging the button on an idle agent.
|
||||
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
|
||||
}
|
||||
|
||||
// Poll the endpoint's recent commands until the given command reaches a terminal
|
||||
// state or we give up. Returns 'succeeded' | 'failed' | 'timeout'. The agent
|
||||
// long-polls, so a live box turns this around in a couple of seconds; an offline
|
||||
// box lets it time out cleanly rather than hanging the button forever.
|
||||
async function waitForCommand(commandId, { attempts = 15, delayMs = 1000 } = {}) {
|
||||
if (!commandId) return 'timeout'
|
||||
for (let i = 0; i < attempts; i += 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, delayMs))
|
||||
try {
|
||||
const response = await endpointsApi.listCommands(computer.value.assetid)
|
||||
const command = (response?.data?.data || []).find(
|
||||
(row) => row.endpointcommandid === commandId)
|
||||
if (command && command.status === 'succeeded') return 'succeeded'
|
||||
if (command && command.status === 'failed') return 'failed'
|
||||
} catch {
|
||||
// Transient poll failure: keep trying until the attempt budget runs out.
|
||||
}
|
||||
}
|
||||
return 'timeout'
|
||||
}
|
||||
|
||||
// Destructive: queues a system_restart that reboots the PC immediately
|
||||
// (shutdown /r /t 0 on the agent). The confirm gate is the last stop before an
|
||||
// unrecoverable action; the server re-checks endpoints.restart regardless.
|
||||
async function confirmRestart() {
|
||||
const label = computer.value?.assetnumber || computer.value?.name || 'this PC'
|
||||
if (!confirm(`Restart ${label} now?\n\n`
|
||||
+ 'The computer reboots immediately with no grace period. '
|
||||
+ 'Any unsaved work on it will be lost.')) return
|
||||
restarting.value = true
|
||||
try {
|
||||
await endpointsApi.enqueueCommand(computer.value.assetid, 'system_restart')
|
||||
toast.success(`Restart queued for ${label}`)
|
||||
} catch (error) {
|
||||
toast.error(apiError(error, 'Failed to queue restart'))
|
||||
} finally {
|
||||
restarting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Retire, not delete: the server deactivates the asset, so the PC
|
||||
// leaves every list and report while its history and relationships stay put.
|
||||
// Reversible by an admin setting isactive back to true.
|
||||
|
||||
@@ -11,7 +11,7 @@ import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from flask import Blueprint, request, current_app
|
||||
from flask_jwt_extended import jwt_required, current_user
|
||||
from flask_jwt_extended import current_user
|
||||
|
||||
from shopdb.api import (
|
||||
db, Asset, Setting, success_response, error_response, paginated_response,
|
||||
@@ -21,7 +21,7 @@ from shopdb.api import (
|
||||
)
|
||||
|
||||
from ..models import EndpointState, EndpointEvent, EndpointCommand
|
||||
from ..commands import COMMAND_ALLOWLIST, is_allowed_action, ALLOWED_ACTIONS_FOR, row_actions_for, requires_target, action_targetkind
|
||||
from ..commands import COMMAND_ALLOWLIST, is_allowed_action, ALLOWED_ACTIONS_FOR, row_actions_for, requires_target, action_targetkind, elevated_permission_for
|
||||
from ..eventcatalog import CRITICAL_EVENT_IDS, event_severity, event_title
|
||||
|
||||
endpoints_bp = Blueprint('endpoints', __name__)
|
||||
@@ -56,7 +56,6 @@ def _format_uptime(seconds):
|
||||
# =============================================================================
|
||||
|
||||
@endpoints_bp.route('/', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('endpoints.view')
|
||||
def list_endpoints():
|
||||
"""List reported endpoint states.
|
||||
@@ -91,7 +90,6 @@ def list_endpoints():
|
||||
|
||||
|
||||
@endpoints_bp.route('/<int:asset_id>', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('endpoints.view')
|
||||
def get_endpoint(asset_id: int):
|
||||
"""Get the current state for one endpoint by asset id."""
|
||||
@@ -108,7 +106,6 @@ def get_endpoint(asset_id: int):
|
||||
|
||||
|
||||
@endpoints_bp.route('/<int:asset_id>/events', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('endpoints.view')
|
||||
def list_endpoint_events(asset_id: int):
|
||||
"""List curated Windows events for one endpoint, newest first."""
|
||||
@@ -136,7 +133,6 @@ def list_endpoint_events(asset_id: int):
|
||||
# =============================================================================
|
||||
|
||||
@endpoints_bp.route('/asset/<int:asset_id>/status', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('endpoints.view')
|
||||
def panel_status(asset_id: int):
|
||||
"""Keyvalue panel: the endpoint's current status snapshot.
|
||||
@@ -168,8 +164,25 @@ def panel_status(asset_id: int):
|
||||
return success_response({'fields': fields})
|
||||
|
||||
|
||||
@endpoints_bp.route('/asset/<int:asset_id>/telemetrymeta', methods=['GET'])
|
||||
@require_permission('endpoints.view')
|
||||
def panel_telemetry_meta(asset_id: int):
|
||||
"""Lightweight freshness probe for the panels.
|
||||
|
||||
Returns just the endpoint's lastreportedat so the WebUI can tell whether a
|
||||
genuinely new telemetry snapshot has landed after it forces a collect. The
|
||||
UI captures this before enqueuing collect_now (or a row Start/Stop), then
|
||||
polls until the stamp advances - a command reaching 'succeeded' only means
|
||||
the agent ran it, not that it has yet posted the fresh services snapshot, so
|
||||
gating the refresh on this timestamp is what avoids re-rendering stale rows.
|
||||
"""
|
||||
state = EndpointState.query.filter_by(assetid=asset_id).first()
|
||||
stamp = (state.lastreportedat.isoformat() + 'Z'
|
||||
if state and state.lastreportedat else None)
|
||||
return success_response({'lastreportedat': stamp})
|
||||
|
||||
|
||||
@endpoints_bp.route('/asset/<int:asset_id>/services', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('endpoints.view')
|
||||
def panel_services(asset_id: int):
|
||||
"""List panel: monitored services/processes with a run-state badge per row.
|
||||
@@ -199,6 +212,7 @@ def panel_services(asset_id: int):
|
||||
running = raw in ('running', 'started', 'up', 'ok', 'active')
|
||||
row = {
|
||||
'name': name,
|
||||
'kind': (kind or '').lower() or None,
|
||||
'statelabel': 'Running' if running else (raw.title() or 'Unknown'),
|
||||
'statecolor': 'green' if running else ('red' if raw else 'gray'),
|
||||
}
|
||||
@@ -217,7 +231,6 @@ def panel_services(asset_id: int):
|
||||
|
||||
|
||||
@endpoints_bp.route('/asset/<int:asset_id>/recentevents', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('endpoints.view')
|
||||
def panel_recent_events(asset_id: int):
|
||||
"""List panel: the endpoint's most recent curated events, newest first.
|
||||
@@ -241,7 +254,6 @@ def panel_recent_events(asset_id: int):
|
||||
|
||||
|
||||
@endpoints_bp.route('/asset/<int:asset_id>/interfaces', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('endpoints.view')
|
||||
def panel_interfaces(asset_id: int):
|
||||
"""Table panel: every hardware NIC the agent last reported.
|
||||
@@ -287,7 +299,6 @@ def panel_interfaces(asset_id: int):
|
||||
# =============================================================================
|
||||
|
||||
@endpoints_bp.route('/dashboard/summary', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('endpoints.view')
|
||||
def dashboard_summary():
|
||||
"""High-level fleet counts for the endpoints dashboard."""
|
||||
@@ -315,7 +326,6 @@ def dashboard_summary():
|
||||
|
||||
|
||||
@endpoints_bp.route('/dashboard/quiet', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('endpoints.view')
|
||||
def dashboard_quiet():
|
||||
"""Endpoints that reported before, then went quiet past the window.
|
||||
@@ -351,7 +361,6 @@ def dashboard_quiet():
|
||||
|
||||
|
||||
@endpoints_bp.route('/dashboard/stability', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('endpoints.view')
|
||||
def dashboard_stability():
|
||||
"""Endpoints with recent crash/shutdown/corruption events.
|
||||
@@ -441,7 +450,6 @@ def _agentkey_status_payload():
|
||||
|
||||
|
||||
@endpoints_bp.route('/agentkey', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_role('admin')
|
||||
def get_agentkey():
|
||||
"""Report the endpoint agent key state (no secret)."""
|
||||
@@ -449,7 +457,6 @@ def get_agentkey():
|
||||
|
||||
|
||||
@endpoints_bp.route('/agentkey/regenerate', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_role('admin')
|
||||
def regenerate_agentkey():
|
||||
"""Mint a fresh endpoint agent key, revoking any previous one.
|
||||
@@ -480,7 +487,6 @@ def regenerate_agentkey():
|
||||
|
||||
|
||||
@endpoints_bp.route('/agentkey', methods=['DELETE'])
|
||||
@jwt_required()
|
||||
@require_role('admin')
|
||||
def revoke_agentkey():
|
||||
"""Revoke the endpoint agent key: stop all managed-token endpoint traffic.
|
||||
@@ -534,7 +540,6 @@ def _resolve_asset_for_hostname(hostname):
|
||||
|
||||
|
||||
@endpoints_bp.route('/<int:asset_id>/commands', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('endpoints.view')
|
||||
def list_endpoint_commands(asset_id: int):
|
||||
"""Recent commands for one endpoint, newest first (operator view)."""
|
||||
@@ -547,7 +552,6 @@ def list_endpoint_commands(asset_id: int):
|
||||
|
||||
|
||||
@endpoints_bp.route('/<int:asset_id>/commands', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('endpoints.command')
|
||||
def enqueue_endpoint_command(asset_id: int):
|
||||
"""Queue an allowlisted command for one endpoint.
|
||||
@@ -562,6 +566,16 @@ def enqueue_endpoint_command(asset_id: int):
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
f'Unknown or disallowed action: {action!r}')
|
||||
|
||||
# Destructive actions (for example system_restart) demand a stricter grant
|
||||
# ON TOP OF endpoints.command, so a remote reboot cannot ride the routine
|
||||
# toggle permission.
|
||||
elevated = elevated_permission_for(action)
|
||||
if elevated and not current_user.haspermission(elevated):
|
||||
return error_response(
|
||||
ErrorCodes.FORBIDDEN,
|
||||
f'Action {action!r} requires the {elevated} permission',
|
||||
http_code=403)
|
||||
|
||||
if action not in ALLOWED_ACTIONS_FOR(assetid=asset_id):
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
|
||||
@@ -69,9 +69,30 @@ COMMAND_ALLOWLIST = {
|
||||
'label': 'Check MTConnect',
|
||||
'description': 'Report whether the MTConnect UDC agent is running.',
|
||||
},
|
||||
'system_restart': {
|
||||
'label': 'Restart computer',
|
||||
'description': 'Restart the endpoint computer immediately.',
|
||||
},
|
||||
'collect_now': {
|
||||
'label': 'Collect now',
|
||||
'description': 'Force the endpoint agent to report fresh telemetry off-cadence.',
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# Actions that demand a permission stricter than the base endpoints.command.
|
||||
# The enqueue route requires the caller to ALSO hold the mapped permission, so a
|
||||
# destructive action (a remote reboot) is a heavier grant than a routine toggle.
|
||||
ELEVATED_ACTION_PERMISSIONS = {
|
||||
'system_restart': 'endpoints.restart',
|
||||
}
|
||||
|
||||
|
||||
def elevated_permission_for(action):
|
||||
"""The extra permission an action requires, or None for routine actions."""
|
||||
return ELEVATED_ACTION_PERMISSIONS.get(action)
|
||||
|
||||
|
||||
# Maps a monitored services/processes row to the start/stop/status actions that
|
||||
# control it, so the services panel can render an inline toggle on exactly the
|
||||
# rows that have a registered command action. The key is the agent-reported
|
||||
|
||||
@@ -108,6 +108,11 @@ class EndpointsPlugin(BasePlugin):
|
||||
# shop-floor PC is a heavier grant than reading its telemetry.
|
||||
('endpoints.command',
|
||||
'Send allowlisted commands to endpoint agents', 'endpoints'),
|
||||
# Elevated grant for destructive endpoint actions (system_restart).
|
||||
# Required IN ADDITION to endpoints.command by the enqueue route, so a
|
||||
# remote reboot is a strictly heavier grant than a routine toggle.
|
||||
('endpoints.restart',
|
||||
'Restart endpoint computers (destructive)', 'endpoints'),
|
||||
# Collector service scope for the endpoint agent. A managed token
|
||||
# scoped to ONLY this authorizes the endpoints collector and nothing
|
||||
# else, so rotating/revoking it stops endpoint-agent traffic without
|
||||
@@ -181,6 +186,8 @@ class EndpointsPlugin(BasePlugin):
|
||||
'icon': 'activity',
|
||||
'route': '/endpoints',
|
||||
'position': 16,
|
||||
'permission': 'endpoints.view',
|
||||
'section': 'fleet',
|
||||
},
|
||||
]
|
||||
|
||||
@@ -192,11 +199,13 @@ class EndpointsPlugin(BasePlugin):
|
||||
def get_asset_panels(self) -> List[Dict]:
|
||||
"""Per-PC detail panels, computer assets only.
|
||||
|
||||
Three panels feeding the generic renderer: the current status snapshot
|
||||
(keyvalue), monitored services with a run-state badge (list), and the
|
||||
most recent curated events (list). Each is a single-asset indexed read,
|
||||
so the cost is the same profile as the warranty/backups panels. Panels
|
||||
hide themselves when their endpoint returns nothing.
|
||||
Panels feeding the generic renderer: the current status snapshot
|
||||
(keyvalue), monitored services and processes split into two list cards
|
||||
that share one services snapshot (filtered by kind), the most recent
|
||||
curated events (list), and the network interfaces (table). Each is a
|
||||
single-asset indexed read, so the cost is the same profile as the
|
||||
warranty/backups panels. Panels hide themselves when their endpoint
|
||||
returns nothing.
|
||||
"""
|
||||
return [
|
||||
{
|
||||
@@ -209,12 +218,13 @@ class EndpointsPlugin(BasePlugin):
|
||||
'position': 10,
|
||||
},
|
||||
{
|
||||
'id': 'endpoints-services',
|
||||
'title': 'Services and processes',
|
||||
'id': 'endpoints-tracked-services',
|
||||
'title': 'Tracked Services',
|
||||
'assettypes': ['computer'],
|
||||
'endpoint': '/api/endpoints/asset/{assetid}/services',
|
||||
'render': 'list',
|
||||
'collapsible': True,
|
||||
'filter': {'key': 'kind', 'equals': 'service'},
|
||||
'status': {
|
||||
'key': 'statecolor',
|
||||
'levels': {
|
||||
@@ -233,6 +243,32 @@ class EndpointsPlugin(BasePlugin):
|
||||
'empty': 'No monitored services reported.',
|
||||
'position': 12,
|
||||
},
|
||||
{
|
||||
'id': 'endpoints-tracked-processes',
|
||||
'title': 'Tracked Processes',
|
||||
'assettypes': ['computer'],
|
||||
'endpoint': '/api/endpoints/asset/{assetid}/services',
|
||||
'render': 'list',
|
||||
'collapsible': True,
|
||||
'filter': {'key': 'kind', 'equals': 'process'},
|
||||
'status': {
|
||||
'key': 'statecolor',
|
||||
'levels': {
|
||||
'green': 'ok',
|
||||
'yellow': 'warning',
|
||||
'red': 'critical',
|
||||
},
|
||||
},
|
||||
'map': {
|
||||
'title': 'name',
|
||||
'badge': {'label': 'statelabel', 'color': 'statecolor'},
|
||||
'action': 'action',
|
||||
},
|
||||
'commandendpoint': '/api/endpoints/{assetid}/commands',
|
||||
'permission': 'endpoints.command',
|
||||
'empty': 'No monitored processes reported.',
|
||||
'position': 13,
|
||||
},
|
||||
{
|
||||
'id': 'endpoints-events',
|
||||
'title': 'Recent events',
|
||||
@@ -263,7 +299,8 @@ class EndpointsPlugin(BasePlugin):
|
||||
],
|
||||
},
|
||||
'empty': 'No recent events.',
|
||||
'position': 14,
|
||||
'collapsedDefault': True,
|
||||
'position': 90,
|
||||
},
|
||||
{
|
||||
'id': 'endpoints-interfaces',
|
||||
|
||||
@@ -9,6 +9,10 @@ from flask import Blueprint
|
||||
|
||||
from plugins.endpoints.plugin import EndpointsPlugin, CURATED_EVENT_IDS
|
||||
from plugins.endpoints.eventcatalog import EVENT_CATALOG, CRITICAL_EVENT_IDS
|
||||
from plugins.endpoints.commands import (
|
||||
COMMAND_ALLOWLIST, is_allowed_action, elevated_permission_for,
|
||||
requires_target,
|
||||
)
|
||||
|
||||
|
||||
def test_endpoints_plugin_meta_is_valid():
|
||||
@@ -65,11 +69,41 @@ def test_endpoints_asset_panels_are_computer_scoped():
|
||||
"""Every asset panel targets computer assets only, with an endpoint."""
|
||||
plugin = EndpointsPlugin()
|
||||
panels = plugin.get_asset_panels()
|
||||
assert len(panels) == 3
|
||||
assert len(panels) == 5
|
||||
for panel in panels:
|
||||
assert panel['assettypes'] == ['computer']
|
||||
assert panel['endpoint'].startswith('/api/endpoints/asset/{assetid}')
|
||||
assert panel['render'] in ('keyvalue', 'list')
|
||||
assert panel['render'] in ('keyvalue', 'list', 'table')
|
||||
|
||||
|
||||
def test_services_and_processes_are_split_into_two_filtered_cards():
|
||||
"""The single services card is now two cards filtered by row kind, both
|
||||
reading the same services snapshot so a future top-20 collector is just
|
||||
another filtered panel over the same feed."""
|
||||
plugin = EndpointsPlugin()
|
||||
panels = {p['id']: p for p in plugin.get_asset_panels()}
|
||||
services = panels['endpoints-tracked-services']
|
||||
processes = panels['endpoints-tracked-processes']
|
||||
assert services['title'] == 'Tracked Services'
|
||||
assert processes['title'] == 'Tracked Processes'
|
||||
assert services['filter'] == {'key': 'kind', 'equals': 'service'}
|
||||
assert processes['filter'] == {'key': 'kind', 'equals': 'process'}
|
||||
# Both slice one endpoint feed.
|
||||
assert services['endpoint'] == processes['endpoint']
|
||||
assert services['endpoint'].endswith('/services')
|
||||
# Command wiring carries over to both cards.
|
||||
assert services['permission'] == 'endpoints.command'
|
||||
assert processes['permission'] == 'endpoints.command'
|
||||
|
||||
|
||||
def test_recent_events_card_is_last_and_starts_collapsed():
|
||||
"""Events is the lowest-priority card: forced to the bottom of the column
|
||||
and collapsed by default."""
|
||||
plugin = EndpointsPlugin()
|
||||
panels = plugin.get_asset_panels()
|
||||
events = next(p for p in panels if p['id'] == 'endpoints-events')
|
||||
assert events['collapsedDefault'] is True
|
||||
assert events['position'] == max(p['position'] for p in panels)
|
||||
|
||||
|
||||
def test_endpoints_dashboard_widgets_declared():
|
||||
@@ -90,3 +124,48 @@ def test_endpoints_navigation_item_points_at_fleet_view():
|
||||
items = plugin.get_navigation_items()
|
||||
routes = {item['route'] for item in items}
|
||||
assert '/endpoints' in routes
|
||||
|
||||
|
||||
def test_system_restart_is_allowlisted_and_policy_selectable():
|
||||
"""Restart is a real allowlisted action, so the policy builder (which
|
||||
reads COMMAND_ALLOWLIST) surfaces it as a selectable action."""
|
||||
assert is_allowed_action('system_restart')
|
||||
assert 'system_restart' in COMMAND_ALLOWLIST
|
||||
assert COMMAND_ALLOWLIST['system_restart']['label'] == 'Restart computer'
|
||||
|
||||
|
||||
def test_system_restart_requires_the_elevated_restart_permission():
|
||||
"""Restart maps to endpoints.restart so the enqueue route can demand a
|
||||
heavier grant than the routine endpoints.command toggle. Routine actions
|
||||
carry no elevated requirement."""
|
||||
assert elevated_permission_for('system_restart') == 'endpoints.restart'
|
||||
assert elevated_permission_for('mtconnect_start') is None
|
||||
|
||||
|
||||
def test_endpoints_declares_the_restart_permission():
|
||||
"""The dedicated restart permission is declared for role-grid seeding."""
|
||||
plugin = EndpointsPlugin()
|
||||
permissions = {name for name, *_ in plugin.get_permissions()}
|
||||
assert 'endpoints.restart' in permissions
|
||||
assert 'endpoints.command' in permissions
|
||||
|
||||
|
||||
def test_collect_now_is_allowlisted_targetless_and_routine():
|
||||
"""collect_now forces an off-cadence telemetry report. It is allowlisted so
|
||||
the enqueue route accepts it, carries no wire target (fixed action), and
|
||||
needs no elevated grant beyond endpoints.command."""
|
||||
assert is_allowed_action('collect_now')
|
||||
assert 'collect_now' in COMMAND_ALLOWLIST
|
||||
assert not requires_target('collect_now')
|
||||
assert elevated_permission_for('collect_now') is None
|
||||
|
||||
|
||||
def test_telemetrymeta_route_is_registered_for_freshness_gating():
|
||||
"""The WebUI gates panel refreshes on lastreportedat advancing, so the
|
||||
lightweight freshness probe must be exposed on the plugin blueprint."""
|
||||
from flask import Flask
|
||||
|
||||
application = Flask(__name__)
|
||||
application.register_blueprint(EndpointsPlugin().get_blueprint())
|
||||
rules = {rule.rule for rule in application.url_map.iter_rules()}
|
||||
assert any(rule.endswith('/asset/<int:asset_id>/telemetrymeta') for rule in rules)
|
||||
|
||||
@@ -87,7 +87,11 @@ $ErrorActionPreference = 'Stop'
|
||||
# =============================================================================
|
||||
|
||||
$LocalStageRoot = Join-Path $env:ProgramData 'ShopDB\intake'
|
||||
$NetworkStoreRoot = '\\wi001dataaege.wlm.geaerospace.net\shared\TEAMSYS\.sfscripts\shopdb'
|
||||
# Ship blank so a site that has not configured its share keeps everything local
|
||||
# instead of publishing to another site's path. Set this to your own share (or
|
||||
# pass -PublishRoot), e.g. '\\fileserver\shopdb$\intake'. The download endpoint
|
||||
# fills this in from asset_import_csv_location when the site has set it.
|
||||
$NetworkStoreRoot = ''
|
||||
|
||||
# Resolve effective destinations (command-line params win over CONFIG above).
|
||||
if (-not $OutputRoot) { $OutputRoot = $LocalStageRoot }
|
||||
@@ -531,9 +535,57 @@ if ($PublishRoot) {
|
||||
Write-Host ' WARNING: publishing while elevated. Network writes should run as the standard user.' -ForegroundColor Yellow
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath $PublishRoot)) { New-Item -ItemType Directory -Path $PublishRoot -Force | Out-Null }
|
||||
Copy-Item -Path (Join-Path $OutputRoot '*') -Destination $PublishRoot -Recurse -Force
|
||||
Write-Host ''
|
||||
Write-Host (' Published store to: ' + $PublishRoot) -ForegroundColor Green
|
||||
|
||||
# Merge THIS PC's rows into the shared store rather than overwriting it. A
|
||||
# blind copy would erase every other PC's rows (issue: the store is meant to
|
||||
# accumulate site-wide). Merge-CsvStore upserts field-by-field, so a second
|
||||
# PC's run adds its rows and refreshes shared ones without dropping the rest.
|
||||
# A lock file serializes concurrent publishers so two PCs cannot read-modify-
|
||||
# write the same store CSV at once and lose each other's rows.
|
||||
$lockPath = Join-Path $PublishRoot '.publish.lock'
|
||||
$lockStream = $null
|
||||
for ($attempt = 0; $attempt -lt 60; $attempt++) {
|
||||
try {
|
||||
$lockStream = [System.IO.File]::Open(
|
||||
$lockPath, [System.IO.FileMode]::OpenOrCreate,
|
||||
[System.IO.FileAccess]::ReadWrite, [System.IO.FileShare]::None)
|
||||
break
|
||||
} catch {
|
||||
Start-Sleep -Milliseconds 500
|
||||
}
|
||||
}
|
||||
if ($null -eq $lockStream) {
|
||||
Write-Host ''
|
||||
Write-Host (' WARNING: could not acquire publish lock at ' + $lockPath + '; skipping network merge.') -ForegroundColor Yellow
|
||||
} else {
|
||||
try {
|
||||
foreach ($fileName in $script:Stores.Keys) {
|
||||
$spec = $script:Stores[$fileName]
|
||||
$localPath = Join-Path $OutputRoot $fileName
|
||||
if (-not (Test-Path -LiteralPath $localPath)) { continue }
|
||||
$localRows = @(Import-Csv -LiteralPath $localPath)
|
||||
if ($localRows.Count -eq 0) { continue }
|
||||
$sharePath = Join-Path $PublishRoot $fileName
|
||||
$n = Merge-CsvStore -Path $sharePath -Header $spec.header -Key $spec.key -IncomingRows $localRows
|
||||
Write-Host (' merged ' + $n + ' row(s) into ' + $fileName + ' on share') -ForegroundColor DarkGray
|
||||
}
|
||||
|
||||
# Sidecars are uniquely named per asset, so a straight copy is safe and
|
||||
# nothing is overwritten across PCs.
|
||||
$localSidecars = Join-Path $OutputRoot 'sidecars'
|
||||
if (Test-Path -LiteralPath $localSidecars) {
|
||||
$shareSidecars = Join-Path $PublishRoot 'sidecars'
|
||||
if (-not (Test-Path -LiteralPath $shareSidecars)) { New-Item -ItemType Directory -Path $shareSidecars -Force | Out-Null }
|
||||
Copy-Item -Path (Join-Path $localSidecars '*') -Destination $shareSidecars -Recurse -Force
|
||||
}
|
||||
} finally {
|
||||
$lockStream.Close()
|
||||
$lockStream.Dispose()
|
||||
Remove-Item -LiteralPath $lockPath -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
Write-Host ''
|
||||
Write-Host (' Published store to: ' + $PublishRoot) -ForegroundColor Green
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host ''
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
assetid,computertypeid,hostname,osid,vendorid,modelnumberid,loggedinuser
|
||||
SAMPLE-PC-01,Shopfloor PC,WKS-SAMPLE01,Windows 10 Enterprise,Dell,OptiPlex 7090,AEROAD\operator1
|
||||
SAMPLE-PC-02,CMM PC,WKS-SAMPLE02,Windows 11 Enterprise,Dell,Precision 3660,AEROAD\operator2
|
||||
SAMPLE-PC-01,Shopfloor PC,WKS-SAMPLE01,Windows 10 Enterprise,Dell,OptiPlex 7090,EXAMPLE\operator1
|
||||
SAMPLE-PC-02,CMM PC,WKS-SAMPLE02,Windows 11 Enterprise,Dell,Precision 3660,EXAMPLE\operator2
|
||||
|
||||
|
@@ -260,10 +260,12 @@ _COLLECTOR_SCRIPT = os.path.join(
|
||||
def _embed_store_path(script_text, path):
|
||||
"""Return the collector script with $NetworkStoreRoot set to path.
|
||||
|
||||
The script ships with a site-specific default on that line; rewriting it
|
||||
means an operator downloads a script already pointed at this facility's CSV
|
||||
store and does not have to edit PowerShell by hand. A blank path leaves the
|
||||
shipped default untouched.
|
||||
The script ships with a BLANK default on that line; rewriting it means an
|
||||
operator downloads a script already pointed at this facility's CSV store and
|
||||
does not have to edit PowerShell by hand. A blank path leaves the shipped
|
||||
blank default untouched, so a site that has not configured
|
||||
asset_import_csv_location keeps everything local instead of inheriting
|
||||
another facility's share.
|
||||
"""
|
||||
if not path:
|
||||
return script_text
|
||||
@@ -281,7 +283,6 @@ def _embed_store_path(script_text, path):
|
||||
|
||||
|
||||
@settings_bp.route('/asset-import/script', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_role('admin')
|
||||
def download_collector_script():
|
||||
"""Download Collect-ShopDB.ps1 with this site's CSV path embedded."""
|
||||
@@ -300,7 +301,6 @@ def download_collector_script():
|
||||
|
||||
|
||||
@settings_bp.route('/asset-import/templates', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_role('admin')
|
||||
def list_import_templates():
|
||||
"""List the importable CSV tables in the order they must be uploaded."""
|
||||
@@ -312,7 +312,6 @@ def list_import_templates():
|
||||
|
||||
|
||||
@settings_bp.route('/asset-import/templates/<tablename>', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_role('admin')
|
||||
def download_import_template(tablename):
|
||||
"""Download a generated CSV template for a single importable table."""
|
||||
@@ -356,7 +355,6 @@ def _existing_hostname_map():
|
||||
|
||||
|
||||
@settings_bp.route('/asset-import/import', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_role('admin')
|
||||
def import_asset_csvs():
|
||||
"""Validate uploaded CSVs and, when commit=true, load them.
|
||||
|
||||
@@ -72,7 +72,7 @@ class AzureDeviceGroupSource:
|
||||
BUS). Resolved by the engine against computertypes.
|
||||
category - optional Entra device category / group membership label
|
||||
stamped onto every computer's azuredevicecategory (e.g.
|
||||
SFLD_DYN_Device_Group_Wilmington_BUS_NC_COUNTRY:USA).
|
||||
SFLD_DYN_Device_Group_<Site>_BUS_NC_COUNTRY:USA).
|
||||
hostname_map - optional {lowercased hostname: existing assetnumber}. A
|
||||
device whose displayName matches an existing computer's
|
||||
hostname is routed to that asset and updated in place
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
},
|
||||
{
|
||||
"modeltype": "Camera",
|
||||
"category": "Network"
|
||||
"category": "Network",
|
||||
"description": "Security camera for facility monitoring"
|
||||
},
|
||||
{
|
||||
"modeltype": "CMM",
|
||||
@@ -44,7 +45,8 @@
|
||||
},
|
||||
{
|
||||
"modeltype": "Eddy Current",
|
||||
"category": "Equipment"
|
||||
"category": "Equipment",
|
||||
"description": "Wild Stallions will never be a super band until we have Eddie Van Halen on guitar."
|
||||
},
|
||||
{
|
||||
"modeltype": "EDM",
|
||||
@@ -74,7 +76,8 @@
|
||||
},
|
||||
{
|
||||
"modeltype": "IDF",
|
||||
"category": "Network"
|
||||
"category": "Network",
|
||||
"description": "Intermediate Distribution Frame - network equipment closet"
|
||||
},
|
||||
{
|
||||
"modeltype": "Inspection",
|
||||
@@ -96,7 +99,8 @@
|
||||
},
|
||||
{
|
||||
"modeltype": "Lathe",
|
||||
"category": "Equipment"
|
||||
"category": "Equipment",
|
||||
"description": "The Okuma & Howa 2SPV80 is a CNC machine renowned for its precision and efficiency in manufacturing processes. It is widely utilized across various sectors, facilitating the production of intricate components with ease. Industries such as automotive, aerospace, and metalworking benefit from its capabilities. "
|
||||
},
|
||||
{
|
||||
"modeltype": "Measuring Machine",
|
||||
@@ -108,7 +112,8 @@
|
||||
},
|
||||
{
|
||||
"modeltype": "Part Marker",
|
||||
"category": "Equipment"
|
||||
"category": "Equipment",
|
||||
"description": "Dot peen markers provide a reliable, permanent method for marking materials with information that is crucial for product identification and traceability in various industrial applications."
|
||||
},
|
||||
{
|
||||
"modeltype": "Part Washer",
|
||||
@@ -116,11 +121,13 @@
|
||||
},
|
||||
{
|
||||
"modeltype": "PC",
|
||||
"category": "PC"
|
||||
"category": "PC",
|
||||
"description": "Standard user workstation computer"
|
||||
},
|
||||
{
|
||||
"modeltype": "Printer",
|
||||
"category": "Printer"
|
||||
"category": "Printer",
|
||||
"description": "Network printer - HP, Xerox, or other print devices"
|
||||
},
|
||||
{
|
||||
"modeltype": "Robotic Deburring",
|
||||
@@ -138,7 +145,8 @@
|
||||
},
|
||||
{
|
||||
"modeltype": "Shotpeen",
|
||||
"category": "Equipment"
|
||||
"category": "Equipment",
|
||||
"description": "Shot peening is a cold working process used to produce a compressive residual stress layer and modify the mechanical properties of metals and composites."
|
||||
},
|
||||
{
|
||||
"modeltype": "Spline Checker",
|
||||
@@ -175,7 +183,8 @@
|
||||
},
|
||||
{
|
||||
"machinetype": "CMM",
|
||||
"description": "Coordinate Measuring Machine"
|
||||
"description": "Coordinate Measuring Machine",
|
||||
"color": "#8e44ad"
|
||||
},
|
||||
{
|
||||
"machinetype": "CNC",
|
||||
@@ -250,7 +259,8 @@
|
||||
],
|
||||
"computertypes": [
|
||||
{
|
||||
"computertype": "CMM"
|
||||
"computertype": "CMM",
|
||||
"color": "#7934f3"
|
||||
},
|
||||
{
|
||||
"computertype": "CMM PC",
|
||||
@@ -337,7 +347,8 @@
|
||||
"printertypes": [
|
||||
{
|
||||
"printertype": "Card",
|
||||
"description": "ID / card printer"
|
||||
"description": "ID / card printer",
|
||||
"color": "#2dce89"
|
||||
},
|
||||
{
|
||||
"printertype": "Color Laser Printer",
|
||||
@@ -547,11 +558,15 @@
|
||||
{
|
||||
"vendor": "ABTech"
|
||||
},
|
||||
{
|
||||
"vendor": "Allen-Bradley"
|
||||
},
|
||||
{
|
||||
"vendor": "Broach"
|
||||
},
|
||||
{
|
||||
"vendor": "Brother"
|
||||
"vendor": "Brother",
|
||||
"website": "https://www.brother.com"
|
||||
},
|
||||
{
|
||||
"vendor": "Brown & Sharpe"
|
||||
@@ -652,6 +667,9 @@
|
||||
{
|
||||
"vendor": "Ransohoff"
|
||||
},
|
||||
{
|
||||
"vendor": "Siemens"
|
||||
},
|
||||
{
|
||||
"vendor": "Telesis"
|
||||
},
|
||||
@@ -681,7 +699,8 @@
|
||||
{
|
||||
"modelnumber": "EAS1000",
|
||||
"vendor": "ABTech",
|
||||
"modeltype": "Measuring Machine"
|
||||
"modeltype": "Measuring Machine",
|
||||
"imageurl": "/images/models/machines/abtech-eas1000.png"
|
||||
},
|
||||
{
|
||||
"modelnumber": "Vertical Broach",
|
||||
@@ -701,7 +720,8 @@
|
||||
{
|
||||
"modelnumber": "1000C1000",
|
||||
"vendor": "Campbell",
|
||||
"modeltype": "Grinder"
|
||||
"modeltype": "Grinder",
|
||||
"imageurl": "/images/models/machines/1000C1000.jpg"
|
||||
},
|
||||
{
|
||||
"modelnumber": "6400",
|
||||
@@ -716,7 +736,8 @@
|
||||
{
|
||||
"modelnumber": "9120AXI-B",
|
||||
"vendor": "Cisco",
|
||||
"modeltype": "Access Point"
|
||||
"modeltype": "Access Point",
|
||||
"imageurl": "/images/models/machines/Cisco9120.png"
|
||||
},
|
||||
{
|
||||
"modelnumber": "C9200-48P-E",
|
||||
@@ -751,12 +772,14 @@
|
||||
{
|
||||
"modelnumber": "Latitude 5440",
|
||||
"vendor": "Dell Inc.",
|
||||
"modeltype": "Laptop"
|
||||
"modeltype": "Laptop",
|
||||
"imageurl": "/images/models/machines/latitude5440.png"
|
||||
},
|
||||
{
|
||||
"modelnumber": "Latitude 5450",
|
||||
"vendor": "Dell Inc.",
|
||||
"modeltype": "Laptop"
|
||||
"modeltype": "Laptop",
|
||||
"imageurl": "/images/models/computers/Latitude-5450.png"
|
||||
},
|
||||
{
|
||||
"modelnumber": "Latitude 7230 Rugged Extreme Tablet",
|
||||
@@ -801,27 +824,32 @@
|
||||
{
|
||||
"modelnumber": "OptiPlex 5050",
|
||||
"vendor": "Dell Inc.",
|
||||
"modeltype": "PC"
|
||||
"modeltype": "PC",
|
||||
"imageurl": "/images/models/computers/Optiplex-5050.png"
|
||||
},
|
||||
{
|
||||
"modelnumber": "OptiPlex 5060",
|
||||
"vendor": "Dell Inc.",
|
||||
"modeltype": "PC"
|
||||
"modeltype": "PC",
|
||||
"imageurl": "/images/models/computers/Optiplex-5060.png"
|
||||
},
|
||||
{
|
||||
"modelnumber": "OptiPlex 7000",
|
||||
"vendor": "Dell Inc.",
|
||||
"modeltype": "PC"
|
||||
"modeltype": "PC",
|
||||
"imageurl": "/images/models/computers/Optiplex-7000.png"
|
||||
},
|
||||
{
|
||||
"modelnumber": "OptiPlex 7070",
|
||||
"vendor": "Dell Inc.",
|
||||
"modeltype": "PC"
|
||||
"modeltype": "PC",
|
||||
"imageurl": "/images/models/machines/OptiPlex-7070.png"
|
||||
},
|
||||
{
|
||||
"modelnumber": "OptiPlex 7080",
|
||||
"vendor": "Dell Inc.",
|
||||
"modeltype": "PC"
|
||||
"modeltype": "PC",
|
||||
"imageurl": "/images/models/computers/Optiplex-7080.jpg"
|
||||
},
|
||||
{
|
||||
"modelnumber": "OptiPlex 7090",
|
||||
@@ -871,7 +899,8 @@
|
||||
{
|
||||
"modelnumber": "OptiPlex Tower Plus 7010",
|
||||
"vendor": "Dell Inc.",
|
||||
"modeltype": "PC"
|
||||
"modeltype": "PC",
|
||||
"imageurl": "/images/models/computers/OptiPlex-Tower-Plus-7010.png"
|
||||
},
|
||||
{
|
||||
"modelnumber": "OptiPlex Tower Plus 7020",
|
||||
@@ -901,7 +930,8 @@
|
||||
{
|
||||
"modelnumber": "Precision 5560",
|
||||
"vendor": "Dell Inc.",
|
||||
"modeltype": "Laptop"
|
||||
"modeltype": "Laptop",
|
||||
"imageurl": "/images/models/machines/precision5560.jpg"
|
||||
},
|
||||
{
|
||||
"modelnumber": "Precision 5570",
|
||||
@@ -1046,22 +1076,26 @@
|
||||
{
|
||||
"modelnumber": "NT-4300",
|
||||
"vendor": "DMG Mori",
|
||||
"modeltype": "Mill Turn"
|
||||
"modeltype": "Mill Turn",
|
||||
"imageurl": "/images/models/machines/nt4300.jpg"
|
||||
},
|
||||
{
|
||||
"modelnumber": "Puma MX3100",
|
||||
"vendor": "Doosan",
|
||||
"modeltype": "Mill Turn"
|
||||
"modeltype": "Mill Turn",
|
||||
"imageurl": "/images/models/machines/mx3100.png"
|
||||
},
|
||||
{
|
||||
"modelnumber": "EZ-Eddy",
|
||||
"vendor": "Eddy",
|
||||
"modeltype": "Eddy Current"
|
||||
"modeltype": "Eddy Current",
|
||||
"imageurl": "/images/models/machines/ezeddy.png"
|
||||
},
|
||||
{
|
||||
"modelnumber": "TM-C3500",
|
||||
"vendor": "Epson",
|
||||
"modeltype": "Printer"
|
||||
"modeltype": "Printer",
|
||||
"imageurl": "/images/models/printers/Epson-C3500.png"
|
||||
},
|
||||
{
|
||||
"modelnumber": "31i-MB",
|
||||
@@ -1071,12 +1105,14 @@
|
||||
{
|
||||
"modelnumber": "M710UC",
|
||||
"vendor": "Fanuc",
|
||||
"modeltype": "Robotic Deburring"
|
||||
"modeltype": "Robotic Deburring",
|
||||
"imageurl": "/images/models/machines/M710uc.png"
|
||||
},
|
||||
{
|
||||
"modelnumber": "D218",
|
||||
"vendor": "Fidia",
|
||||
"modeltype": "5-axis Mill"
|
||||
"modeltype": "5-axis Mill",
|
||||
"imageurl": "/images/models/machines/d218.png"
|
||||
},
|
||||
{
|
||||
"modelnumber": "475GMS",
|
||||
@@ -1086,12 +1122,14 @@
|
||||
{
|
||||
"modelnumber": "P600S",
|
||||
"vendor": "Gleason-Pfauter",
|
||||
"modeltype": "Hobbing Machine"
|
||||
"modeltype": "Hobbing Machine",
|
||||
"imageurl": "/images/models/machines/p600s.png"
|
||||
},
|
||||
{
|
||||
"modelnumber": "Vacuum Furnace",
|
||||
"vendor": "GM Enterprises",
|
||||
"modeltype": "Furnace"
|
||||
"modeltype": "Furnace",
|
||||
"imageurl": "/images/models/machines/furnace.png"
|
||||
},
|
||||
{
|
||||
"modelnumber": "550T",
|
||||
@@ -1101,7 +1139,8 @@
|
||||
{
|
||||
"modelnumber": "G750",
|
||||
"vendor": "Grob",
|
||||
"modeltype": "Mill Turn"
|
||||
"modeltype": "Mill Turn",
|
||||
"imageurl": "/images/models/machines/g750.jpg"
|
||||
},
|
||||
{
|
||||
"modelnumber": "VF-2SS",
|
||||
@@ -1111,12 +1150,14 @@
|
||||
{
|
||||
"modelnumber": "7.10.7 SF",
|
||||
"vendor": "Hexagon",
|
||||
"modeltype": "CMM"
|
||||
"modeltype": "CMM",
|
||||
"imageurl": "/images/models/machines/7107sf.png"
|
||||
},
|
||||
{
|
||||
"modelnumber": "CMM",
|
||||
"vendor": "Hexagon",
|
||||
"modeltype": "CMM"
|
||||
"modeltype": "CMM",
|
||||
"imageurl": "/images/models/machines/cmm.png"
|
||||
},
|
||||
{
|
||||
"modelnumber": "Global Advantage",
|
||||
@@ -1126,7 +1167,8 @@
|
||||
{
|
||||
"modelnumber": "DTC 4500e",
|
||||
"vendor": "HID",
|
||||
"modeltype": "Printer"
|
||||
"modeltype": "Printer",
|
||||
"imageurl": "/images/models/printers/DTC4500e.png"
|
||||
},
|
||||
{
|
||||
"modelnumber": "Hoffmann Broach",
|
||||
@@ -1136,12 +1178,14 @@
|
||||
{
|
||||
"modelnumber": "Color LaserJet CP2025",
|
||||
"vendor": "HP",
|
||||
"modeltype": "Printer"
|
||||
"modeltype": "Printer",
|
||||
"imageurl": "/images/models/printers/LaserJet -CP2025.png"
|
||||
},
|
||||
{
|
||||
"modelnumber": "Color LaserJet M254dw",
|
||||
"vendor": "HP",
|
||||
"modeltype": "Printer"
|
||||
"modeltype": "Printer",
|
||||
"imageurl": "/images/models/printers/LaserJet-M254dw.png"
|
||||
},
|
||||
{
|
||||
"modelnumber": "Color LaserJet M255dw",
|
||||
@@ -1165,7 +1209,8 @@
|
||||
{
|
||||
"modelnumber": "DesignJet T1700dr PS",
|
||||
"vendor": "HP",
|
||||
"modeltype": "Printer"
|
||||
"modeltype": "Printer",
|
||||
"imageurl": "/images/models/printers/HP-DesignJet-T1700dr.png"
|
||||
},
|
||||
{
|
||||
"modelnumber": "EliteDesk 800 G6",
|
||||
@@ -1180,7 +1225,8 @@
|
||||
{
|
||||
"modelnumber": "LaserJet 4250tn",
|
||||
"vendor": "HP",
|
||||
"modeltype": "Printer"
|
||||
"modeltype": "Printer",
|
||||
"imageurl": "/images/models/printers/LaserJet-4250.png"
|
||||
},
|
||||
{
|
||||
"modelnumber": "LaserJet CP5225",
|
||||
@@ -1200,7 +1246,8 @@
|
||||
{
|
||||
"modelnumber": "LaserJet M406",
|
||||
"vendor": "HP",
|
||||
"modeltype": "Printer"
|
||||
"modeltype": "Printer",
|
||||
"imageurl": "/images/models/printers/LaserJet-M406.png"
|
||||
},
|
||||
{
|
||||
"modelnumber": "LaserJet M428Fdn",
|
||||
@@ -1215,12 +1262,14 @@
|
||||
{
|
||||
"modelnumber": "LaserJet M454dn",
|
||||
"vendor": "HP",
|
||||
"modeltype": "Printer"
|
||||
"modeltype": "Printer",
|
||||
"imageurl": "/images/models/printers/LaserJet-M454dn.png"
|
||||
},
|
||||
{
|
||||
"modelnumber": "LaserJet M506",
|
||||
"vendor": "HP",
|
||||
"modeltype": "Printer"
|
||||
"modeltype": "Printer",
|
||||
"imageurl": "/images/models/printers/LaserJet-M506.png"
|
||||
},
|
||||
{
|
||||
"modelnumber": "LaserJet M507",
|
||||
@@ -1235,7 +1284,8 @@
|
||||
{
|
||||
"modelnumber": "LaserJet M602",
|
||||
"vendor": "HP",
|
||||
"modeltype": "Printer"
|
||||
"modeltype": "Printer",
|
||||
"imageurl": "/images/models/printers/LaserJet-M602.png"
|
||||
},
|
||||
{
|
||||
"modelnumber": "LaserJet M610",
|
||||
@@ -1290,7 +1340,8 @@
|
||||
{
|
||||
"modelnumber": "LaserJet P3015dn",
|
||||
"vendor": "HP",
|
||||
"modeltype": "Printer"
|
||||
"modeltype": "Printer",
|
||||
"imageurl": "/images/models/printers/LaserJet-P3015dn.png"
|
||||
},
|
||||
{
|
||||
"modelnumber": "LaserJet Pro 200 color M251nw",
|
||||
@@ -1305,22 +1356,26 @@
|
||||
{
|
||||
"modelnumber": "LaserJet Pro 4001n",
|
||||
"vendor": "HP",
|
||||
"modeltype": "Printer"
|
||||
"modeltype": "Printer",
|
||||
"imageurl": "/images/models/printers/LaserJet-4001n.png"
|
||||
},
|
||||
{
|
||||
"modelnumber": "LaserJet Pro M252dw",
|
||||
"vendor": "HP",
|
||||
"modeltype": "Printer"
|
||||
"modeltype": "Printer",
|
||||
"imageurl": "/images/models/printers/LaserJet-Pro-M252dw.png"
|
||||
},
|
||||
{
|
||||
"modelnumber": "LaserJet Pro M404n",
|
||||
"vendor": "HP",
|
||||
"modeltype": "Printer"
|
||||
"modeltype": "Printer",
|
||||
"imageurl": "/images/models/printers/LaserJet-M404.png"
|
||||
},
|
||||
{
|
||||
"modelnumber": "LaserJet Pro M607",
|
||||
"vendor": "HP",
|
||||
"modeltype": "Printer"
|
||||
"modeltype": "Printer",
|
||||
"imageurl": "/images/models/printers/LaserJet-M607.png"
|
||||
},
|
||||
{
|
||||
"modelnumber": "ProLiant DL380 Gen9",
|
||||
@@ -1330,7 +1385,8 @@
|
||||
{
|
||||
"modelnumber": "VT 550 2SP",
|
||||
"vendor": "Hwacheon",
|
||||
"modeltype": "Vertical Turning Center"
|
||||
"modeltype": "Vertical Turning Center",
|
||||
"imageurl": "/images/models/machines/vt5502sp.png"
|
||||
},
|
||||
{
|
||||
"modelnumber": "PM43",
|
||||
@@ -1340,22 +1396,26 @@
|
||||
{
|
||||
"modelnumber": "VR-3100",
|
||||
"vendor": "Keyence",
|
||||
"modeltype": "Measuring Machine"
|
||||
"modeltype": "Measuring Machine",
|
||||
"imageurl": "/images/models/machines/keyence-vr3100.png"
|
||||
},
|
||||
{
|
||||
"modelnumber": "Horizontal Broach",
|
||||
"vendor": "LaPointe",
|
||||
"modeltype": "Broach"
|
||||
"modeltype": "Broach",
|
||||
"imageurl": "/images/models/machines/hbroach.png"
|
||||
},
|
||||
{
|
||||
"modelnumber": "a81nx",
|
||||
"vendor": "Makino",
|
||||
"modeltype": "Horizontal Machining Center"
|
||||
"modeltype": "Horizontal Machining Center",
|
||||
"imageurl": "/images/models/machines/a81nx.png"
|
||||
},
|
||||
{
|
||||
"modelnumber": "C-4500",
|
||||
"vendor": "Mitutoyo",
|
||||
"modeltype": "Wax Trace"
|
||||
"modeltype": "Wax Trace",
|
||||
"imageurl": "/images/models/machines/c4500.png"
|
||||
},
|
||||
{
|
||||
"modelnumber": "CV-3200",
|
||||
@@ -1374,7 +1434,8 @@
|
||||
{
|
||||
"modelnumber": "VP9000",
|
||||
"vendor": "OKK",
|
||||
"modeltype": "5-axis Mill"
|
||||
"modeltype": "5-axis Mill",
|
||||
"imageurl": "/images/models/machines/vp9000.jpg"
|
||||
},
|
||||
{
|
||||
"modelnumber": "2SP-V80",
|
||||
@@ -1384,7 +1445,8 @@
|
||||
{
|
||||
"modelnumber": "LOC650",
|
||||
"vendor": "Okuma",
|
||||
"modeltype": "Mill Turn"
|
||||
"modeltype": "Mill Turn",
|
||||
"imageurl": "/images/models/machines/loc650.png"
|
||||
},
|
||||
{
|
||||
"modelnumber": "Multus",
|
||||
@@ -1394,12 +1456,14 @@
|
||||
{
|
||||
"modelnumber": "VTM-100",
|
||||
"vendor": "Okuma",
|
||||
"modeltype": "Vertical Lathe"
|
||||
"modeltype": "Vertical Lathe",
|
||||
"imageurl": "/images/models/machines/vtm100.png"
|
||||
},
|
||||
{
|
||||
"modelnumber": "Phoenix Broach",
|
||||
"vendor": "Phoenix Inc",
|
||||
"modeltype": "Broach"
|
||||
"modeltype": "Broach",
|
||||
"imageurl": "/images/models/machines/phoenixbroach.png"
|
||||
},
|
||||
{
|
||||
"modelnumber": "Shotpeen",
|
||||
@@ -1409,12 +1473,14 @@
|
||||
{
|
||||
"modelnumber": "Lean Drum",
|
||||
"vendor": "Ransohoff",
|
||||
"modeltype": "Part Washer"
|
||||
"modeltype": "Part Washer",
|
||||
"imageurl": "/images/models/machines/leandrum.jpg"
|
||||
},
|
||||
{
|
||||
"modelnumber": "LeanJet RB-2",
|
||||
"vendor": "Ransohoff",
|
||||
"modeltype": "Part Washer"
|
||||
"modeltype": "Part Washer",
|
||||
"imageurl": "/images/models/machines/rb2.png"
|
||||
},
|
||||
{
|
||||
"modelnumber": "PINSTAMP - TMP4210 Dot Peen",
|
||||
@@ -1424,7 +1490,8 @@
|
||||
{
|
||||
"modelnumber": "Powerturn",
|
||||
"vendor": "Toshulin",
|
||||
"modeltype": "Vertical Turning Center"
|
||||
"modeltype": "Vertical Turning Center",
|
||||
"imageurl": "/images/models/machines/powerturn.png"
|
||||
},
|
||||
{
|
||||
"modelnumber": "Virtual Server",
|
||||
@@ -1463,12 +1530,14 @@
|
||||
{
|
||||
"modelnumber": "AltaLink C8135",
|
||||
"vendor": "Xerox",
|
||||
"modeltype": "Printer"
|
||||
"modeltype": "Printer",
|
||||
"imageurl": "/images/models/printers/AltaLink-C8130.png"
|
||||
},
|
||||
{
|
||||
"modelnumber": "AltaLink EC8036",
|
||||
"vendor": "Xerox",
|
||||
"modeltype": "Printer"
|
||||
"modeltype": "Printer",
|
||||
"imageurl": "/images/models/printers/Xerox-EC8036.png"
|
||||
},
|
||||
{
|
||||
"modelnumber": "VersaLink B400",
|
||||
@@ -1483,7 +1552,8 @@
|
||||
{
|
||||
"modelnumber": "VersaLink B405DN",
|
||||
"vendor": "Xerox",
|
||||
"modeltype": "Printer"
|
||||
"modeltype": "Printer",
|
||||
"imageurl": "/images/models/printers/Versalink-B405.png"
|
||||
},
|
||||
{
|
||||
"modelnumber": "VersaLink B415",
|
||||
@@ -1498,12 +1568,14 @@
|
||||
{
|
||||
"modelnumber": "VersaLink B7125",
|
||||
"vendor": "Xerox",
|
||||
"modeltype": "Printer"
|
||||
"modeltype": "Printer",
|
||||
"imageurl": "/images/models/printers/Versalink-B7125.png"
|
||||
},
|
||||
{
|
||||
"modelnumber": "VersaLink C405",
|
||||
"vendor": "Xerox",
|
||||
"modeltype": "Printer"
|
||||
"modeltype": "Printer",
|
||||
"imageurl": "/images/models/printers/Versalink-C405.png"
|
||||
},
|
||||
{
|
||||
"modelnumber": "VersaLink C415",
|
||||
@@ -1513,12 +1585,14 @@
|
||||
{
|
||||
"modelnumber": "VersaLink C7100",
|
||||
"vendor": "Xerox",
|
||||
"modeltype": "Printer"
|
||||
"modeltype": "Printer",
|
||||
"imageurl": "/images/models/printers/Versalink-C7125.png"
|
||||
},
|
||||
{
|
||||
"modelnumber": "VersaLink C7125",
|
||||
"vendor": "Xerox",
|
||||
"modeltype": "Printer"
|
||||
"modeltype": "Printer",
|
||||
"imageurl": "/images/models/printers/Versalink-C7125.png"
|
||||
},
|
||||
{
|
||||
"modelnumber": "ZD621",
|
||||
@@ -1528,7 +1602,8 @@
|
||||
{
|
||||
"modelnumber": "ZT411",
|
||||
"vendor": "Zebra",
|
||||
"modeltype": "Printer"
|
||||
"modeltype": "Printer",
|
||||
"imageurl": "/images/models/printers/zt411.png"
|
||||
},
|
||||
{
|
||||
"modelnumber": "CONTURA G2",
|
||||
@@ -1539,6 +1614,132 @@
|
||||
"modelnumber": "Redomatic 600",
|
||||
"vendor": "Zoller",
|
||||
"modeltype": "Measuring Machine"
|
||||
},
|
||||
{
|
||||
"modelnumber": "32i-B",
|
||||
"vendor": "Fanuc",
|
||||
"modeltype": "Controller",
|
||||
"description": "FANUC Series 32i-MODEL B Controller"
|
||||
},
|
||||
{
|
||||
"modelnumber": "Panel i",
|
||||
"vendor": "Fanuc",
|
||||
"modeltype": "Controller",
|
||||
"description": "FANUC Panel i Controller"
|
||||
},
|
||||
{
|
||||
"modelnumber": "SIEMENS SIMATIC PANEL TOUCH",
|
||||
"vendor": "Siemens",
|
||||
"modeltype": "Controller",
|
||||
"description": "SIEMENS SIMATIC PANEL TOUCH Controller"
|
||||
},
|
||||
{
|
||||
"modelnumber": "31i-A5",
|
||||
"vendor": "Fanuc",
|
||||
"modeltype": "Controller",
|
||||
"description": "FANUC Series 31i-MODEL A5 Controller"
|
||||
},
|
||||
{
|
||||
"modelnumber": "Professional 5",
|
||||
"vendor": "Fanuc",
|
||||
"modeltype": "Controller",
|
||||
"description": "FANUC Professional 5 Controller"
|
||||
},
|
||||
{
|
||||
"modelnumber": "31i-B5",
|
||||
"vendor": "Fanuc",
|
||||
"modeltype": "Controller",
|
||||
"description": "FANUC Series 31i-MODEL B5 Controller"
|
||||
},
|
||||
{
|
||||
"modelnumber": "R-30iB",
|
||||
"vendor": "Fanuc",
|
||||
"modeltype": "Controller",
|
||||
"description": "FANUC System R-30iB Controller"
|
||||
},
|
||||
{
|
||||
"modelnumber": "18i-TB",
|
||||
"vendor": "Fanuc",
|
||||
"modeltype": "Controller",
|
||||
"description": "FANUC Series 18i-TB Controller"
|
||||
},
|
||||
{
|
||||
"modelnumber": "31i",
|
||||
"vendor": "Fanuc",
|
||||
"modeltype": "Controller",
|
||||
"description": "FANUC Series 31i Controller"
|
||||
},
|
||||
{
|
||||
"modelnumber": "Power Motion i-A",
|
||||
"vendor": "Fanuc",
|
||||
"modeltype": "Controller",
|
||||
"description": "FANUC Power Motion i-MODEL A Controller"
|
||||
},
|
||||
{
|
||||
"modelnumber": "Allen-Bradley",
|
||||
"vendor": "Allen-Bradley",
|
||||
"modeltype": "Controller",
|
||||
"description": "Allen-Bradley Controller"
|
||||
},
|
||||
{
|
||||
"modelnumber": "ZOLLER",
|
||||
"vendor": "Zoller",
|
||||
"modeltype": "Controller",
|
||||
"description": "ZOLLER Controller"
|
||||
},
|
||||
{
|
||||
"modelnumber": "SIEMENS SINUMERIK",
|
||||
"vendor": "Siemens",
|
||||
"modeltype": "Controller",
|
||||
"description": "SIEMENS SINUMERIK Controller"
|
||||
},
|
||||
{
|
||||
"modelnumber": "31i-B",
|
||||
"vendor": "Fanuc",
|
||||
"modeltype": "Controller",
|
||||
"description": "FANUC Series 31i-MODEL B Controller"
|
||||
},
|
||||
{
|
||||
"modelnumber": "0i-TC",
|
||||
"vendor": "Fanuc",
|
||||
"modeltype": "Controller",
|
||||
"description": "FANUC Series 0i-TC Controller"
|
||||
},
|
||||
{
|
||||
"modelnumber": "SIEMENS",
|
||||
"vendor": "Siemens",
|
||||
"modeltype": "Controller",
|
||||
"description": "SIEMENS Controller"
|
||||
},
|
||||
{
|
||||
"modelnumber": "OKUMA OSP-P300LA",
|
||||
"vendor": "Okuma",
|
||||
"modeltype": "Controller",
|
||||
"description": "OKUMA OSP-P300LA Controller"
|
||||
},
|
||||
{
|
||||
"modelnumber": "OKUMA OSP-P300L",
|
||||
"vendor": "Okuma",
|
||||
"modeltype": "Controller",
|
||||
"description": "OKUMA OSP-P300L Controller"
|
||||
},
|
||||
{
|
||||
"modelnumber": "31i-A",
|
||||
"vendor": "Fanuc",
|
||||
"modeltype": "Controller",
|
||||
"description": "FANUC Series 31i-MODEL A Controller"
|
||||
},
|
||||
{
|
||||
"modelnumber": "FIDIA C20",
|
||||
"vendor": "Fidia",
|
||||
"modeltype": "Controller",
|
||||
"description": "FIDIA C20 Controller"
|
||||
},
|
||||
{
|
||||
"modelnumber": "R-30iA",
|
||||
"vendor": "Fanuc",
|
||||
"modeltype": "Controller",
|
||||
"description": "FANUC System R-30iA Controller"
|
||||
}
|
||||
],
|
||||
"modelsupplies": [
|
||||
|
||||
Reference in New Issue
Block a user