diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..3ea9bfaa --- /dev/null +++ b/.dockerignore @@ -0,0 +1,23 @@ +# Keep the build context lean and prevent host artifacts from clobbering +# in-image installs. node_modules is platform-specific: copying the host's +# (Windows) tree over the container's `npm ci` breaks rollup's native binary. +**/node_modules +frontend/dist +frontend/src/.plugins-staged +frontend/src/router/routes.gen.js + +# Python caches and virtualenvs +**/__pycache__ +**/*.pyc +.venv +venv + +# Local dev and VCS noise +.git +.gitignore +*.log +.env.local + +# Installer build artifacts (not part of the app image) +installer-arch-dev/build +installer-arch-dev/dist diff --git a/.env.example b/.env.example index a2689518..68e9ebbe 100644 --- a/.env.example +++ b/.env.example @@ -64,6 +64,7 @@ ZABBIX_TOKEN= # COLLECTOR_API_KEY_ first, then COLLECTOR_API_KEY as fallback. # COLLECTOR_API_KEY= # COLLECTOR_API_KEY_COMPUTERS= +# COLLECTOR_API_KEY_ENDPOINTS= # ---- Trusted plugin publisher keys (ADR-013, optional) ---- # Public-key PEM paths (OS path separator: ':' on Linux, ';' on Windows) used diff --git a/.github/agent-workflow.md b/.github/agent-workflow.md new file mode 100644 index 00000000..59b87aaf --- /dev/null +++ b/.github/agent-workflow.md @@ -0,0 +1,86 @@ +# Agent workflow rules + +Companion to `copilot-instructions.md` (which owns naming/style/architecture). +These cut wasted turns. All are lessons from real sessions. + +## Environment - know these, do not rediscover + +- Shell is **PowerShell**: use `;` not `&&`, `2>$null` not `2>/dev/null`. +- API container `shopdb-flask-wilm-api-1` (Flask, port `5001`). +- DB container `shopdb-flask-wilm-db-1` (MySQL 8, root pw `root_password_123`). +- SPA is **baked into the image** (not bind-mounted). Plugin code is + **bind-mounted read-only**. +- `docs/` is stripped to a public wiki mirror - keep working docs out of it. +- `requirements.txt` is hash-checked - never hand-edit. + +## Verify, don't react + +- The terminal "Command failed" banner fires on any non-zero exit or stderr + output and is **usually a false negative** (`curl --fail` on a 400, + pytest/npm/git writing to stderr). Judge success from real output. +- Read container health via + `docker inspect --format '{{.State.Health.Status}}'` plus a probe **without** + `--fail` - never eyeball the exit code. + +## Container runs the image, not your edits + +Before running anything in the container that must reflect a change: + +1. Frontend: `npm run build`, then + `docker cp frontend/dist/. shopdb-flask-wilm-api-1:/app/frontend/dist`. +2. Backend/scripts/templates: `docker cp` the files (or rebuild) first. + +If a result contradicts a change you just made, suspect stale container code +before re-deriving the fix. + +## Testing + +- Confirm the working directory once, up front. +- Run the smallest test that proves the point; find the minimal polluter for + isolation bugs. Run the full suite only at the end. +- Clear stale local state before plugin/migration tests: + `if (Test-Path instance/plugins.json) { Remove-Item instance/plugins.json -Force }`. +- Naming-gate hits inside local venvs (`.ci-venv/`, `env/`) are not repo code - + do not "fix" vendored libraries. +- Final gates, exactly as CI: `python -m pytest tests/ -q`; + `cd frontend; npx vitest run; npm run build; cd ..`; + `bash scripts/check-naming-and-style.sh`. + +## Migrations and imports + +- Every migration: idempotent guard + real downgrade; must run clean on strict + MySQL 8. If you rename indexes in `upgrade()`, update `downgrade()` to match. +- Avoid duplicate indexes: `index=True` plus an explicit `db.Index` on the same + column creates two. +- Importers/seeders match by natural key and must be idempotent - prove it by + running twice (expect `updated`, not `created`). Watch all-or-nothing writes. + +## Don't offer rollbacks you don't need + +State current state plainly; surface a rollback path only for a real regression. + +## Wilmington (wilm) local-run known failures - do not re-investigate + +Running the full `python -m pytest tests/ -q` on a Windows dev box (not CI) +produces a stable set of environmental failures that are NOT code regressions. +Treat these as expected locally; judge your change against CI/Linux instead: + +- `test_core/test_security_headers.py::test_a_served_upload_is_sandboxed` - + `PermissionError [WinError 32]` (served upload file still locked by another + process). Windows file-lock timing, not a headers bug. +- `test_plugin_contract.py::test_import_scan_covers_symlinked_plugins` - + `OSError [WinError 1314]` (symlink privilege not held). Needs admin/Developer + Mode to create symlinks; irrelevant to plugin logic. +- `test_plugin_importguard.py::test_verified_source_signed_ok_tampered_raises` - + CRLF vs LF (`b'# plugin\r\n' == b'# plugin\n'`). Git autocrlf checkout, not a + signing bug. +- `test_docs_publishable.py` and `test_docs_versions.py` - the local `docs/` + tree is not the stripped public wiki mirror, so doc-count / PROJECT-MAP + assertions fail. Expected off-CI. +- `test_core/test_public_read_surface.py::test_every_get_route_is_gated_or_named_public` + - pre-existing ungated-GET inventory drift; only act on it if YOUR change + added a new GET route. + +If a run shows ONLY these, the suite is green for our purposes. Anything outside +this list is real - investigate it. + diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index fefd16a4..77f2f109 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -72,6 +72,7 @@ name. Spell out: `config context manager request response environment utilities` with quantities are standalone tables, not assets. - **Ledger pattern**: a cached `quantityonhand` moves in the SAME commit as the signed transaction row it reflects. +- **Configuration for endpoint agent**: The endpoint agent's live collector key and base URL are provisioned in the Windows registry `HKLM\SOFTWARE\GE\ShopDB` (values `CollectorKey`, `BaseUrl`), not committed in `appsettings.json`; `appsettings.json` holds only non-secret runtime settings. ## Before you finish a change diff --git a/.gitignore b/.gitignore index d81657df..26c16f38 100644 --- a/.gitignore +++ b/.gitignore @@ -83,3 +83,12 @@ scripts/site_imports/wjf/idmap.json # work-PC publication clone: bundle drop folder for the transfer pipeline _transfer/ + +# Local developer scratch notes and chat handoffs - never committed +devnotes/ + +# docs/ is generated/published to the wiki, not tracked in the product repo. +# A single tracked file here makes docs/ exist in the CI checkout, which +# defeats the intentional skip in the docs publish/version tests. Keep this +# local-only guide out of version control so those tests skip as designed. +docs/CSV-IMPORT-GUIDE.md diff --git a/Dockerfile b/Dockerfile index c6567d3c..df0156d7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -23,6 +23,23 @@ FROM node:20-slim AS frontendbuild WORKDIR /build +# 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. COPY frontend/package*.json ./frontend/ RUN cd frontend && npm ci @@ -86,7 +103,7 @@ USER shopdb EXPOSE 5001 HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ - CMD curl --fail --silent http://localhost:5001/api/auth/login -X POST \ + CMD curl --silent http://localhost:5001/api/auth/login -X POST \ -H "Content-Type: application/json" -d '{}' \ | grep -q "VALIDATION_ERROR" || exit 1 diff --git a/agent/.gitignore b/agent/.gitignore new file mode 100644 index 00000000..aa65026a --- /dev/null +++ b/agent/.gitignore @@ -0,0 +1,7 @@ +# .NET build output - never tracked; produced by dotnet build/publish. +bin/ +obj/ +publish/ + +# Local publish scratch and command-output captures. +*.user diff --git a/agent/README.md b/agent/README.md new file mode 100644 index 00000000..0c43efe5 --- /dev/null +++ b/agent/README.md @@ -0,0 +1,166 @@ +# ShopDB Endpoint Agent + +A Windows telemetry agent that reports endpoint state to a ShopDB server's +`endpoints` plugin. It runs as a Windows service in production and as a console +app for development and testing. + +## Projects + +- `ShopDbEndpointAgent.Core` - contracts, config, logging, collection, and the + resilient collector HTTP client. No host concerns; unit-testable. +- `ShopDbEndpointAgent` - the service host (generic host + Windows service + lifetime). Composes Core through DI. This is the telemetry runtime. +- `ShopDbEndpointAgent.Tray` - interactive user-session tray app: a system-tray + icon with "Send Status now" (on-demand report) and the 5-click issue reporter + (ServiceNow flow is still a scaffold). Separate process from the service, + because a SYSTEM service cannot draw a tray icon in a user session. + +## Configuration + +Runtime config is layered. Non-secret defaults live in `appsettings.json` +(shipped with the build). The collector key and base URL are provisioned out of +band in the registry and win over `appsettings.json`, so a distributed build +never carries the secret and the site can rotate the key without a rebuild. + +### Registry (production) + +Create these values under `HKLM\SOFTWARE\GE\ShopDB` (64-bit view): + +| Value | Type | Meaning | +|----------------|--------|--------------------------------------------------| +| `BaseUrl` | String | `https://shopdb.example.net` (server root) | +| `CollectorKey` | String | The endpoint agent key from Settings -> Endpoints -> Key management | + +Example (elevated PowerShell): + +``` +New-Item -Path 'HKLM:\SOFTWARE\GE\ShopDB' -Force | Out-Null +Set-ItemProperty -Path 'HKLM:\SOFTWARE\GE\ShopDB' -Name 'BaseUrl' -Value 'https://shopdb.example.net' +Set-ItemProperty -Path 'HKLM:\SOFTWARE\GE\ShopDB' -Name 'CollectorKey' -Value '' +``` + +### appsettings.json (all environments) + +| Key | Default | Meaning | +|-------------------|-------------|------------------------------------------------| +| `baseurl` | "" | Dev fallback for the server root (registry wins). | +| `apikey` | placeholder | Dev fallback for the collector key (registry wins). | +| `cadenceseconds` | 900 | Report interval (15 min). | +| `jitterseconds` | 60 | Random +/- spread on the interval (anti-herd). | +| `adoptonfirstrun` | true | Send `adopt=true` on first contact only. | +| `utilitiespath` | "" | Directory of site PowerShell "agent utilities". | +| `logpath` | "" | Override the rolling log directory. | + +For a dev box, set `baseurl` and `apikey` here and skip the registry. + +## Running + +Console (dev), one report then exit: + +``` +dotnet run --project ShopDbEndpointAgent -- --once +``` + +Console (dev), continuous cadence loop: + +``` +dotnet run --project ShopDbEndpointAgent -- --console +``` + +## Install as a Windows service + +Publish self-contained, then register with `sc.exe` (elevated): + +``` +dotnet publish ShopDbEndpointAgent -c Release -r win-x64 --self-contained -o C:\ProgramData\ShopDB\EndpointAgent\bin +sc.exe create "ShopDBEndpointAgent" binPath= "C:\ProgramData\ShopDB\EndpointAgent\bin\shopdb-endpoint-agent.exe" start= auto DisplayName= "ShopDB Endpoint Agent" +sc.exe description "ShopDBEndpointAgent" "Reports endpoint telemetry to ShopDB." +sc.exe start "ShopDBEndpointAgent" +``` + +The service runs as LocalSystem by default, which it needs to read the Security +event channel and system WMI. + +## Tray app (system-tray icon) + +The tray is a per-user process, not a service, so it starts at sign-in rather +than under `sc.exe`. `install-shopdbagent.ps1` handles it: it copies +`shopdb-endpoint-tray.exe` next to the agent, registers an HKCU logon autostart +(`Software\Microsoft\Windows\CurrentVersion\Run\ShopDbEndpointTray`), and +launches it for the current user. Run the installer from the operator's own +session so the autostart lands in the right user hive. Pass `-NoTray` for a +headless, service-only install. + +Right-click the tray icon for: + +- `Send Status now` - reports this PC to ShopDB immediately so the monitor page + refreshes without waiting for the next cadence tick. It shells out to + `shopdbagent.exe --once`, reusing the exact production collection path; the + tray holds no collector key. A balloon tip reflects success or failure (on + failure, see the rolling agent log). +- `Report an issue...` - the ServiceNow ticket flow (scaffold; submission path + is an open decision). + +A desktop shortcut to `shopdb-endpoint-tray.exe` can be added later for an +explicitly launchable ServiceNow surface. + +## WebUI command execution model (security context) + +The WebUI can send an endpoint an allowlisted command (server source of truth: +`plugins/endpoints/commands.py`; agent handler: `CommandExecutor`). The agent +service runs as LocalSystem, but not every command runs in that context. The +launch context is chosen per action so processes surface to the operator while +privileged operations keep the token they need: + +- Generic `process_start` (for example DncMain, UDC) launches into the ACTIVE + CONSOLE USER's session at medium integrity, so the app is visible on the + operator's desktop. CyberArk/EPM brokers any elevation afterward. +- Generic `process_stop` / `process_status` run as SYSTEM (kill and probe). +- `service_start` / `service_stop` / `service_status` run as SYSTEM, because + service control requires the admin/SYSTEM token. +- `mtconnect_start` / `mtconnect_stop` / `mtconnect_status` all run as SYSTEM, + because MTConnect is the "UDC MTConnect Agent" Windows service and service + control requires the admin/SYSTEM token. `mtconnect_start` runs `install.bat` + (`Agent.exe install`) to install or repair the service, then ensures it is + running; `mtconnect_stop` stops the service; `mtconnect_status` queries it. + `run.bat` (`agent.exe run agent.cfg`) is a foreground TESTING launcher and is + deliberately NOT used by the agent. +- `system_restart` runs as SYSTEM and reboots the machine immediately via + `shutdown.exe /r /t 0` (no grace timer). It is a fixed action with no target, + gated server-side by the dedicated `endpoints.restart` permission on top of + `endpoints.command`, and fronted by a WebUI confirmation gate. + +Do not route MTConnect through the generic `process_start` user-session path or +service control breaks. MTConnect is intentionally absent from the +`process_start` launch map for the same reason; the fixed `mtconnect_*` handler +owns it. + +Behavior note for redeploys: the MTConnect service-control model is a runtime +change, so the agent must be rebuilt and redeployed for it to take effect. +Server-side command/allowlist/RBAC changes are picked up by the container +rebuild; rebuild and redeploy the agent whenever the runtime launch/control +logic itself changes. + +## Diagnostics + +- Rolling file log: `%ProgramData%\ShopDB\EndpointAgent\logs\agent-YYYYMMDD.log` + (14-day retention). +- Windows Event Log: agent runtime failures and unhandled exceptions are written + to the `Application` log under source `ShopDB Endpoint Agent`. The source is + created on first elevated run (or by the installer). Filter Event Viewer by + that source to see agent/system interop failures. + +## Agent utilities (PowerShell seam) + +If `utilitiespath` points at a directory, the runtime runs each `*.ps1` there +per report, parses its JSON stdout, and merges the result into the report. This +is the seam the future server-side "Agent configuration" settings page uses to +push site-authored collectors down. Scripts must be code-signed and +allow-listed before this is enabled in the field (rollout concern). + +## Logging extensibility + +All runtime code logs through `Microsoft.Extensions.Logging` (`ILogger`). Adding +a sink later (a ShopDB HTTP sink, Seq, syslog) is a host-builder change in +`Program.cs::ConfigureLogging` with no changes to the worker, collector, or +client. diff --git a/agent/ShopDbEndpointAgent.Core/AgentConfig.cs b/agent/ShopDbEndpointAgent.Core/AgentConfig.cs new file mode 100644 index 00000000..f8a8b9f9 --- /dev/null +++ b/agent/ShopDbEndpointAgent.Core/AgentConfig.cs @@ -0,0 +1,109 @@ +using System.Text.Json.Serialization; + +namespace ShopDbEndpointAgent.Core; + +// Runtime configuration for the agent. Non-secret values come from +// appsettings.json; the collector key and base URL are provisioned out of band +// in the registry (HKLM:\SOFTWARE\GE\ShopDB) and layered on top - see +// RegistryConfigProvider. The registry wins so a distributed appsettings.json +// never carries the secret and the site can rotate the key without a rebuild. +public sealed class AgentConfig +{ + [JsonPropertyName("baseurl")] public string BaseUrl { get; set; } = ""; + [JsonPropertyName("apikey")] public string ApiKey { get; set; } = ""; + [JsonPropertyName("cadenceseconds")] public int CadenceSeconds { get; set; } = 900; + + // Jitter spreads a fleet's reports so they do not arrive in lockstep + // (thundering herd). Actual delay = cadence +/- up to jitterseconds. + [JsonPropertyName("jitterseconds")] public int JitterSeconds { get; set; } = 60; + + [JsonPropertyName("adoptonfirstrun")] public bool AdoptOnFirstRun { get; set; } = true; + + // Directory holding site-provided PowerShell "agent utilities" the runtime + // may invoke through the documented collection seam. Empty = none loaded. + [JsonPropertyName("utilitiespath")] public string UtilitiesPath { get; set; } = ""; + + // Absolute path for the rolling diagnostics log. Empty = the default under + // %ProgramData%\ShopDB\EndpointAgent\logs. + [JsonPropertyName("logpath")] public string LogPath { get; set; } = ""; + + // What this endpoint watches. Shipped in appsettings.json (non-secret) so a + // site can retarget monitors without a rebuild. Processes are interactive + // apps watched by image name; services are Windows services watched by their + // service key name. Empty lists = nothing watched. + [JsonPropertyName("monitors")] public MonitorConfig Monitors { get; set; } = new(); + + // Per-image launchable paths for the generic process_start action. The wire + // never carries a path (that would be a code-execution hole); Start sends + // only the image name and the agent maps it here to a known-good executable + // (or a .bat launcher). Anything not in the resolved map fails closed. A + // site overrides or extends the shipped defaults in appsettings.json without + // a rebuild. Keyed by process image name (case-insensitive, .exe optional). + [JsonPropertyName("launchpaths")] + public Dictionary LaunchPaths { get; set; } = new(); + + // Shipped defaults for the org's common shop-floor apps. A site's + // appsettings.json LaunchPaths entries win over these on a per-image basis. + // MTConnect is intentionally absent here: it is a Windows service controlled + // by the fixed mtconnect_* handler (install.bat + service control), not the + // generic process_start path. eDNC and UDC launch their runtime executables + // directly. + public static readonly IReadOnlyDictionary DefaultLaunchPaths = + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["DncMain.exe"] = @"C:\Program Files (x86)\Dnc\bin\DncMain.exe", + ["UDC.exe"] = @"C:\Program Files\UDC\UDC.exe", + }; + + // The effective launch map: shipped defaults overlaid with appsettings.json + // entries (site wins). Keys are normalized to bare image names so a lookup + // tolerates 'DncMain' or 'DncMain.exe'. + public IReadOnlyDictionary ResolveLaunchPaths() + { + var resolved = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var pair in DefaultLaunchPaths) + resolved[NormalizeImage(pair.Key)] = pair.Value; + foreach (var pair in LaunchPaths) + { + if (string.IsNullOrWhiteSpace(pair.Key) || string.IsNullOrWhiteSpace(pair.Value)) + continue; + resolved[NormalizeImage(pair.Key)] = pair.Value; + } + return resolved; + } + + private static string NormalizeImage(string image) + { + var bare = (image ?? "").Trim(); + if (bare.EndsWith(".exe", StringComparison.OrdinalIgnoreCase)) + bare = bare[..^4]; + return bare; + } + + public bool IsUsable() + => !string.IsNullOrWhiteSpace(BaseUrl) + && !string.IsNullOrWhiteSpace(ApiKey) + && ApiKey != "REPLACE_WITH_COLLECTOR_KEY"; +} + +// Watched targets for this endpoint. Both lists carry entries with a wire name +// (process image name or Windows service key name) and an optional friendly app +// label the server/UI can show (for example "eDNC" for dncMain.exe). +public sealed class MonitorConfig +{ + [JsonPropertyName("processes")] public MonitorTarget[] Processes { get; set; } + = Array.Empty(); + + [JsonPropertyName("services")] public MonitorTarget[] Services { get; set; } + = Array.Empty(); +} + +public sealed class MonitorTarget +{ + // Process image name (for example "udc.exe") or Windows service key name + // (for example "MTConnectAgent"). Matched case-insensitively. + [JsonPropertyName("name")] public string Name { get; set; } = ""; + + // Human-facing application label, optional. Falls back to Name when empty. + [JsonPropertyName("app")] public string App { get; set; } = ""; +} diff --git a/agent/ShopDbEndpointAgent.Core/AgentConfigProvider.cs b/agent/ShopDbEndpointAgent.Core/AgentConfigProvider.cs new file mode 100644 index 00000000..ede80f88 --- /dev/null +++ b/agent/ShopDbEndpointAgent.Core/AgentConfigProvider.cs @@ -0,0 +1,110 @@ +using System.Text.Json; +using Microsoft.Extensions.Logging; +using Microsoft.Win32; + +namespace ShopDbEndpointAgent.Core; + +// Builds the effective AgentConfig by layering two sources: +// 1. appsettings.json - non-secret runtime defaults, shipped with the build. +// 2. HKLM\SOFTWARE\GE\ShopDB - the collector key and base URL, provisioned +// out of band (Intune/DSC/GE-Enforce). Registry values WIN so a +// distributed appsettings.json never carries the secret and the site can +// rotate the key without a rebuild. +// +// Reading the registry never throws out to the caller: a missing hive or value +// simply leaves the appsettings value in place, which is what makes dev boxes +// (appsettings only) and fielded machines (registry provisioned) both work. +public static class AgentConfigProvider +{ + public const string RegistryPath = @"SOFTWARE\GE\ShopDB"; + + // Registry value names. These mirror the names the computers reporter uses + // (see plugins/endpoints/DESIGN-DECISIONS-agent.md section 1). + public const string BaseUrlValue = "BaseUrl"; + public const string CollectorKeyValue = "CollectorKey"; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true, + }; + + public static AgentConfig Load(string? appSettingsPath = null, + Microsoft.Extensions.Logging.ILogger? logger = null) + { + var config = LoadAppSettings(appSettingsPath) ?? new AgentConfig(); + ApplyRegistryOverrides(config, logger); + return config; + } + + private static AgentConfig? LoadAppSettings(string? path) + { + path ??= Path.Combine(AppContext.BaseDirectory, "appsettings.json"); + if (!File.Exists(path)) + return null; + try + { + var json = File.ReadAllText(path); + return JsonSerializer.Deserialize(json, JsonOptions); + } + catch + { + // Malformed appsettings: fall back to defaults + registry so the + // agent can still run if the secret is provisioned in the registry. + return null; + } + } + + private static void ApplyRegistryOverrides(AgentConfig config, + Microsoft.Extensions.Logging.ILogger? logger = null) + { + // Try 64-bit view first, then the default/32-bit view. On a locked-down + // box (CyberArk/EPM gating regedit and hive reads) the read can throw + // SecurityException/UnauthorizedAccessException even when the values + // exist. We log the exact outcome per view instead of swallowing it, so + // "Config incomplete" can be told apart from "access denied". + RegistryView[] views = { RegistryView.Registry64, RegistryView.Registry32 }; + foreach (var view in views) + { + try + { + using var machine = RegistryKey.OpenBaseKey( + RegistryHive.LocalMachine, view); + using var key = machine.OpenSubKey(RegistryPath); + if (key is null) + { + logger?.LogWarning( + "Registry key HKLM\\{Path} not found in {View} view.", + RegistryPath, view); + continue; + } + + var baseUrl = key.GetValue(BaseUrlValue) as string; + if (!string.IsNullOrWhiteSpace(baseUrl)) + config.BaseUrl = baseUrl.Trim(); + + var collectorKey = key.GetValue(CollectorKeyValue) as string; + if (!string.IsNullOrWhiteSpace(collectorKey)) + config.ApiKey = collectorKey.Trim(); + + logger?.LogInformation( + "Registry {View}: BaseUrl {BaseUrlState}, CollectorKey {KeyState}.", + view, + string.IsNullOrWhiteSpace(baseUrl) ? "MISSING" : "present", + string.IsNullOrWhiteSpace(collectorKey) ? "MISSING" : "present"); + + // Success reading this view; stop unless nothing was found. + if (!string.IsNullOrWhiteSpace(baseUrl) || + !string.IsNullOrWhiteSpace(collectorKey)) + return; + } + catch (Exception error) + { + logger?.LogError(error, + "Registry read of HKLM\\{Path} in {View} view was blocked " + + "({ErrorType}). If CyberArk/EPM is gating the hive, grant the " + + "agent read access or use appsettings.json for this test.", + RegistryPath, view, error.GetType().Name); + } + } + } +} diff --git a/agent/ShopDbEndpointAgent.Core/AgentFaultMonitor.cs b/agent/ShopDbEndpointAgent.Core/AgentFaultMonitor.cs new file mode 100644 index 00000000..a46e98df --- /dev/null +++ b/agent/ShopDbEndpointAgent.Core/AgentFaultMonitor.cs @@ -0,0 +1,91 @@ +using System.Diagnostics; +using Microsoft.Extensions.Logging; + +namespace ShopDbEndpointAgent.Core; + +// Agent self-monitoring (WER-style). Routes otherwise-unobserved failures - +// unhandled exceptions on any thread and faulted tasks nobody awaited - to the +// ILogger (so they land in the rolling file) AND directly to a dedicated +// Windows Event Log source so agent runtime failures and agent/system interop +// errors are visible in Event Viewer alongside the OS's own records. +// +// The Event Log source is created on first use; creating a source needs admin, +// so if it is absent and cannot be created we degrade to the file log only and +// never throw from a fault handler (that would mask the original failure). +public sealed class AgentFaultMonitor +{ + public const string EventLogSource = "ShopDB Endpoint Agent"; + public const string EventLogName = "Application"; + + private readonly ILogger logger; + private readonly bool eventLogReady; + + public AgentFaultMonitor(ILogger logger) + { + this.logger = logger; + eventLogReady = EnsureEventLogSource(); + } + + // Wire the process-wide fault hooks. Call once at startup. + public void Install() + { + AppDomain.CurrentDomain.UnhandledException += OnUnhandledException; + TaskScheduler.UnobservedTaskException += OnUnobservedTaskException; + } + + public void ReportFatal(string context, Exception error) + => Report(EventLogEntryType.Error, 1000, $"{context}: {error}"); + + private void OnUnhandledException(object sender, UnhandledExceptionEventArgs args) + { + var error = args.ExceptionObject as Exception; + var message = error?.ToString() ?? "unknown unhandled exception"; + logger.LogCritical(error, "Unhandled exception (terminating={Terminating})", + args.IsTerminating); + Report(EventLogEntryType.Error, 1001, + $"Unhandled exception (terminating={args.IsTerminating}): {message}"); + } + + private void OnUnobservedTaskException(object? sender, + UnobservedTaskExceptionEventArgs args) + { + logger.LogError(args.Exception, "Unobserved task exception"); + Report(EventLogEntryType.Warning, 1002, + $"Unobserved task exception: {args.Exception}"); + // Mark observed so it does not escalate to a process-level failure. + args.SetObserved(); + } + + private void Report(EventLogEntryType type, int eventId, string message) + { + if (!eventLogReady) + return; + try + { + EventLog.WriteEntry(EventLogSource, message, type, eventId); + } + catch + { + // Event Log write failed (source removed, log full): the file log + // already has this entry; a fault handler must never throw. + } + } + + private static bool EnsureEventLogSource() + { + try + { + if (EventLog.SourceExists(EventLogSource)) + return true; + EventLog.CreateEventSource(new EventSourceCreationData( + EventLogSource, EventLogName)); + return true; + } + catch + { + // Needs admin the first time; the installer creates the source, so + // in the field this succeeds. In dev without admin we log to file. + return false; + } + } +} diff --git a/agent/ShopDbEndpointAgent.Core/CollectSignal.cs b/agent/ShopDbEndpointAgent.Core/CollectSignal.cs new file mode 100644 index 00000000..4deea78b --- /dev/null +++ b/agent/ShopDbEndpointAgent.Core/CollectSignal.cs @@ -0,0 +1,26 @@ +namespace ShopDbEndpointAgent.Core; + +// A one-shot wake signal shared between the command worker and the telemetry +// worker. After a command runs (e.g. MTConnect start/stop), the command worker +// calls Request() so the telemetry loop collects immediately instead of waiting +// out the remaining cadence, and the WebUI card reflects the new state within a +// single report cycle rather than up to 15 minutes later. +public sealed class CollectSignal +{ + private readonly SemaphoreSlim gate = new(0, 1); + + // Wake the telemetry loop for one off-cadence collection. Extra calls while + // one is already pending collapse into a single wake (the gate caps at 1). + public void Request() + { + try { gate.Release(); } + catch (SemaphoreFullException) { /* a wake is already pending */ } + } + + // Wait until either the cadence delay elapses or a collect is requested. + // Returns true when woken by a request, false on a normal cadence timeout. + public async Task WaitAsync(TimeSpan delay, CancellationToken token) + { + return await gate.WaitAsync(delay, token); + } +} diff --git a/agent/ShopDbEndpointAgent.Core/CollectorClient.cs b/agent/ShopDbEndpointAgent.Core/CollectorClient.cs new file mode 100644 index 00000000..4c4d30d9 --- /dev/null +++ b/agent/ShopDbEndpointAgent.Core/CollectorClient.cs @@ -0,0 +1,118 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using Microsoft.Extensions.Logging; + +namespace ShopDbEndpointAgent.Core; + +// Posts one EndpointReport to POST {baseurl}/api/collector/endpoints with the +// X-API-Key header (plugins/endpoints/AGENT-CONTRACT.md section 1), unwrapping +// the ShopDB envelope { status, data, meta } to return the CollectorResponse. +// +// Resilience: transient failures (network errors, timeouts, 5xx, 429) are +// retried with exponential backoff plus jitter, bounded by maxAttempts. A 4xx +// other than 429 is a permanent error (bad key, unknown host in strict mode) +// and is surfaced immediately without retry - hammering the server changes +// nothing. +public sealed class CollectorClient : IDisposable +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + DefaultIgnoreCondition = System.Text.Json.Serialization + .JsonIgnoreCondition.WhenWritingNull, + }; + + private readonly HttpClient http; + private readonly ILogger logger; + private readonly int maxAttempts; + private readonly Random jitter = new(); + + public CollectorClient(AgentConfig config, ILogger logger, + int maxAttempts = 4) + { + this.logger = logger; + this.maxAttempts = Math.Max(1, maxAttempts); + http = new HttpClient + { + BaseAddress = new Uri(config.BaseUrl.TrimEnd('/') + "/"), + Timeout = TimeSpan.FromSeconds(30), + }; + http.DefaultRequestHeaders.Add("X-API-Key", config.ApiKey); + } + + public async Task SendAsync( + EndpointReport report, CancellationToken token) + { + Exception? last = null; + for (int attempt = 1; attempt <= maxAttempts; attempt++) + { + token.ThrowIfCancellationRequested(); + try + { + return await SendOnceAsync(report, token); + } + catch (CollectorException error) when (error.IsPermanent) + { + // Bad credential or rejected payload: no retry. + throw; + } + catch (Exception error) when ( + error is CollectorException or HttpRequestException or TaskCanceledException + && !token.IsCancellationRequested) + { + last = error; + if (attempt == maxAttempts) + break; + var delay = BackoffDelay(attempt); + logger.LogWarning( + "Collector send attempt {Attempt}/{Max} failed: {Message}; retrying in {Delay}s", + attempt, maxAttempts, error.Message, delay.TotalSeconds); + await Task.Delay(delay, token); + } + } + throw last ?? new CollectorException(0, "collector send failed"); + } + + private async Task SendOnceAsync( + EndpointReport report, CancellationToken token) + { + using var response = await http.PostAsJsonAsync( + "api/collector/endpoints", report, JsonOptions, token); + + var body = await response.Content.ReadAsStringAsync(token); + if (!response.IsSuccessStatusCode) + throw new CollectorException((int)response.StatusCode, body); + + var envelope = JsonSerializer.Deserialize(body, JsonOptions); + if (envelope?.Data is null) + throw new CollectorException((int)response.StatusCode, "empty response body"); + return envelope.Data; + } + + private TimeSpan BackoffDelay(int attempt) + { + // 2^(attempt-1) seconds, capped, plus up to 1s jitter. + double seconds = Math.Min(30, Math.Pow(2, attempt - 1)); + return TimeSpan.FromSeconds(seconds + jitter.NextDouble()); + } + + public void Dispose() => http.Dispose(); +} + +public sealed class CollectorException : Exception +{ + public int StatusCode { get; } + + // A permanent failure is a 4xx that is not 429 (rate limit): retrying with + // the same key and payload cannot succeed, so the worker should back off to + // its normal cadence rather than fast-retry. + public bool IsPermanent => + StatusCode >= 400 && StatusCode < 500 + && StatusCode != (int)HttpStatusCode.TooManyRequests; + + public CollectorException(int statusCode, string body) + : base($"collector returned HTTP {statusCode}: {body}") + { + StatusCode = statusCode; + } +} diff --git a/agent/ShopDbEndpointAgent.Core/CommandClient.cs b/agent/ShopDbEndpointAgent.Core/CommandClient.cs new file mode 100644 index 00000000..296dc280 --- /dev/null +++ b/agent/ShopDbEndpointAgent.Core/CommandClient.cs @@ -0,0 +1,83 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using Microsoft.Extensions.Logging; + +namespace ShopDbEndpointAgent.Core; + +// Talks to the endpoint command channel (plugins/endpoints/AGENT-CONTRACT.md +// section 7). The agent opens no inbound port: it long-polls +// GET /api/endpoints/commands/next, which the server holds open until a command +// is queued (HTTP 200) or the hold window elapses (HTTP 204). After running the +// claimed command locally the agent posts the outcome to +// POST /api/endpoints/commands/{id}/result. Both share the endpoints collector +// key via X-API-Key, so the command channel rides the same kill switch as +// telemetry and adds no second secret. +public sealed class CommandClient : IDisposable +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + DefaultIgnoreCondition = System.Text.Json.Serialization + .JsonIgnoreCondition.WhenWritingNull, + }; + + private readonly HttpClient http; + private readonly ILogger logger; + + public CommandClient(AgentConfig config, ILogger logger) + { + this.logger = logger; + http = new HttpClient + { + BaseAddress = new Uri(config.BaseUrl.TrimEnd('/') + "/"), + // Longer than the collector client: the claim request is held open + // server-side (~25s), so the read timeout must clear that plus slack. + Timeout = TimeSpan.FromSeconds(60), + }; + http.DefaultRequestHeaders.Add("X-API-Key", config.ApiKey); + } + + // Claim the next pending command for this hostname, or null when the server + // returned 204 (nothing queued within the hold window). A 4xx other than 429 + // is surfaced so the worker can back off instead of hot-looping a bad key or + // an unknown host. + public async Task ClaimNextAsync( + string hostname, CancellationToken token) + { + var url = "api/endpoints/commands/next?hostname=" + + Uri.EscapeDataString(hostname); + using var response = await http.GetAsync(url, token); + + if (response.StatusCode == HttpStatusCode.NoContent) + return null; + + var body = await response.Content.ReadAsStringAsync(token); + if (!response.IsSuccessStatusCode) + throw new CollectorException((int)response.StatusCode, body); + + var envelope = JsonSerializer.Deserialize( + body, JsonOptions); + return envelope?.Data; + } + + // Report a claimed command's outcome. Best-effort: a failure to post the + // result is logged and swallowed so a transient network blip does not crash + // the worker; the command simply stays 'running' server-side until a later + // operator retry supersedes it. + public async Task ReportResultAsync( + int commandId, CommandResult result, CancellationToken token) + { + var url = $"api/endpoints/commands/{commandId}/result"; + using var response = await http.PostAsJsonAsync( + url, result, JsonOptions, token); + if (!response.IsSuccessStatusCode) + { + var body = await response.Content.ReadAsStringAsync(token); + logger.LogWarning( + "Reporting result for command {CommandId} returned HTTP {Status}: {Body}", + commandId, (int)response.StatusCode, body); + } + } + + public void Dispose() => http.Dispose(); +} diff --git a/agent/ShopDbEndpointAgent.Core/CommandExecutor.cs b/agent/ShopDbEndpointAgent.Core/CommandExecutor.cs new file mode 100644 index 00000000..c739bdc7 --- /dev/null +++ b/agent/ShopDbEndpointAgent.Core/CommandExecutor.cs @@ -0,0 +1,581 @@ +using System.Diagnostics; +using System.ServiceProcess; +using Microsoft.Extensions.Logging; + +namespace ShopDbEndpointAgent.Core; + +// Runs an allowlisted endpoint command locally and returns the result to post +// back. The allowlist here mirrors the server's (plugins/endpoints/commands.py): +// the wire carries an action NAME plus, for generic actions, a TARGET, and this +// is the only place that maps a name to concrete behavior. An unknown name +// fails closed - it is never treated as a command string to execute. +// +// Two families: +// Generic primitives (data-driven; NO agent rebuild for a new app/policy): +// service_start / service_stop / service_status - act on claim.Target, a +// Windows service name. +// process_stop / process_status - act on claim.Target, a process image name +// (for example 'DncMain.exe'). process_start also acts on the image name +// but launches ONLY through a per-image allowlist of known-good paths +// (AgentConfig.LaunchPaths, shipped defaults overridable per site); an +// unmapped target fails closed so the wire can never launch an arbitrary +// binary. process_start launches into the ACTIVE CONSOLE USER'S session +// (not session 0) at the user's medium integrity so the app is visible +// on their desktop; CyberArk/EPM brokers any elevation afterward. Stop +// and status stay as SYSTEM. +// Fixed MTConnect actions (target pinned in the agent, never on the wire): +// mtconnect_start / mtconnect_stop / mtconnect_status. +// MTConnect is a Windows service ("UDC MTConnect Agent"), so all three run +// as SYSTEM (service control needs the admin/SYSTEM token). Start runs +// install.bat (Agent.exe install) to install or repair the service, then +// ensures it is running; stop stops the service; status queries it. NOTE: +// run.bat (agent.exe run agent.cfg) is a foreground TESTING launcher and is +// deliberately NOT used - do not route MTConnect through the generic +// process_start user-session path or the service control breaks. +// +// Every action probes and reports the resulting run state (MtConnectRunning for +// MTConnect; the generic probes report via Message/Detail) so the WebUI toggle +// reflects reality rather than the action's intent. +public sealed class CommandExecutor +{ + // The UDC MTConnect agent is a Windows service. Its name is fixed here (not + // sent over the wire) so a compromised WebUI cannot redirect service control. + // A later per-site override belongs in AgentConfig, not the command payload. + private const string MtConnectServiceName = "UDC MTConnect Agent"; + + // The MTConnect install/repair seam: Agent.exe install. run.bat (agent.exe + // run agent.cfg) is a foreground TESTING launcher and is deliberately NOT + // used here. install.bat registers or repairs the service; the start handler + // runs it, then ensures the service is running. + private const string MtConnectInstallBat = + @"C:\ProgramData\UDC\MTConnect_UDC\Agent\install.bat"; + + private readonly ILogger logger; + private readonly TimeSpan startTimeout; + + // Resolved per-image launchable paths for process_start (shipped defaults + // overlaid with any appsettings.json overrides). A target absent from this + // map cannot be started - process_start fails closed. + private readonly IReadOnlyDictionary launchPaths; + + public CommandExecutor(ILogger logger, + AgentConfig config, TimeSpan? startTimeout = null) + { + this.logger = logger; + this.startTimeout = startTimeout ?? TimeSpan.FromSeconds(30); + this.launchPaths = (config ?? new AgentConfig()).ResolveLaunchPaths(); + } + + public async Task ExecuteAsync( + CommandClaim claim, CancellationToken token) + { + var action = claim.Action; + var target = (claim.Target ?? "").Trim(); + switch (action) + { + // Fixed MTConnect actions: target is pinned in the agent. + case "mtconnect_start": + return await MtConnectStartAsync(token); + case "mtconnect_stop": + return MtConnectStop(); + case "mtconnect_status": + return MtConnectStatus(); + + // Generic service primitives: act on the wire target. + case "service_start": + return RequireTarget(action, target) + ?? ServiceControl(target, ServiceControllerStatus.Running); + case "service_stop": + return RequireTarget(action, target) + ?? ServiceControl(target, ServiceControllerStatus.Stopped); + case "service_status": + return RequireTarget(action, target) + ?? ServiceStatus(target); + + // Generic process primitives: act on the wire target image name. + case "process_stop": + return RequireTarget(action, target) + ?? ProcessStop(target); + case "process_status": + return RequireTarget(action, target) + ?? ProcessStatus(target); + case "process_start": + return RequireTarget(action, target) + ?? await ProcessStart(target, token); + + // Fixed destructive action: reboot this endpoint immediately. Runs + // as the SYSTEM agent, so shutdown does not need a user session. + case "system_restart": + return SystemRestart(); + + // Force an immediate off-cadence telemetry report. The handler is a + // no-op success: CommandWorker fires collectSignal.Request() after + // every command, which wakes the telemetry loop to collect now, so + // the WebUI reflects live state instead of waiting for cadence. + case "collect_now": + logger.LogInformation("collect_now requested: forcing off-cadence telemetry"); + return new CommandResult + { + Succeeded = true, + Message = "Fresh telemetry collection requested", + }; + + default: + logger.LogWarning("Rejected unknown command action {Action}", action); + return new CommandResult + { + Succeeded = false, + Message = $"Unknown or disallowed action: {action}", + }; + } + } + + // Guard: a generic action with no target has nothing to act on. Returns a + // failed result to short-circuit, or null when the target is present. + private CommandResult? RequireTarget(string action, string target) + { + if (!string.IsNullOrWhiteSpace(target)) + return null; + logger.LogWarning("Action {Action} arrived with no target", action); + return new CommandResult + { + Succeeded = false, + Message = $"Action {action} requires a target", + }; + } + + // ---- Fixed destructive actions ---- + + // Reboot the machine now. shutdown.exe with /t 0 gives no grace timer; the + // WebUI confirmation gate is the safety, not a countdown. As SYSTEM the + // agent already holds the privilege, so no session or elevation dance. + private CommandResult SystemRestart() + { + try + { + var restart = new ProcessStartInfo + { + FileName = "shutdown.exe", + Arguments = "/r /t 0", + UseShellExecute = false, + CreateNoWindow = true, + }; + using var process = Process.Start(restart); + logger.LogWarning("system_restart issued: rebooting now"); + return new CommandResult + { + Succeeded = true, + Message = "Restart initiated", + }; + } + catch (Exception error) + { + logger.LogError(error, "system_restart failed"); + return new CommandResult + { + Succeeded = false, + Message = $"Failed to initiate restart: {error.Message}", + }; + } + } + + // ---- Generic service primitives ---- + + private CommandResult ServiceControl( + string serviceName, ServiceControllerStatus desired) + { + try + { + using var controller = new ServiceController(serviceName); + + if (controller.Status == desired) + { + return new CommandResult + { + Succeeded = true, + Message = $"Service {serviceName} already {desired}", + Detail = ServiceDetail(controller), + }; + } + + if (desired == ServiceControllerStatus.Running) + controller.Start(); + else + controller.Stop(); + + controller.WaitForStatus(desired, startTimeout); + controller.Refresh(); + var reached = controller.Status == desired; + return new CommandResult + { + Succeeded = reached, + Message = reached + ? $"Service {serviceName} {desired}" + : $"Service {serviceName} did not reach {desired} within timeout", + Detail = ServiceDetail(controller), + }; + } + catch (Exception error) + { + logger.LogWarning(error, "Failed to control service {Service}", serviceName); + return new CommandResult + { + Succeeded = false, + Message = $"Failed to control service {serviceName}: {error.Message}", + }; + } + } + + private CommandResult ServiceStatus(string serviceName) + { + try + { + using var controller = new ServiceController(serviceName); + return new CommandResult + { + Succeeded = true, + Message = $"Service {serviceName} is {controller.Status}", + Detail = ServiceDetail(controller), + }; + } + catch (Exception error) + { + logger.LogWarning(error, "Failed to read service {Service}", serviceName); + return new CommandResult + { + Succeeded = false, + Message = $"Failed to read service {serviceName}: {error.Message}", + }; + } + } + + private static Dictionary ServiceDetail(ServiceController controller) + { + controller.Refresh(); + return new Dictionary + { + ["kind"] = "service", + ["name"] = controller.ServiceName, + ["status"] = controller.Status.ToString(), + ["running"] = controller.Status == ServiceControllerStatus.Running, + }; + } + + // ---- Generic process primitives ---- + + private CommandResult ProcessStop(string image) + { + var processes = FindProcessesByImage(image); + if (processes.Count == 0) + { + return new CommandResult + { + Succeeded = true, + Message = $"Process {image} already stopped", + Detail = ProcessDetail(image, false), + }; + } + + var errors = new List(); + foreach (var process in processes) + { + try + { + process.Kill(entireProcessTree: true); + process.WaitForExit(10000); + } + catch (Exception error) + { + errors.Add(error.Message); + logger.LogWarning(error, "Failed to stop process {Pid}", process.Id); + } + finally + { + process.Dispose(); + } + } + + var running = IsProcessRunning(image); + return new CommandResult + { + Succeeded = !running, + Message = running + ? $"Process {image} still running after stop: {string.Join("; ", errors)}" + : $"Process {image} stopped", + Detail = ProcessDetail(image, running), + }; + } + + private CommandResult ProcessStatus(string image) + { + var running = IsProcessRunning(image); + return new CommandResult + { + Succeeded = true, + Message = running ? $"Process {image} running" : $"Process {image} stopped", + Detail = ProcessDetail(image, running), + }; + } + + private static Dictionary ProcessDetail(string image, bool running) + => new() + { + ["kind"] = "process", + ["name"] = image, + ["running"] = running, + }; + + // Start a process by image name, but ONLY if it resolves to a known-good + // launchable path (shipped defaults + appsettings overrides). The wire never + // carries a path, so a compromised WebUI cannot launch an arbitrary binary: + // an unmapped target fails closed. A mapped .bat is launched from its own + // directory; a mapped .exe is launched + // directly. Either way we do not wait for the launcher to exit - we poll for + // the image to appear and report the resulting run state. + private async Task ProcessStart(string image, CancellationToken token) + { + if (IsProcessRunning(image)) + { + return new CommandResult + { + Succeeded = true, + Message = $"Process {image} already running", + Detail = ProcessDetail(image, true), + }; + } + + var bare = image; + if (bare.EndsWith(".exe", StringComparison.OrdinalIgnoreCase)) + bare = bare[..^4]; + + if (!launchPaths.TryGetValue(bare, out var launchPath) + || string.IsNullOrWhiteSpace(launchPath)) + { + logger.LogWarning( + "Rejected process_start for {Image}: no launchable path configured", + image); + return new CommandResult + { + Succeeded = false, + Message = $"process_start is not permitted for {image}: no launch path configured", + Detail = ProcessDetail(image, false), + }; + } + + if (!File.Exists(launchPath)) + { + logger.LogWarning( + "process_start for {Image}: configured launch path does not exist: {Path}", + image, launchPath); + return new CommandResult + { + Succeeded = false, + Message = $"Launch path not found for {image}: {launchPath}", + Detail = ProcessDetail(image, false), + }; + } + + try + { + // Launch into the ACTIVE CONSOLE USER'S session, not session 0. The + // agent is a SYSTEM service; a plain Process.Start here would put the + // GUI app on the hidden session-0 desktop where the operator never + // sees it. The app starts at the user's medium integrity and + // CyberArk/EPM brokers any elevation afterward. + var workingDirectory = Path.GetDirectoryName(launchPath) ?? ""; + var outcome = InteractiveProcessLauncher.LaunchInActiveSession( + launchPath, workingDirectory); + if (!outcome.Launched) + { + logger.LogWarning( + "process_start could not launch {Image} in the user session: {Error}", + image, outcome.Error); + return new CommandResult + { + Succeeded = false, + Message = $"Failed to start {image}: {outcome.Error}", + Detail = ProcessDetail(image, false), + }; + } + + // The launcher (an .exe or a possibly long-lived run.bat) may not be + // the watched image itself, so poll for the target image to appear + // rather than waiting on the launcher to exit. + var deadline = DateTime.UtcNow + startTimeout; + while (DateTime.UtcNow < deadline) + { + if (IsProcessRunning(image)) + return new CommandResult + { + Succeeded = true, + Message = $"Process {image} started", + Detail = ProcessDetail(image, true), + }; + await Task.Delay(1000, token); + } + + return new CommandResult + { + Succeeded = false, + Message = $"Process {image} did not report running within the timeout", + Detail = ProcessDetail(image, IsProcessRunning(image)), + }; + } + catch (Exception ex) + { + logger.LogError(ex, "process_start failed for {Image} via {Path}", + image, launchPath); + return new CommandResult + { + Succeeded = false, + Message = $"Failed to start {image}: {ex.Message}", + Detail = ProcessDetail(image, false), + }; + } + } + + private bool IsProcessRunning(string image) + { + var processes = FindProcessesByImage(image); + try + { + return processes.Count > 0; + } + finally + { + foreach (var process in processes) + process.Dispose(); + } + } + + // Match a target image name against running processes. Accepts 'DncMain.exe' + // or 'DncMain' - Process.GetProcessesByName wants the bare name, so strip a + // trailing .exe before matching. + private static List FindProcessesByImage(string image) + { + var bare = image; + if (bare.EndsWith(".exe", StringComparison.OrdinalIgnoreCase)) + bare = bare[..^4]; + + var found = new List(); + try + { + found.AddRange(Process.GetProcessesByName(bare)); + } + catch + { + // Access-denied on a foreign session's process: not our agent's to + // see, so treat it as absent rather than failing the probe. + } + return found; + } + + // ---- Fixed MTConnect actions ---- + + private async Task MtConnectStartAsync(CancellationToken token) + { + if (MtConnectServiceRunning()) + { + return new CommandResult + { + Succeeded = true, + Message = "MTConnect already running", + MtConnectRunning = true, + }; + } + + // install.bat (Agent.exe install) installs or repairs the service. Run it + // as SYSTEM and wait for it to exit - unlike the long-lived run.bat, the + // installer is a quick one-shot. Missing installer is not fatal on its + // own: the service may already be registered, so fall through to start. + if (File.Exists(MtConnectInstallBat)) + { + try + { + var install = new ProcessStartInfo + { + FileName = MtConnectInstallBat, + WorkingDirectory = Path.GetDirectoryName(MtConnectInstallBat) ?? "", + UseShellExecute = false, + CreateNoWindow = true, + }; + using var installer = Process.Start(install); + if (installer is not null) + { + try + { + await installer.WaitForExitAsync(token); + } + catch (OperationCanceledException) + { + // Shutting down; leave the service in whatever state it + // reached and report the current status below. + } + } + } + catch (Exception error) + { + logger.LogWarning(error, "MTConnect install.bat failed"); + } + } + else + { + logger.LogWarning( + "MTConnect installer not found: {Installer}; attempting service start anyway", + MtConnectInstallBat); + } + + // install.bat may or may not leave the service started; ensure it is + // running via the same service-control path the generic primitives use. + var result = ServiceControl(MtConnectServiceName, ServiceControllerStatus.Running); + return new CommandResult + { + Succeeded = result.Succeeded, + Message = result.Succeeded ? "MTConnect started" : result.Message, + MtConnectRunning = MtConnectServiceRunning(), + Detail = result.Detail, + }; + } + + private CommandResult MtConnectStop() + { + var result = ServiceControl(MtConnectServiceName, ServiceControllerStatus.Stopped); + var running = MtConnectServiceRunning(); + return new CommandResult + { + Succeeded = result.Succeeded, + Message = running ? result.Message : "MTConnect stopped", + MtConnectRunning = running, + Detail = result.Detail, + }; + } + + private CommandResult MtConnectStatus() + { + var result = ServiceStatus(MtConnectServiceName); + var running = MtConnectServiceRunning(); + return new CommandResult + { + Succeeded = result.Succeeded, + Message = running ? "MTConnect running" : "MTConnect stopped", + MtConnectRunning = running, + Detail = result.Detail, + }; + } + + // Probe the MTConnect Windows service state. A missing/unqueryable service + // (not installed, access denied on a foreign session) reports not running + // rather than throwing, so the WebUI toggle degrades to "stopped". + private bool MtConnectServiceRunning() + { + try + { + using var controller = new ServiceController(MtConnectServiceName); + return controller.Status == ServiceControllerStatus.Running; + } + catch + { + return false; + } + } +} diff --git a/agent/ShopDbEndpointAgent.Core/CommandWorker.cs b/agent/ShopDbEndpointAgent.Core/CommandWorker.cs new file mode 100644 index 00000000..9389d805 --- /dev/null +++ b/agent/ShopDbEndpointAgent.Core/CommandWorker.cs @@ -0,0 +1,143 @@ +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace ShopDbEndpointAgent.Core; + +// The command loop as a hosted BackgroundService, separate from TelemetryWorker +// so a slow or held-open command poll never delays a telemetry report and vice +// versa. It long-polls the server for an allowlisted command +// (plugins/endpoints/AGENT-CONTRACT.md section 7), runs it through +// CommandExecutor, and posts the result back. The server holds each poll open +// briefly, so this loop is effectively "wait for work, do it, immediately wait +// again" rather than a fixed-cadence poll. +// +// Like TelemetryWorker, one bad iteration never kills the loop: a permanent +// error (bad key, unknown host) backs off to a slow retry so it does not hammer +// the server, and any other failure is logged and retried after a short pause. +public sealed class CommandWorker : BackgroundService +{ + // Pause after a transient failure before re-polling, so a flapping network + // or a briefly-down server does not spin this loop. + private static readonly TimeSpan TransientRetryDelay = TimeSpan.FromSeconds(10); + + // Longer back-off after a permanent error (bad key, host removed): the + // operator has to fix something, so re-polling fast changes nothing. + private static readonly TimeSpan PermanentRetryDelay = TimeSpan.FromSeconds(300); + + private readonly CommandClient client; + private readonly CommandExecutor executor; + private readonly ILogger logger; + private readonly CollectSignal? collectSignal; + private readonly string hostname; + + public CommandWorker(CommandClient client, CommandExecutor executor, + ILogger logger, CollectSignal? collectSignal = null) + { + this.client = client; + this.executor = executor; + this.logger = logger; + this.collectSignal = collectSignal; + hostname = Environment.MachineName; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + logger.LogInformation("Command channel started for host {Host}", hostname); + + while (!stoppingToken.IsCancellationRequested) + { + try + { + var claim = await client.ClaimNextAsync(hostname, stoppingToken); + if (claim is not null) + await RunClaimAsync(claim, stoppingToken); + // On a 204 (no work) the server already consumed the hold + // window, so re-poll immediately. + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + break; + } + catch (CollectorException error) when (error.IsPermanent) + { + logger.LogError(error, + "Permanent command-channel error; backing off"); + await SafeDelay(PermanentRetryDelay, stoppingToken); + } + catch (Exception error) when (IsIdlePollClose(error)) + { + // The server (or a proxy) closed the held-open long poll without + // a command. That is the normal end of a hold window, not a + // failure: log quietly at debug and re-poll immediately. + logger.LogDebug(error, "Command poll connection closed; re-polling"); + } + catch (Exception error) + { + logger.LogWarning(error, "Command poll failed; retrying shortly"); + await SafeDelay(TransientRetryDelay, stoppingToken); + } + } + + logger.LogInformation("Command channel stopped"); + } + + private async Task RunClaimAsync(CommandClaim claim, CancellationToken token) + { + logger.LogInformation("Running command {Id} action={Action}", + claim.EndpointCommandId, claim.Action); + + CommandResult result; + try + { + result = await executor.ExecuteAsync(claim, token); + } + catch (Exception error) + { + logger.LogError(error, "Command {Id} action={Action} threw", + claim.EndpointCommandId, claim.Action); + result = new CommandResult + { + Succeeded = false, + Message = $"Command failed: {error.Message}", + }; + } + + await client.ReportResultAsync(claim.EndpointCommandId, result, token); + logger.LogInformation("Command {Id} finished succeeded={Succeeded}", + claim.EndpointCommandId, result.Succeeded); + + // A command changed something on the endpoint (e.g. MTConnect start/stop); + // wake the telemetry loop to collect now so the WebUI card reflects the + // new state within one report cycle instead of after the full cadence. + collectSignal?.Request(); + } + + // A long poll that the server or an intermediary closes without sending a + // command surfaces as an HttpRequestException wrapping an HttpIOException + // (ResponseEnded), or a bare IOException. That is the expected end of a hold + // window, so it should not be treated as a transient failure. + private static bool IsIdlePollClose(Exception error) + { + for (Exception? current = error; current is not null; current = current.InnerException) + { + if (current is System.Net.Http.HttpIOException httpIo + && httpIo.HttpRequestError == System.Net.Http.HttpRequestError.ResponseEnded) + return true; + if (current is System.IO.IOException) + return true; + } + return false; + } + + private static async Task SafeDelay(TimeSpan delay, CancellationToken token) + { + try + { + await Task.Delay(delay, token); + } + catch (OperationCanceledException) + { + // Shutting down: let the loop condition end it. + } + } +} diff --git a/agent/ShopDbEndpointAgent.Core/Contracts.cs b/agent/ShopDbEndpointAgent.Core/Contracts.cs new file mode 100644 index 00000000..cb504a36 --- /dev/null +++ b/agent/ShopDbEndpointAgent.Core/Contracts.cs @@ -0,0 +1,92 @@ +using System.Text.Json.Serialization; + +namespace ShopDbEndpointAgent.Core; + +// Wire keys MUST stay lowercase/no-underscore to match the ShopDB collector +// schema (plugins/endpoints/AGENT-CONTRACT.md). PascalCase C# properties are +// mapped back with JsonPropertyName so the serializer does not rename them. + +public sealed class EndpointReport +{ + [JsonPropertyName("hostname")] public string Hostname { get; set; } = ""; + [JsonPropertyName("adopt")] public bool? Adopt { get; set; } + [JsonPropertyName("loggedinuser")] public string? LoggedInUser { get; set; } + [JsonPropertyName("serialnumber")] public string? SerialNumber { get; set; } + [JsonPropertyName("pctype")] public string? PcType { get; set; } + [JsonPropertyName("vendorname")] public string? VendorName { get; set; } + [JsonPropertyName("modelnumber")] public string? ModelNumber { get; set; } + [JsonPropertyName("osname")] public string? OsName { get; set; } + [JsonPropertyName("ipaddress")] public string? IpAddress { get; set; } + [JsonPropertyName("machinenumber")] public string? MachineNumber { get; set; } + [JsonPropertyName("agentversion")] public string? AgentVersion { get; set; } + [JsonPropertyName("lastboottime")] public DateTimeOffset? LastBootTime { get; set; } + [JsonPropertyName("uptimeseconds")] public long? UptimeSeconds { get; set; } + [JsonPropertyName("idleseconds")] public long? IdleSeconds { get; set; } + [JsonPropertyName("cpupercent")] public double? CpuPercent { get; set; } + [JsonPropertyName("memorypercent")] public double? MemoryPercent { get; set; } + [JsonPropertyName("services")] public object[]? Services { get; set; } + [JsonPropertyName("ports")] public object[]? Ports { get; set; } + [JsonPropertyName("interfaces")] public object[]? Interfaces { get; set; } + [JsonPropertyName("scheduledtasks")] public object[]? ScheduledTasks { get; set; } + [JsonPropertyName("events")] public EndpointEventRecord[]? Events { get; set; } +} + +public sealed class EndpointEventRecord +{ + [JsonPropertyName("eventid")] public int EventId { get; set; } + [JsonPropertyName("eventchannel")] public string? EventChannel { get; set; } + [JsonPropertyName("eventsource")] public string? EventSource { get; set; } + [JsonPropertyName("eventtime")] public DateTimeOffset? EventTime { get; set; } + [JsonPropertyName("eventmessage")] public string? EventMessage { get; set; } + [JsonPropertyName("fields")] public Dictionary? Fields { get; set; } +} + +public sealed class CollectorResponse +{ + [JsonPropertyName("status")] public string Status { get; set; } = ""; + [JsonPropertyName("action")] public string? Action { get; set; } + [JsonPropertyName("assetid")] public int? AssetId { get; set; } + [JsonPropertyName("identityvalue")] public string? IdentityValue { get; set; } + [JsonPropertyName("warnings")] public string[]? Warnings { get; set; } +} + +// The collector wraps the plugin payload in the ShopDB envelope +// { status, data: { action, assetid, identityvalue, warnings }, meta }. +public sealed class CollectorEnvelope +{ + [JsonPropertyName("status")] public string Status { get; set; } = ""; + [JsonPropertyName("data")] public CollectorResponse? Data { get; set; } +} + +// A command the server claimed for this endpoint on the long-poll. The +// allowlisted action name crosses the wire (see AGENT-CONTRACT.md section 7); +// generic actions (service_* / process_*) also carry the subject to act on - +// Target is a Windows service name or a process image (for example +// 'DncMain.exe') and TargetKind is 'service' or 'process'. Fixed-target actions +// (mtconnect_*) leave both null. Carrying the target on the wire is what lets a +// site build the agent once and drive every future policy/app from server data. +public sealed class CommandClaim +{ + [JsonPropertyName("endpointcommandid")] public int EndpointCommandId { get; set; } + [JsonPropertyName("action")] public string Action { get; set; } = ""; + [JsonPropertyName("target")] public string? Target { get; set; } + [JsonPropertyName("targetkind")] public string? TargetKind { get; set; } +} + +// Envelope for the claim long-poll: { status, data: { endpointcommandid, action } }. +public sealed class CommandClaimEnvelope +{ + [JsonPropertyName("status")] public string Status { get; set; } = ""; + [JsonPropertyName("data")] public CommandClaim? Data { get; set; } +} + +// The outcome the agent posts back after running a claimed command. When the +// action probed MTConnect, MtConnectRunning carries the observed state so the +// server can cache it for the WebUI toggle. +public sealed class CommandResult +{ + [JsonPropertyName("succeeded")] public bool Succeeded { get; set; } + [JsonPropertyName("message")] public string? Message { get; set; } + [JsonPropertyName("mtconnectrunning")] public bool? MtConnectRunning { get; set; } + [JsonPropertyName("detail")] public Dictionary? Detail { get; set; } +} diff --git a/agent/ShopDbEndpointAgent.Core/CuratedEventCatalog.cs b/agent/ShopDbEndpointAgent.Core/CuratedEventCatalog.cs new file mode 100644 index 00000000..57b44d7f --- /dev/null +++ b/agent/ShopDbEndpointAgent.Core/CuratedEventCatalog.cs @@ -0,0 +1,44 @@ +namespace ShopDbEndpointAgent.Core; + +// The curated Windows Event catalog, LOCKED to the server list in +// plugins/endpoints/plugin.py (CURATED_EVENT_IDS) and documented in +// plugins/endpoints/DESIGN-DECISIONS-agent.md section 4. The server stores ONLY +// these IDs; anything else in a report's `events` is ignored (not an error). +// Sending only these keeps the payload small and mirrors the catalog intent. +// +// Channels matter: an ID is read from its own log, so the collector reads +// Security, System, and Application - not System alone. IDs are namespaced by +// channel here because the same numeric ID can mean different things across +// logs; we only harvest the (channel, id) pairs the server curates. +public static class CuratedEventCatalog +{ + public sealed record Entry(int EventId, string Channel, string Meaning); + + public static readonly IReadOnlyList Entries = new List + { + new(4624, "Security", "Successful logon"), + new(4625, "Security", "Failed logon"), + new(4634, "Security", "Logoff"), + new(4672, "Security", "Special privileges assigned to new logon"), + new(4697, "Security", "Service installed"), + new(4698, "Security", "Scheduled task created"), + new(4618, "Security", "Monitored security event pattern"), + new(41, "System", "Kernel-Power, unexpected shutdown"), + new(55, "System", "NTFS file system corruption"), + new(7011, "System", "Service Control Manager timeout"), + new(10016, "System", "DCOM permission error"), + new(1001, "Application", "Application error / bugcheck report"), + }; + + // The distinct channels the collector must read. + public static readonly IReadOnlyList Channels = + Entries.Select(entry => entry.Channel).Distinct().ToList(); + + // The set of curated IDs for a given channel, for a fast membership check + // while walking that channel's log newest-first. + public static IReadOnlySet IdsForChannel(string channel) + => Entries.Where(entry => + string.Equals(entry.Channel, channel, StringComparison.OrdinalIgnoreCase)) + .Select(entry => entry.EventId) + .ToHashSet(); +} diff --git a/agent/ShopDbEndpointAgent.Core/EndpointCollector.cs b/agent/ShopDbEndpointAgent.Core/EndpointCollector.cs new file mode 100644 index 00000000..4154db63 --- /dev/null +++ b/agent/ShopDbEndpointAgent.Core/EndpointCollector.cs @@ -0,0 +1,489 @@ +using System.Diagnostics; +using System.Management; +using System.Text.Json; +using Microsoft.Extensions.Logging; +using Microsoft.Win32; + +namespace ShopDbEndpointAgent.Core; + +// Pure-C# endpoint collector implementing ICollector. Mirrors the ShopDB +// PowerShell asset-collector data set (minus the CSV emit): hostname, logged-in +// user, serial number, pc type, boot time, monitored processes, plus a curated +// Windows Event Log tail across the Security/System/Application channels the +// server curates (CuratedEventCatalog). WMI/registry/event access can throw or +// return null on a locked-down box, so every probe is defensive and never +// aborts the report. +public sealed class EndpointCollector : ICollector +{ + // Max curated events harvested per channel per report, newest-first, to + // keep the payload bounded on a chatty Security log. + private const int MaxEventsPerChannel = 25; + + private readonly ILogger logger; + private readonly AgentConfig config; + private readonly IAgentUtilityRunner? utilities; + + public EndpointCollector(ILogger logger, AgentConfig config, + IAgentUtilityRunner? utilities = null) + { + this.logger = logger; + this.config = config; + this.utilities = utilities; + } + + public EndpointReport Collect(string agentVersion, bool adopt) + { + var report = new EndpointReport + { + Hostname = Environment.MachineName, + AgentVersion = agentVersion, + Adopt = adopt ? true : null, + LoggedInUser = GetLoggedInUser(), + SerialNumber = GetSerialNumber(), + PcType = GetPcType(), + VendorName = GetVendor(), + ModelNumber = GetModelNumber(), + OsName = GetOsName(), + IpAddress = GetPrimaryIpAddress(), + MachineNumber = GetMachineNumber(), + LastBootTime = GetLastBootTime(), + Services = GetMonitoredTargets(), + Interfaces = GetInterfaces(), + Events = GetCuratedEvents(), + }; + + if (report.LastBootTime is DateTimeOffset boot) + report.UptimeSeconds = (long)(DateTimeOffset.UtcNow - boot).TotalSeconds; + + return report; + } + + // Combined watch list: interactive processes (by image name) and Windows + // services (by service key name). Both land in report.Services with a shared + // {name, app, kind, state, running, count} shape so the server/UI renders + // one list of health badges. + private object[] GetMonitoredTargets() + { + var result = new List(); + result.AddRange(GetMonitoredProcesses()); + result.AddRange(GetMonitoredServices()); + return result.ToArray(); + } + + private IEnumerable GetMonitoredProcesses() + { + var targets = config.Monitors?.Processes ?? Array.Empty(); + if (targets.Length == 0) + return Array.Empty(); + + var running = new Dictionary(StringComparer.OrdinalIgnoreCase); + try + { + foreach (var proc in Process.GetProcesses()) + { + string image; + try { image = proc.ProcessName + ".exe"; } + catch { continue; } + finally { proc.Dispose(); } + + if (Array.FindIndex(targets, t => + string.Equals(t.Name, image, StringComparison.OrdinalIgnoreCase)) < 0) + continue; + + running.TryGetValue(image, out int count); + running[image] = count + 1; + } + } + catch (Exception error) + { + logger.LogDebug(error, "Process enumeration blocked"); + } + + var result = new List(targets.Length); + foreach (var target in targets) + { + bool found = running.TryGetValue(target.Name, out int count); + result.Add(new + { + name = target.Name, + app = string.IsNullOrWhiteSpace(target.App) ? target.Name : target.App, + kind = "process", + // state string so the UI badge can color it; missing = unknown. + state = found ? "running" : "stopped", + running = found, + count = found ? count : 0, + }); + } + return result; + } + + // Windows services watched by service key name. Reports the reported state + // (running/stopped/etc.) from Win32_Service so the server can flag a + // collector service (MTConnect and the like) that has stopped or gone + // missing. Never aborts: a locked-down WMI hive leaves entries "unknown". + private IEnumerable GetMonitoredServices() + { + var targets = config.Monitors?.Services ?? Array.Empty(); + if (targets.Length == 0) + return Array.Empty(); + + var states = new Dictionary(StringComparer.OrdinalIgnoreCase); + try + { + using var searcher = new ManagementObjectSearcher( + "SELECT Name, DisplayName, State FROM Win32_Service"); + foreach (var mo in searcher.Get()) + { + var state = mo["State"]?.ToString()?.Trim() ?? ""; + // Index by both service key name and display name so a config + // that used either one still matches. + var name = mo["Name"]?.ToString(); + if (!string.IsNullOrWhiteSpace(name)) + states[name.Trim()] = state; + var display = mo["DisplayName"]?.ToString(); + if (!string.IsNullOrWhiteSpace(display)) + states[display.Trim()] = state; + } + } + catch (Exception error) + { + logger.LogDebug(error, "Service enumeration blocked"); + } + + var result = new List(targets.Length); + foreach (var target in targets) + { + bool found = states.TryGetValue(target.Name, out var raw); + bool running = string.Equals(raw, "Running", StringComparison.OrdinalIgnoreCase); + result.Add(new + { + name = target.Name, + app = string.IsNullOrWhiteSpace(target.App) ? target.Name : target.App, + kind = "service", + // "unknown" = not installed / WMI unreadable; else lowercased + // Win32_Service state (running, stopped, start pending, ...). + state = found ? (raw ?? "").ToLowerInvariant() : "unknown", + running, + count = running ? 1 : 0, + }); + } + return result; + } + + private string? GetLoggedInUser() + { + var user = QueryFirst("SELECT UserName FROM Win32_ComputerSystem", "UserName"); + return string.IsNullOrWhiteSpace(user) + ? $"{Environment.UserDomainName}\\{Environment.UserName}" + : user; + } + + private string? GetSerialNumber() + => QueryFirst("SELECT SerialNumber FROM Win32_BIOS", "SerialNumber"); + + private string? GetPcType() + { + // Raw WMI model; the ShopDB pctype map normalizes it server-side, so the + // current site's catalog stays the single source of that mapping. + var model = QueryFirst("SELECT Model FROM Win32_ComputerSystem", "Model"); + return string.IsNullOrWhiteSpace(model) ? null : model; + } + + // Manufacturer for the Asset vendor/model rails (Win32_ComputerSystem + // Manufacturer, for example "Dell Inc.", "HP"). Server upserts Vendor. + private string? GetVendor() + => QueryFirst("SELECT Manufacturer FROM Win32_ComputerSystem", "Manufacturer"); + + // Hardware model number. Prefer the human-readable Win32_ComputerSystem + // Model (for example "OptiPlex 7090", "Latitude 5540") - the same value + // pctype classifies on. SystemSKUNumber is an OEM SKU code (for example + // Dell's "0BCC"), not a model name, so it is only a last resort when Model + // is blank. + private string? GetModelNumber() + { + var model = QueryFirst("SELECT Model FROM Win32_ComputerSystem", "Model"); + if (!string.IsNullOrWhiteSpace(model)) + return model; + return QueryFirst("SELECT SystemSKUNumber FROM Win32_ComputerSystem", + "SystemSKUNumber"); + } + + // OS caption, for example "Microsoft Windows 10 Enterprise". The server + // matches this against its controlled OperatingSystem vocab (warns if the + // exact string is unknown), so it may need a catalog entry to bind. + private string? GetOsName() + => QueryFirst("SELECT Caption FROM Win32_OperatingSystem", "Caption"); + + // Primary IPv4 address of the first enabled, non-loopback adapter with a + // default gateway. Feeds the Communication (IP) row server-side. + private string? GetPrimaryIpAddress() + { + try + { + using var searcher = new ManagementObjectSearcher( + "SELECT IPAddress, DefaultIPGateway, IPEnabled " + + "FROM Win32_NetworkAdapterConfiguration WHERE IPEnabled = True"); + string? firstEnabled = null; + foreach (var mo in searcher.Get()) + { + var addresses = mo["IPAddress"] as string[]; + if (addresses is null) + continue; + var ipv4 = Array.Find(addresses, a => + !string.IsNullOrWhiteSpace(a) && a.IndexOf(':') < 0); + if (string.IsNullOrWhiteSpace(ipv4)) + continue; + + firstEnabled ??= ipv4; + // Prefer an adapter that actually has a default gateway (the + // real LAN NIC, not a virtual/VPN interface). + if (mo["DefaultIPGateway"] is string[] gw && gw.Length > 0) + return ipv4; + } + return firstEnabled; + } + catch (Exception error) + { + logger.LogDebug(error, "IP address query failed"); + return null; + } + } + + // All hardware NICs, not just the primary. DESIGN-DECISIONS-agent.md 9.3: + // upwards of two-thirds of shop-floor PCs carry a SECOND, static, gatewayless + // NIC whose only job is to talk to the machine's onboard IPS. That interface + // is the physical PC-to-machine link and is exactly the one a single + // primary-IP probe throws away, so we report every enabled adapter with its + // alias/ipv4/mac/gateway/subnet and let the server keep the whole picture. + // Stored verbatim as JSON server-side (AGENT-CONTRACT.md), so keys are + // lowercase/no-underscore. + private object[] GetInterfaces() + { + var result = new List(); + try + { + using var searcher = new ManagementObjectSearcher( + "SELECT Description, IPAddress, IPSubnet, DefaultIPGateway, " + + "MACAddress, DHCPEnabled FROM Win32_NetworkAdapterConfiguration " + + "WHERE IPEnabled = True"); + foreach (var mo in searcher.Get()) + { + var mac = mo["MACAddress"] as string; + // No MAC = not a real hardware adapter (some tunnels/virtuals). + if (string.IsNullOrWhiteSpace(mac)) + continue; + + var addresses = mo["IPAddress"] as string[]; + var ipv4 = addresses is null ? null : Array.Find(addresses, + a => !string.IsNullOrWhiteSpace(a) && a.IndexOf(':') < 0); + + var subnets = mo["IPSubnet"] as string[]; + var subnet = subnets is null ? null : Array.Find(subnets, + s => !string.IsNullOrWhiteSpace(s) && s.IndexOf(':') < 0); + + var gateways = mo["DefaultIPGateway"] as string[]; + var gateway = gateways is null || gateways.Length == 0 + ? null : gateways[0]; + + result.Add(new + { + alias = mo["Description"]?.ToString()?.Trim() ?? "", + ipv4 = ipv4 ?? "", + mac = mac.Trim(), + // A gatewayless NIC is the likely machine-IPS link; the + // gateway NIC is the corporate LAN. Server/UI can tell them + // apart from these two fields. + gateway = gateway ?? "", + subnet = subnet ?? "", + dhcp = mo["DHCPEnabled"] as bool? ?? false, + }); + } + } + catch (Exception error) + { + logger.LogDebug(error, "Interface enumeration blocked"); + } + return result.ToArray(); + } + + // The four-digit ShopDB Machine Number, discovered from on-box app config. + // DESIGN-DECISIONS-agent.md 9.4/9.5: when eDNC or UDC is installed its config + // carries the number that binds this PC to a ShopDB machines row. Source + // priority: eDNC live registry first, then UDC settings JSON. "" or "9999" + // means none (AGENT-CONTRACT.md). Never throws out to the caller. + private string? GetMachineNumber() + { + var fromRegistry = GetMachineNumberFromEdncRegistry(); + if (IsUsableMachineNumber(fromRegistry)) + return fromRegistry!.Trim(); + + var fromUdc = GetMachineNumberFromUdcSettings(); + if (IsUsableMachineNumber(fromUdc)) + return fromUdc!.Trim(); + + return null; + } + + private static bool IsUsableMachineNumber(string? value) + => !string.IsNullOrWhiteSpace(value) && value.Trim() != "9999"; + + // eDNC writes MachineNo under the GE Aircraft Engines\DNC\General key. Try + // the 64-bit view then the 32-bit (WOW6432Node) view, mirroring + // AgentConfigProvider so a locked-down or 32-bit-installed box still reads. + private string? GetMachineNumberFromEdncRegistry() + { + const string subKey = @"SOFTWARE\GE Aircraft Engines\DNC\General"; + RegistryView[] views = { RegistryView.Registry64, RegistryView.Registry32 }; + foreach (var view in views) + { + try + { + using var machine = RegistryKey.OpenBaseKey( + RegistryHive.LocalMachine, view); + using var key = machine.OpenSubKey(subKey); + var value = key?.GetValue("MachineNo")?.ToString(); + if (IsUsableMachineNumber(value)) + return value; + } + catch (Exception error) + { + logger.LogDebug(error, + "eDNC registry read blocked in {View} view", view); + } + } + return null; + } + + // UDC stores GeneralSettings.MachineNumber in udc_settings_*.json under + // %ProgramData%\udc. Scan for the newest settings file and read the number. + private string? GetMachineNumberFromUdcSettings() + { + try + { + var root = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), + "udc"); + if (!Directory.Exists(root)) + return null; + + var files = Directory.EnumerateFiles( + root, "udc_settings*.json", SearchOption.AllDirectories) + .OrderByDescending(File.GetLastWriteTimeUtc); + + foreach (var file in files) + { + try + { + using var doc = JsonDocument.Parse(File.ReadAllText(file)); + if (doc.RootElement.TryGetProperty("GeneralSettings", out var general) + && general.TryGetProperty("MachineNumber", out var number)) + { + var value = number.ValueKind == JsonValueKind.String + ? number.GetString() + : number.ToString(); + if (IsUsableMachineNumber(value)) + return value; + } + } + catch (Exception error) + { + logger.LogDebug(error, "UDC settings parse failed: {File}", file); + } + } + } + catch (Exception error) + { + logger.LogDebug(error, "UDC settings scan failed"); + } + return null; + } + + private DateTimeOffset? GetLastBootTime() + { + var raw = QueryFirst( + "SELECT LastBootUpTime FROM Win32_OperatingSystem", "LastBootUpTime"); + if (string.IsNullOrWhiteSpace(raw)) + return null; + try + { + return ManagementDateTimeConverter.ToDateTime(raw).ToUniversalTime(); + } + catch (Exception error) + { + logger.LogDebug(error, "LastBootUpTime parse failed"); + return null; + } + } + + // Walk each curated channel newest-first, keep only the curated IDs for + // that channel, cap per channel. Reads Security/System/Application - the + // old scaffold read System only and filtered by the wrong IDs. + private EndpointEventRecord[] GetCuratedEvents() + { + var records = new List(); + foreach (var channel in CuratedEventCatalog.Channels) + { + var wanted = CuratedEventCatalog.IdsForChannel(channel); + try + { + using var log = new EventLog(channel); + int kept = 0; + for (int i = log.Entries.Count - 1; + i >= 0 && kept < MaxEventsPerChannel; i--) + { + EventLogEntry entry; + try { entry = log.Entries[i]; } catch { continue; } + + int id = (int)(entry.InstanceId & 0xFFFF); + if (!wanted.Contains(id)) + continue; + + records.Add(new EndpointEventRecord + { + EventId = id, + EventChannel = channel, + EventSource = entry.Source, + EventTime = new DateTimeOffset( + entry.TimeGenerated.ToUniversalTime(), TimeSpan.Zero), + EventMessage = Truncate(entry.Message, 1000), + }); + kept++; + } + } + catch (Exception error) + { + // Channel unreadable (Security needs elevation; a log may be + // off): log and continue with the other channels. + logger.LogDebug(error, "Event channel {Channel} unreadable", channel); + } + } + return records.ToArray(); + } + + private string? QueryFirst(string wql, string property) + { + try + { + using var searcher = new ManagementObjectSearcher(wql); + foreach (var mo in searcher.Get()) + { + var value = mo[property]?.ToString(); + if (!string.IsNullOrWhiteSpace(value)) + return value.Trim(); + } + } + catch (Exception error) + { + logger.LogDebug(error, "WMI query failed: {Query}", wql); + } + return null; + } + + private static string Truncate(string? text, int max) + { + if (string.IsNullOrEmpty(text)) + return ""; + text = text.Trim(); + return text.Length <= max ? text : text[..max]; + } +} diff --git a/agent/ShopDbEndpointAgent.Core/FileLoggerProvider.cs b/agent/ShopDbEndpointAgent.Core/FileLoggerProvider.cs new file mode 100644 index 00000000..fa374105 --- /dev/null +++ b/agent/ShopDbEndpointAgent.Core/FileLoggerProvider.cs @@ -0,0 +1,164 @@ +using System.Collections.Concurrent; +using System.Text; +using Microsoft.Extensions.Logging; + +namespace ShopDbEndpointAgent.Core; + +// A dependency-free rolling-file ILoggerProvider. The runtime code only ever +// talks to ILogger, so swapping or adding sinks later (a ShopDB HTTP sink, Seq, +// syslog) is a host-builder change with zero worker changes. This provider +// writes one line per entry to a daily file under the configured log directory +// and prunes files older than the retention window. +public sealed class FileLoggerProvider : ILoggerProvider +{ + private readonly string directory; + private readonly LogLevel minLevel; + private readonly int retentionDays; + private readonly object gate = new(); + private readonly ConcurrentDictionary loggers = new(); + private string currentDateStamp = ""; + private StreamWriter? writer; + + public FileLoggerProvider(string directory, LogLevel minLevel = LogLevel.Information, + int retentionDays = 14) + { + this.directory = directory; + this.minLevel = minLevel; + this.retentionDays = retentionDays; + // Best-effort: creating the log directory can fail on a locked-down host + // or when a non-privileged user targets a SYSTEM-owned path. Never let + // that abort construction; Write() degrades to a no-op if the sink is + // unusable. + try { Directory.CreateDirectory(directory); } + catch (Exception error) when (error is IOException or UnauthorizedAccessException) { } + } + + // The default log directory when config leaves LogPath empty: + // %ProgramData%\ShopDB\EndpointAgent\logs + public static string DefaultDirectory() + => Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), + "ShopDB", "EndpointAgent", "logs"); + + public ILogger CreateLogger(string categoryName) + => loggers.GetOrAdd(categoryName, name => new FileLogger(this, name)); + + internal bool IsEnabled(LogLevel level) => level >= minLevel && level != LogLevel.None; + + internal void Write(string category, LogLevel level, string message, Exception? error) + { + var now = DateTimeOffset.Now; + var line = new StringBuilder() + .Append(now.ToString("yyyy-MM-dd HH:mm:ss.fff zzz")) + .Append(" [").Append(LevelTag(level)).Append("] ") + .Append(ShortCategory(category)).Append(": ") + .Append(message); + if (error is not null) + line.Append(Environment.NewLine).Append(error); + + lock (gate) + { + // Logging must never take down the agent. If the log file cannot be + // opened or written (for example a non-privileged user running the + // exe against a logs directory owned by the SYSTEM service, which + // yields UnauthorizedAccessException on open), swallow it and drop + // the line rather than letting it unwind through bootstrap. A failed + // sink is a degraded state, not a fatal one. + try + { + RollIfNeeded(now); + writer?.WriteLine(line.ToString()); + } + catch (Exception sinkError) when (sinkError is IOException or UnauthorizedAccessException) + { + writer = null; + } + } + } + + private void RollIfNeeded(DateTimeOffset now) + { + var stamp = now.ToString("yyyyMMdd"); + if (stamp == currentDateStamp && writer is not null) + return; + + writer?.Flush(); + writer?.Dispose(); + currentDateStamp = stamp; + var path = Path.Combine(directory, $"agent-{stamp}.log"); + writer = new StreamWriter(new FileStream( + path, FileMode.Append, FileAccess.Write, FileShare.ReadWrite)) + { + AutoFlush = true, + }; + PruneOldFiles(); + } + + private void PruneOldFiles() + { + try + { + var cutoff = DateTime.Now.AddDays(-retentionDays); + foreach (var file in Directory.EnumerateFiles(directory, "agent-*.log")) + { + if (File.GetLastWriteTime(file) < cutoff) + File.Delete(file); + } + } + catch + { + // Pruning is best-effort; never let housekeeping break logging. + } + } + + private static string LevelTag(LogLevel level) => level switch + { + LogLevel.Trace => "TRC", + LogLevel.Debug => "DBG", + LogLevel.Information => "INF", + LogLevel.Warning => "WRN", + LogLevel.Error => "ERR", + LogLevel.Critical => "CRT", + _ => "---", + }; + + private static string ShortCategory(string category) + { + var dot = category.LastIndexOf('.'); + return dot >= 0 && dot < category.Length - 1 ? category[(dot + 1)..] : category; + } + + public void Dispose() + { + lock (gate) + { + writer?.Flush(); + writer?.Dispose(); + writer = null; + } + } + + private sealed class FileLogger : ILogger + { + private readonly FileLoggerProvider provider; + private readonly string category; + + public FileLogger(FileLoggerProvider provider, string category) + { + this.provider = provider; + this.category = category; + } + + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => provider.IsEnabled(logLevel); + + public void Log(LogLevel logLevel, EventId eventId, TState state, + Exception? exception, Func formatter) + { + if (!IsEnabled(logLevel)) + return; + provider.Write(category, logLevel, formatter(state, exception), exception); + } + } +} diff --git a/agent/ShopDbEndpointAgent.Core/ICollector.cs b/agent/ShopDbEndpointAgent.Core/ICollector.cs new file mode 100644 index 00000000..7be48930 --- /dev/null +++ b/agent/ShopDbEndpointAgent.Core/ICollector.cs @@ -0,0 +1,24 @@ +namespace ShopDbEndpointAgent.Core; + +// The collection surface. The runtime worker depends only on this, so the +// concrete implementation (pure C# today, possibly C# + PowerShell utilities +// later) can change without touching the worker or the HTTP client. +public interface ICollector +{ + EndpointReport Collect(string agentVersion, bool adopt); +} + +// The documented seam for site-provided PowerShell "agent utilities". The +// agent configuration settings page (server-side, future) lets site staff load +// their own .ps1 scripts as utilities; the runtime discovers them under the +// configured utilities directory and merges their JSON output into the report. +// +// This iteration ships the interface and a discovery contract only - the +// concrete runner is added when the settings page can push utilities down. +public interface IAgentUtilityRunner +{ + // Run every discovered utility and return a map of utility name -> parsed + // JSON result, to be attached to the report. Never throws: a failing + // utility is logged and skipped so it cannot break the telemetry run. + IReadOnlyDictionary RunAll(CancellationToken token); +} diff --git a/agent/ShopDbEndpointAgent.Core/InteractiveProcessLauncher.cs b/agent/ShopDbEndpointAgent.Core/InteractiveProcessLauncher.cs new file mode 100644 index 00000000..3b028bcb --- /dev/null +++ b/agent/ShopDbEndpointAgent.Core/InteractiveProcessLauncher.cs @@ -0,0 +1,200 @@ +using System.ComponentModel; +using System.Runtime.InteropServices; + +namespace ShopDbEndpointAgent.Core; + +// Launches a program in the ACTIVE CONSOLE USER'S session at that user's +// integrity, from the agent's LocalSystem service (session 0). +// +// Why this exists: the agent runs as a session-0 service. A plain +// Process.Start() there launches GUI apps (DncMain.exe, UDC.exe) onto the hidden +// session-0 desktop, so the logged-in operator never sees a window even though +// the process is running. process_start needs the app to appear on the user's +// desktop, so we obtain the interactive user's token and CreateProcessAsUser. +// +// Elevation is deliberately NOT handled here: the process starts at the user's +// normal (medium) integrity and CyberArk/EPM brokers any elevation afterward. +// +// Only process_start uses this. Stop/status/service actions stay as SYSTEM. +public static class InteractiveProcessLauncher +{ + public readonly record struct LaunchOutcome(bool Launched, string? Error); + + private const int TokenDuplicate = 0x0002; + private const int TokenQuery = 0x0008; + private const int TokenAssignPrimary = 0x0001; + private const int TokenAdjustPrivileges = 0x0020; + private const int TokenAdjustDefault = 0x0080; + private const int TokenAdjustSessionId = 0x0100; + + private const uint CreateUnicodeEnvironment = 0x00000400; + private const uint CreateNoWindow = 0x08000000; + + private const uint InvalidSessionId = 0xFFFFFFFF; + + // Returns Launched=true when CreateProcessAsUser succeeds. Never throws; + // failures come back as Launched=false with a descriptive Error so the + // caller can surface a clean CommandResult. + public static LaunchOutcome LaunchInActiveSession( + string applicationPath, string? workingDirectory) + { + var sessionId = WTSGetActiveConsoleSessionId(); + if (sessionId == InvalidSessionId) + return new LaunchOutcome(false, + "No active console session (no interactive user is signed in)."); + + if (!WTSQueryUserToken(sessionId, out var userToken)) + return new LaunchOutcome(false, + "Could not obtain the interactive user token (WTSQueryUserToken failed): " + + new Win32Exception(Marshal.GetLastWin32Error()).Message); + + var duplicatedToken = IntPtr.Zero; + var environmentBlock = IntPtr.Zero; + try + { + var desiredAccess = TokenAssignPrimary | TokenDuplicate | TokenQuery + | TokenAdjustPrivileges | TokenAdjustDefault | TokenAdjustSessionId; + if (!DuplicateTokenEx(userToken, (uint)desiredAccess, IntPtr.Zero, + SecurityImpersonationLevel.SecurityImpersonation, + TokenType.TokenPrimary, out duplicatedToken)) + { + return new LaunchOutcome(false, + "Could not duplicate the user token (DuplicateTokenEx failed): " + + new Win32Exception(Marshal.GetLastWin32Error()).Message); + } + + // A per-user environment block so the launched app sees the user's + // profile variables rather than SYSTEM's. + if (!CreateEnvironmentBlock(out environmentBlock, duplicatedToken, false)) + environmentBlock = IntPtr.Zero; + + var startupInfo = new STARTUPINFO(); + startupInfo.cb = Marshal.SizeOf(startupInfo); + // Target the interactive desktop so the window is visible. + startupInfo.lpDesktop = @"winsta0\default"; + + var creationFlags = CreateUnicodeEnvironment | CreateNoWindow; + + var launched = CreateProcessAsUser( + duplicatedToken, + applicationPath, + null, + IntPtr.Zero, + IntPtr.Zero, + false, + creationFlags, + environmentBlock, + workingDirectory, + ref startupInfo, + out var processInformation); + + if (!launched) + { + return new LaunchOutcome(false, + "CreateProcessAsUser failed: " + + new Win32Exception(Marshal.GetLastWin32Error()).Message); + } + + if (processInformation.hProcess != IntPtr.Zero) + CloseHandle(processInformation.hProcess); + if (processInformation.hThread != IntPtr.Zero) + CloseHandle(processInformation.hThread); + + return new LaunchOutcome(true, null); + } + finally + { + if (environmentBlock != IntPtr.Zero) + DestroyEnvironmentBlock(environmentBlock); + if (duplicatedToken != IntPtr.Zero) + CloseHandle(duplicatedToken); + if (userToken != IntPtr.Zero) + CloseHandle(userToken); + } + } + + private enum SecurityImpersonationLevel + { + SecurityAnonymous, + SecurityIdentification, + SecurityImpersonation, + SecurityDelegation, + } + + private enum TokenType + { + TokenPrimary = 1, + TokenImpersonation, + } + + [StructLayout(LayoutKind.Sequential)] + private struct PROCESS_INFORMATION + { + public IntPtr hProcess; + public IntPtr hThread; + public uint dwProcessId; + public uint dwThreadId; + } + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + private struct STARTUPINFO + { + public int cb; + public string? lpReserved; + public string? lpDesktop; + public string? lpTitle; + public uint dwX; + public uint dwY; + public uint dwXSize; + public uint dwYSize; + public uint dwXCountChars; + public uint dwYCountChars; + public uint dwFillAttribute; + public uint dwFlags; + public ushort wShowWindow; + public ushort cbReserved2; + public IntPtr lpReserved2; + public IntPtr hStdInput; + public IntPtr hStdOutput; + public IntPtr hStdError; + } + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern uint WTSGetActiveConsoleSessionId(); + + [DllImport("wtsapi32.dll", SetLastError = true)] + private static extern bool WTSQueryUserToken(uint sessionId, out IntPtr token); + + [DllImport("advapi32.dll", SetLastError = true)] + private static extern bool DuplicateTokenEx( + IntPtr existingToken, + uint desiredAccess, + IntPtr tokenAttributes, + SecurityImpersonationLevel impersonationLevel, + TokenType tokenType, + out IntPtr newToken); + + [DllImport("userenv.dll", SetLastError = true)] + private static extern bool CreateEnvironmentBlock( + out IntPtr environment, IntPtr token, bool inherit); + + [DllImport("userenv.dll", SetLastError = true)] + private static extern bool DestroyEnvironmentBlock(IntPtr environment); + + [DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + private static extern bool CreateProcessAsUser( + IntPtr token, + string? applicationName, + string? commandLine, + IntPtr processAttributes, + IntPtr threadAttributes, + bool inheritHandles, + uint creationFlags, + IntPtr environment, + string? currentDirectory, + ref STARTUPINFO startupInfo, + out PROCESS_INFORMATION processInformation); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool CloseHandle(IntPtr handle); +} diff --git a/agent/ShopDbEndpointAgent.Core/PowerShellUtilityRunner.cs b/agent/ShopDbEndpointAgent.Core/PowerShellUtilityRunner.cs new file mode 100644 index 00000000..e7c624b3 --- /dev/null +++ b/agent/ShopDbEndpointAgent.Core/PowerShellUtilityRunner.cs @@ -0,0 +1,114 @@ +using System.Diagnostics; +using System.Text.Json; +using Microsoft.Extensions.Logging; + +namespace ShopDbEndpointAgent.Core; + +// Site-provided PowerShell "agent utilities" runner. Discovers *.ps1 under the +// configured utilities directory and runs each with the OS powershell.exe, +// parsing its stdout as JSON. This is the documented collection seam: the +// future agent-configuration settings page lets site staff push their own +// scripts down, and their JSON output is merged into the report under +// `scheduledtasks`-adjacent extension keys without the runtime knowing what any +// script means. +// +// Safety posture for this iteration: +// - Disabled unless a utilities directory is configured AND exists. +// - Each script is bounded by a timeout and its failure is isolated. +// - Output must be a single JSON value; non-JSON output is logged and dropped. +// Script SIGNING/allow-listing is a rollout concern (code signing + Intune) and +// is called out in the design doc; do not enable this in production until that +// is in place. +public sealed class PowerShellUtilityRunner : IAgentUtilityRunner +{ + private readonly string utilitiesPath; + private readonly ILogger logger; + private readonly TimeSpan perScriptTimeout; + + public PowerShellUtilityRunner(string utilitiesPath, + ILogger logger, TimeSpan? perScriptTimeout = null) + { + this.utilitiesPath = utilitiesPath; + this.logger = logger; + this.perScriptTimeout = perScriptTimeout ?? TimeSpan.FromSeconds(30); + } + + public IReadOnlyDictionary RunAll(CancellationToken token) + { + var results = new Dictionary(); + if (string.IsNullOrWhiteSpace(utilitiesPath) || !Directory.Exists(utilitiesPath)) + return results; + + foreach (var script in Directory.EnumerateFiles(utilitiesPath, "*.ps1")) + { + token.ThrowIfCancellationRequested(); + var name = Path.GetFileNameWithoutExtension(script); + try + { + var parsed = RunOne(script, token); + if (parsed is not null) + results[name] = parsed; + } + catch (Exception error) + { + logger.LogWarning(error, "Agent utility {Utility} failed", name); + } + } + return results; + } + + private object? RunOne(string scriptPath, CancellationToken token) + { + var start = new ProcessStartInfo + { + FileName = "powershell.exe", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + start.ArgumentList.Add("-NoProfile"); + start.ArgumentList.Add("-NonInteractive"); + start.ArgumentList.Add("-ExecutionPolicy"); + start.ArgumentList.Add("Bypass"); + start.ArgumentList.Add("-File"); + start.ArgumentList.Add(scriptPath); + + using var process = new Process { StartInfo = start }; + if (!process.Start()) + return null; + + var stdout = process.StandardOutput.ReadToEnd(); + var stderr = process.StandardError.ReadToEnd(); + + if (!process.WaitForExit((int)perScriptTimeout.TotalMilliseconds)) + { + try { process.Kill(entireProcessTree: true); } catch { } + logger.LogWarning("Agent utility {Script} timed out", scriptPath); + return null; + } + + if (process.ExitCode != 0) + { + logger.LogWarning("Agent utility {Script} exit {Code}: {Error}", + scriptPath, process.ExitCode, stderr.Trim()); + return null; + } + + stdout = stdout.Trim(); + if (stdout.Length == 0) + return null; + + try + { + using var document = JsonDocument.Parse(stdout); + return JsonSerializer.Deserialize(stdout); + } + catch (JsonException) + { + logger.LogWarning("Agent utility {Script} produced non-JSON output", + scriptPath); + return null; + } + } +} diff --git a/agent/ShopDbEndpointAgent.Core/ShopDbEndpointAgent.Core.csproj b/agent/ShopDbEndpointAgent.Core/ShopDbEndpointAgent.Core.csproj new file mode 100644 index 00000000..013080b0 --- /dev/null +++ b/agent/ShopDbEndpointAgent.Core/ShopDbEndpointAgent.Core.csproj @@ -0,0 +1,22 @@ + + + + net8.0-windows + enable + enable + ShopDbEndpointAgent.Core + ShopDbEndpointAgent.Core + true + + + + + + + + + + + + + diff --git a/agent/ShopDbEndpointAgent.Core/TelemetryWorker.cs b/agent/ShopDbEndpointAgent.Core/TelemetryWorker.cs new file mode 100644 index 00000000..1fcd3412 --- /dev/null +++ b/agent/ShopDbEndpointAgent.Core/TelemetryWorker.cs @@ -0,0 +1,129 @@ +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace ShopDbEndpointAgent.Core; + +// The telemetry loop as a hosted BackgroundService. On first contact it sends +// adopt=true (once) so the server's computers collector creates the PC; every +// run after that omits adopt and relies on strict hostname resolution. Between +// runs it waits the configured cadence plus/minus jitter so a fleet does not +// report in lockstep (thundering herd). +// +// The worker never lets one bad run kill the loop: a transient failure is +// already retried inside CollectorClient, and a permanent failure (bad key, +// PC removed server-side) is logged and the loop simply waits for the next +// cadence tick rather than hammering the server. +public sealed class TelemetryWorker : BackgroundService +{ + private readonly ICollector collector; + private readonly CollectorClient client; + private readonly IAgentUtilityRunner? utilities; + private readonly AgentConfig config; + private readonly string agentVersion; + private readonly ILogger logger; + private readonly CollectSignal? collectSignal; + private readonly Random jitter = new(); + + public TelemetryWorker(ICollector collector, CollectorClient client, + AgentConfig config, string agentVersion, ILogger logger, + IAgentUtilityRunner? utilities = null, CollectSignal? collectSignal = null) + { + this.collector = collector; + this.client = client; + this.config = config; + this.agentVersion = agentVersion; + this.logger = logger; + this.utilities = utilities; + this.collectSignal = collectSignal; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + logger.LogInformation( + "Endpoint agent {Version} -> {BaseUrl}, cadence {Cadence}s +/-{Jitter}s", + agentVersion, config.BaseUrl, config.CadenceSeconds, config.JitterSeconds); + + bool firstRun = true; + while (!stoppingToken.IsCancellationRequested) + { + bool adopt = firstRun && config.AdoptOnFirstRun; + try + { + await RunOnceAsync(adopt, stoppingToken); + firstRun = false; + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + break; + } + catch (CollectorException error) when (error.IsPermanent) + { + // Bad key or PC removed server-side: surface it, do not retry + // faster than cadence. If it was a first-run adopt, keep trying + // to adopt next tick (the operator may be provisioning the PC). + logger.LogError(error, "Permanent collector error; waiting for next cadence"); + } + catch (Exception error) + { + logger.LogError(error, "Telemetry run failed"); + } + + try + { + if (collectSignal is not null) + { + // Wake early if a command asked for an immediate re-collect, + // otherwise fall through when the cadence delay elapses. + bool woken = await collectSignal.WaitAsync(NextDelay(), stoppingToken); + if (woken) + logger.LogInformation("Off-cadence collect requested after a command"); + } + else + { + await Task.Delay(NextDelay(), stoppingToken); + } + } + catch (OperationCanceledException) + { + break; + } + } + + logger.LogInformation("Endpoint agent stopped"); + } + + // Public so a --once console run and future on-demand triggers can reuse the + // exact production collection path. + public async Task RunOnceAsync(bool adopt, CancellationToken token) + { + var report = collector.Collect(agentVersion, adopt); + + if (utilities is not null) + { + var extra = utilities.RunAll(token); + if (extra.Count > 0) + logger.LogDebug("Loaded {Count} agent utility result(s)", extra.Count); + } + + logger.LogInformation( + "Reporting (adopt={Adopt}) user={User} serial={Serial} events={Events}", + adopt, report.LoggedInUser, report.SerialNumber, report.Events?.Length ?? 0); + + var result = await client.SendAsync(report, token); + + logger.LogInformation("OK action={Action} assetid={AssetId} identity={Identity}", + result.Action, result.AssetId, result.IdentityValue); + foreach (var warning in result.Warnings ?? Array.Empty()) + logger.LogWarning("Server warning: {Warning}", warning); + + return result; + } + + private TimeSpan NextDelay() + { + int spread = Math.Max(0, config.JitterSeconds); + int offset = spread == 0 ? 0 : jitter.Next(-spread, spread + 1); + int seconds = Math.Max(1, config.CadenceSeconds + offset); + return TimeSpan.FromSeconds(seconds); + } +} diff --git a/agent/ShopDbEndpointAgent.Tray/Program.cs b/agent/ShopDbEndpointAgent.Tray/Program.cs new file mode 100644 index 00000000..80e0f0cd --- /dev/null +++ b/agent/ShopDbEndpointAgent.Tray/Program.cs @@ -0,0 +1,17 @@ +namespace ShopDbEndpointAgent.Tray; + +// Interactive user-session tray app (SCAFFOLD). Separate process from the +// headless Windows Service: the service owns telemetry, this owns the operator +// surface - a system-tray icon and the 5-click-max ServiceNow ticket flow +// (DESIGN-DECISIONS-agent.md section 6). This iteration wires the tray and a +// stubbed ticket dialog; the ServiceNow submission (direct vs server-proxied) +// is deferred until that decision is made server-side. +internal static class Program +{ + [STAThread] + private static void Main() + { + ApplicationConfiguration.Initialize(); + Application.Run(new TrayContext()); + } +} diff --git a/agent/ShopDbEndpointAgent.Tray/ShopDbEndpointAgent.Tray.csproj b/agent/ShopDbEndpointAgent.Tray/ShopDbEndpointAgent.Tray.csproj new file mode 100644 index 00000000..365c1e5f --- /dev/null +++ b/agent/ShopDbEndpointAgent.Tray/ShopDbEndpointAgent.Tray.csproj @@ -0,0 +1,24 @@ + + + + WinExe + net8.0-windows + enable + enable + true + ShopDbEndpointAgent.Tray + shopdb-endpoint-tray + true + win-x64 + true + true + true + true + none + + + + + + + diff --git a/agent/ShopDbEndpointAgent.Tray/TicketForm.cs b/agent/ShopDbEndpointAgent.Tray/TicketForm.cs new file mode 100644 index 00000000..392418e2 --- /dev/null +++ b/agent/ShopDbEndpointAgent.Tray/TicketForm.cs @@ -0,0 +1,118 @@ +namespace ShopDbEndpointAgent.Tray; + +// The 5-click-max issue reporter (SCAFFOLD). Target flow from +// DESIGN-DECISIONS-agent.md section 6: +// network precheck -> issue dropdown -> optional screenshot -> submit. +// This iteration renders the dropdown of fixed reasons and a submit button but +// does NOT talk to ServiceNow yet - the submission path (direct API vs proxied +// through a ShopDB server endpoint so ServiceNow creds stay server-side) is an +// open decision. Submit currently shows what WOULD be sent. +internal sealed class TicketForm : Form +{ + // Fixed dropdown reasons from the source conversation (section 6). + private static readonly string[] Reasons = + { + "Can't logon to CSF", + "Data collections down", + "Can't download program", + "Machine communication failing", + "Network drives down", + "Other (free text)", + }; + + private readonly ComboBox reasonBox; + private readonly TextBox detailBox; + private readonly CheckBox screenshotBox; + + public TicketForm() + { + Text = "Report an issue"; + FormBorderStyle = FormBorderStyle.FixedDialog; + StartPosition = FormStartPosition.CenterScreen; + MinimizeBox = false; + MaximizeBox = false; + ClientSize = new Size(420, 260); + + var reasonLabel = new Label + { + Text = "What is happening?", + Left = 16, + Top = 16, + Width = 388, + }; + reasonBox = new ComboBox + { + Left = 16, + Top = 40, + Width = 388, + DropDownStyle = ComboBoxStyle.DropDownList, + }; + reasonBox.Items.AddRange(Reasons); + reasonBox.SelectedIndex = 0; + + var detailLabel = new Label + { + Text = "Details (optional)", + Left = 16, + Top = 76, + Width = 388, + }; + detailBox = new TextBox + { + Left = 16, + Top = 100, + Width = 388, + Height = 80, + Multiline = true, + }; + + screenshotBox = new CheckBox + { + Text = "Attach a screenshot of my screen", + Left = 16, + Top = 188, + Width = 388, + }; + + var submit = new Button + { + Text = "Submit", + Left = 244, + Top = 220, + Width = 76, + DialogResult = DialogResult.OK, + }; + submit.Click += OnSubmit; + + var cancel = new Button + { + Text = "Cancel", + Left = 328, + Top = 220, + Width = 76, + DialogResult = DialogResult.Cancel, + }; + + Controls.AddRange(new Control[] + { + reasonLabel, reasonBox, detailLabel, detailBox, screenshotBox, + submit, cancel, + }); + AcceptButton = submit; + CancelButton = cancel; + } + + private void OnSubmit(object? sender, EventArgs e) + { + // Placeholder: show what would be submitted. The real path (screenshot + // capture to Pictures\Screenshots, then ServiceNow submit) lands once + // the direct-vs-proxied decision is made. + var summary = + $"Reason: {reasonBox.SelectedItem}\n" + + $"Details: {(string.IsNullOrWhiteSpace(detailBox.Text) ? "(none)" : detailBox.Text)}\n" + + $"Screenshot: {(screenshotBox.Checked ? "yes" : "no")}"; + MessageBox.Show( + "Ticketing is not wired up yet. This would be submitted:\n\n" + summary, + "ShopDB agent (scaffold)", MessageBoxButtons.OK, MessageBoxIcon.Information); + } +} diff --git a/agent/ShopDbEndpointAgent.Tray/TrayContext.cs b/agent/ShopDbEndpointAgent.Tray/TrayContext.cs new file mode 100644 index 00000000..93827706 --- /dev/null +++ b/agent/ShopDbEndpointAgent.Tray/TrayContext.cs @@ -0,0 +1,202 @@ +using System.Diagnostics; + +namespace ShopDbEndpointAgent.Tray; + +// Owns the tray icon and its context menu for the app's lifetime. Kept minimal +// and headless-friendly: no main window, the icon is the whole UI surface until +// the operator opens the ticket dialog. +// +// "Send Status now" reuses the exact production collection path by shelling out +// to the installed shopdbagent.exe with --once. That keeps the collector key and +// all collection logic in the service binary: the tray never holds a secret and +// never duplicates wiring, it just asks the agent to report on demand so the +// ShopDB monitor page reflects the box immediately instead of at next cadence. +internal sealed class TrayContext : ApplicationContext +{ + // The headless agent/service executable. The tray ships next to it in the + // install directory, so same-dir resolution is the primary path. + private const string AgentExeName = "shopdbagent.exe"; + private const string DefaultInstallDir = @"C:\Program Files\ShopDB\EndpointAgent"; + + private readonly NotifyIcon trayIcon; + private readonly Icon brandIcon; + private bool sending; + + public TrayContext() + { + var menu = new ContextMenuStrip(); + menu.Items.Add("Send Status now", null, OnSendStatus); + menu.Items.Add("Report an issue...", null, OnReportIssue); + menu.Items.Add(new ToolStripSeparator()); + menu.Items.Add("About ShopDB agent", null, OnAbout); + menu.Items.Add("Exit", null, OnExit); + + // Branded tray icon drawn from the ShopDB WebUI favicon (see TrayIcon). + brandIcon = TrayIcon.Create(); + + trayIcon = new NotifyIcon + { + Icon = brandIcon, + Text = "ShopDB Endpoint Agent", + Visible = true, + ContextMenuStrip = menu, + }; + trayIcon.DoubleClick += OnSendStatus; + } + + private void OnReportIssue(object? sender, EventArgs e) + { + using var dialog = new TicketForm(); + dialog.ShowDialog(); + } + + // Runs shopdbagent.exe --once out of process so the operator can push a + // fresh report without waiting for the next cadence tick. The exit code is + // the agent's own contract: 0 = reported OK, non-zero = failed (bad key, + // host removed, network) and the detail is in the rolling agent log. + private async void OnSendStatus(object? sender, EventArgs e) + { + if (sending) + return; + + var agentExe = ResolveAgentExe(); + if (agentExe is null) + { + ShowBalloon("Send Status", "Could not find " + AgentExeName + + ". Is the ShopDB Endpoint Agent installed?", ToolTipIcon.Error); + return; + } + + sending = true; + trayIcon.Text = "ShopDB Endpoint Agent (sending status...)"; + ShowBalloon("Send Status", "Sending this PC's status to ShopDB...", + ToolTipIcon.Info); + + try + { + int exitCode = await RunAgentOnceAsync(agentExe); + if (exitCode == 0) + ShowBalloon("Send Status", "Status sent to ShopDB.", + ToolTipIcon.Info); + else + ShowBalloon("Send Status", + "Status send failed (exit " + exitCode + + "). See the agent log under ProgramData\\ShopDB.", + ToolTipIcon.Warning); + } + catch (Exception error) + { + ShowBalloon("Send Status", "Could not run the agent: " + + error.Message, ToolTipIcon.Error); + } + finally + { + sending = false; + trayIcon.Text = "ShopDB Endpoint Agent"; + } + } + + private static Task RunAgentOnceAsync(string agentExe) + { + var startInfo = new ProcessStartInfo + { + FileName = agentExe, + Arguments = "--once", + WorkingDirectory = Path.GetDirectoryName(agentExe) ?? Environment.CurrentDirectory, + UseShellExecute = false, + CreateNoWindow = true, + }; + + // The tray and the agent are both single-file, self-contained apps. When + // the tray host started, the .NET single-file bootstrapper set DOTNET_* + // and bundle-extraction variables in this process's environment. With + // UseShellExecute=false the child inherits those variables, so the + // freshly installed agent tries to bootstrap against the tray's stale + // extraction state and dies during host startup (exit 0xE0434352) before + // any managed code - or the file log - runs. Stripping these inherited + // markers gives the child the same clean host environment it gets when + // launched from a shell, and it also removes the need to restart the tray + // after every agent rebuild. The agent re-derives whatever it needs. + ScrubInheritedDotnetHostVariables(startInfo); + + var completion = new TaskCompletionSource(); + var process = new Process { StartInfo = startInfo, EnableRaisingEvents = true }; + process.Exited += (_, _) => + { + completion.TrySetResult(process.ExitCode); + process.Dispose(); + }; + + if (!process.Start()) + { + process.Dispose(); + completion.TrySetException( + new InvalidOperationException("failed to start " + agentExe)); + } + + return completion.Task; + } + + // Remove the .NET host / single-file bundle markers this tray process + // inherited at startup so a spawned single-file child bootstraps cleanly + // instead of adopting the tray's extraction state. Names are matched by a + // DOTNET_/COREHOST_ prefix (covers DOTNET_BUNDLE_EXTRACT_BASE_DIR, + // DOTNET_ROOT, the internal startup hooks, etc.) so this stays correct as + // the runtime adds new host variables. + private static void ScrubInheritedDotnetHostVariables(ProcessStartInfo startInfo) + { + var toRemove = startInfo.Environment.Keys + .Where(key => + key.StartsWith("DOTNET_", StringComparison.OrdinalIgnoreCase) || + key.StartsWith("COREHOST_", StringComparison.OrdinalIgnoreCase)) + .ToList(); + foreach (var key in toRemove) + startInfo.Environment.Remove(key); + } + + // Same directory first (the installer ships the tray next to the agent), + // then the known Program Files install directory as a fallback. + private static string? ResolveAgentExe() + { + var beside = Path.Combine(AppContext.BaseDirectory, AgentExeName); + if (File.Exists(beside)) + return beside; + + var installed = Path.Combine(DefaultInstallDir, AgentExeName); + if (File.Exists(installed)) + return installed; + + return null; + } + + private void ShowBalloon(string title, string text, ToolTipIcon icon) + { + trayIcon.BalloonTipTitle = title; + trayIcon.BalloonTipText = text; + trayIcon.BalloonTipIcon = icon; + trayIcon.ShowBalloonTip(4000); + } + + private void OnAbout(object? sender, EventArgs e) + => MessageBox.Show( + "ShopDB Endpoint Agent tray.\n" + + "Telemetry runs as the ShopDB Endpoint Agent Windows service.\n" + + "Send Status now reports this PC to ShopDB on demand.", + "About", MessageBoxButtons.OK, MessageBoxIcon.Information); + + private void OnExit(object? sender, EventArgs e) + { + trayIcon.Visible = false; + ExitThread(); + } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + trayIcon.Dispose(); + brandIcon.Dispose(); + } + base.Dispose(disposing); + } +} diff --git a/agent/ShopDbEndpointAgent.Tray/TrayIcon.cs b/agent/ShopDbEndpointAgent.Tray/TrayIcon.cs new file mode 100644 index 00000000..dbc0957b --- /dev/null +++ b/agent/ShopDbEndpointAgent.Tray/TrayIcon.cs @@ -0,0 +1,81 @@ +using System.Drawing.Drawing2D; +using System.Drawing.Imaging; +using System.Runtime.InteropServices; + +namespace ShopDbEndpointAgent.Tray; + +// Builds the tray icon from the ShopDB WebUI favicon (frontend/public/favicon.svg: +// a #4181ff rounded square with a white "S") by drawing it with GDI+ at runtime. +// Drawing it here rather than shipping a binary .ico keeps the brand in one +// place (these values mirror the SVG) and avoids adding an SVG rasterizer or a +// checked-in binary to the build. +internal static class TrayIcon +{ + // Favicon colors (frontend/public/favicon.svg). + private static readonly Color Background = ColorTranslator.FromHtml("#4181ff"); + private static readonly Color Foreground = Color.White; + + // Renders the branded icon at a tray-friendly size. 32px covers standard and + // most high-DPI tray scaling; the OS downsamples as needed. + public static Icon Create(int size = 32) + { + using var bitmap = new Bitmap(size, size, PixelFormat.Format32bppArgb); + using (var graphics = Graphics.FromImage(bitmap)) + { + graphics.SmoothingMode = SmoothingMode.AntiAlias; + graphics.TextRenderingHint = + System.Drawing.Text.TextRenderingHint.AntiAlias; + graphics.Clear(Color.Transparent); + + // Rounded square (SVG rect rx=14 on a 64 viewport -> ~0.22 radius). + float inset = size * 0.03f; + var rect = new RectangleF(inset, inset, + size - inset * 2, size - inset * 2); + float radius = size * 0.22f; + using (var path = RoundedRect(rect, radius)) + using (var fill = new SolidBrush(Background)) + graphics.FillPath(fill, path); + + // White "S", centered. + using var font = new Font("Arial", size * 0.58f, + FontStyle.Bold, GraphicsUnit.Pixel); + using var text = new SolidBrush(Foreground); + using var format = new StringFormat + { + Alignment = StringAlignment.Center, + LineAlignment = StringAlignment.Center, + }; + graphics.DrawString("S", font, text, rect, format); + } + + // Icon.FromHandle borrows the GDI HICON, so clone into a managed Icon + // and free the handle to avoid leaking one per launch. + IntPtr hicon = bitmap.GetHicon(); + try + { + using var borrowed = Icon.FromHandle(hicon); + return (Icon)borrowed.Clone(); + } + finally + { + DestroyIcon(hicon); + } + } + + private static GraphicsPath RoundedRect(RectangleF rect, float radius) + { + float diameter = radius * 2; + var path = new GraphicsPath(); + path.AddArc(rect.Left, rect.Top, diameter, diameter, 180, 90); + path.AddArc(rect.Right - diameter, rect.Top, diameter, diameter, 270, 90); + path.AddArc(rect.Right - diameter, rect.Bottom - diameter, diameter, + diameter, 0, 90); + path.AddArc(rect.Left, rect.Bottom - diameter, diameter, diameter, 90, 90); + path.CloseFigure(); + return path; + } + + [DllImport("user32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool DestroyIcon(IntPtr handle); +} diff --git a/agent/ShopDbEndpointAgent.slnx b/agent/ShopDbEndpointAgent.slnx new file mode 100644 index 00000000..1383261a --- /dev/null +++ b/agent/ShopDbEndpointAgent.slnx @@ -0,0 +1,5 @@ + + + + + diff --git a/agent/ShopDbEndpointAgent/Program.cs b/agent/ShopDbEndpointAgent/Program.cs new file mode 100644 index 00000000..c97a8839 --- /dev/null +++ b/agent/ShopDbEndpointAgent/Program.cs @@ -0,0 +1,164 @@ +using System.Reflection; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using ShopDbEndpointAgent.Core; + +// Headless Windows Service host for the ShopDB endpoint agent. Composes the +// Core runtime (config -> collector -> client -> worker) behind the generic +// host, adds durable file logging and a WER-style fault monitor, and supports: +// (default) run as a Windows Service (AddWindowsService) +// --console run interactively in the foreground for debugging +// --once collect and report exactly once, then exit (smoke test / cron) +// +// Secrets are never read here: AgentConfigProvider layers the registry +// (HKLM\SOFTWARE\GE\ShopDB: CollectorKey, BaseUrl) over the shipped, non-secret +// appsettings.json. The executable is intentionally named shopdbagent.exe so it +// never collides with third-party agent.exe processes/services (for example the +// MTConnect agent this same build can monitor). + +var runOnce = args.Any(a => string.Equals(a, "--once", StringComparison.OrdinalIgnoreCase)); +var forceConsole = args.Any(a => string.Equals(a, "--console", StringComparison.OrdinalIgnoreCase)); + +var agentVersion = Assembly.GetExecutingAssembly() + .GetName().Version?.ToString() ?? "0.0.0"; + +// Bootstrap logger first so config loading (including the registry read that +// CyberArk/EPM may gate) is fully diagnosable before the host starts. +using var bootstrapFileLogger = new FileLoggerProvider(FileLoggerProvider.DefaultDirectory()); +var bootstrapLogger = bootstrapFileLogger.CreateLogger("Startup"); + +var faultMonitor = new AgentFaultMonitor(bootstrapLogger); +faultMonitor.Install(); + +var config = AgentConfigProvider.Load(logger: bootstrapLogger); + +var logDirectory = string.IsNullOrWhiteSpace(config.LogPath) + ? FileLoggerProvider.DefaultDirectory() + : config.LogPath; + +if (!config.IsUsable()) +{ + bootstrapLogger.LogError( + "Config incomplete: provision CollectorKey and BaseUrl in " + + "HKLM\\SOFTWARE\\GE\\ShopDB (or baseurl/apikey in appsettings.json for dev). " + + "See the registry lines above to tell 'missing' from 'access denied'."); + return 1; +} + +try +{ + if (runOnce) + return await RunOnceAsync(config, agentVersion, logDirectory, faultMonitor); + + await RunHostAsync(config, agentVersion, logDirectory, forceConsole); + return 0; +} +catch (Exception error) +{ + faultMonitor.ReportFatal("host", error); + return 1; +} + +static async Task RunHostAsync(AgentConfig config, string agentVersion, + string logDirectory, bool forceConsole) +{ + var builder = Host.CreateApplicationBuilder(); + + if (!forceConsole) + builder.Services.AddWindowsService(options => + options.ServiceName = "ShopDbEndpointAgent"); + + ConfigureLogging(builder.Logging, logDirectory); + RegisterCoreServices(builder.Services, config, agentVersion); + builder.Services.AddHostedService(sp => sp.GetRequiredService()); + builder.Services.AddHostedService(sp => sp.GetRequiredService()); + + await builder.Build().RunAsync(); +} + +static async Task RunOnceAsync(AgentConfig config, string agentVersion, + string logDirectory, AgentFaultMonitor faultMonitor) +{ + using var services = BuildOnceServices(config, agentVersion, logDirectory); + var worker = services.GetRequiredService(); + var logger = services.GetRequiredService>(); + try + { + var result = await worker.RunOnceAsync(config.AdoptOnFirstRun, CancellationToken.None); + logger.LogInformation("Single report complete: action={Action}", result.Action); + return 0; + } + catch (Exception error) + { + faultMonitor.ReportFatal("--once", error); + return 1; + } +} + +static ServiceProvider BuildOnceServices(AgentConfig config, string agentVersion, + string logDirectory) +{ + var services = new ServiceCollection(); + services.AddLogging(logging => ConfigureLogging(logging, logDirectory)); + RegisterCoreServices(services, config, agentVersion); + return services.BuildServiceProvider(); +} + +static void RegisterCoreServices(IServiceCollection services, AgentConfig config, + string agentVersion) +{ + services.AddSingleton(config); + + // Shared wake used by the command worker to trigger an immediate telemetry + // collect after a command changes endpoint state (e.g. MTConnect toggle). + services.AddSingleton(); + + if (!string.IsNullOrWhiteSpace(config.UtilitiesPath) && + Directory.Exists(config.UtilitiesPath)) + { + services.AddSingleton(sp => + new PowerShellUtilityRunner(config.UtilitiesPath, + sp.GetRequiredService>())); + } + + services.AddSingleton(sp => new EndpointCollector( + sp.GetRequiredService>(), + config, + sp.GetService())); + + services.AddSingleton(sp => new CollectorClient( + config, sp.GetRequiredService>())); + + services.AddSingleton(sp => new TelemetryWorker( + sp.GetRequiredService(), + sp.GetRequiredService(), + config, + agentVersion, + sp.GetRequiredService>(), + sp.GetService(), + sp.GetRequiredService())); + + // Command channel: outbound long-poll for allowlisted WebUI commands, run + // through the fail-closed executor. Hosted alongside telemetry but on its + // own loop so a held-open poll never delays a report. + services.AddSingleton(sp => new CommandClient( + config, sp.GetRequiredService>())); + + services.AddSingleton(sp => new CommandExecutor( + sp.GetRequiredService>(), + config)); + + services.AddSingleton(sp => new CommandWorker( + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService>(), + sp.GetRequiredService())); +} + +static void ConfigureLogging(ILoggingBuilder logging, string logDirectory) +{ + logging.ClearProviders(); + logging.AddProvider(new FileLoggerProvider(logDirectory)); + logging.SetMinimumLevel(LogLevel.Information); +} diff --git a/agent/ShopDbEndpointAgent/ShopDbEndpointAgent.csproj b/agent/ShopDbEndpointAgent/ShopDbEndpointAgent.csproj new file mode 100644 index 00000000..3612a79a --- /dev/null +++ b/agent/ShopDbEndpointAgent/ShopDbEndpointAgent.csproj @@ -0,0 +1,40 @@ + + + + Exe + net8.0-windows + enable + enable + ShopDbEndpointAgent + shopdbagent + + 1.3.0 + true + win-x64 + true + true + true + true + none + + + + + + + + + + + + + + + PreserveNewest + + + + diff --git a/agent/ShopDbEndpointAgent/appsettings.json b/agent/ShopDbEndpointAgent/appsettings.json new file mode 100644 index 00000000..162913e3 --- /dev/null +++ b/agent/ShopDbEndpointAgent/appsettings.json @@ -0,0 +1,26 @@ +{ + "baseurl": "", + "apikey": "REPLACE_WITH_COLLECTOR_KEY", + "cadenceseconds": 900, + "jitterseconds": 60, + "adoptonfirstrun": true, + "utilitiespath": "", + "logpath": "", + "monitors": { + "processes": [ + { "name": "dncMain.exe", "app": "eDNC" }, + { "name": "udc.exe", "app": "Universal Data Collector" }, + { "name": "agent.exe", "app": "MTConnect" } + ], + "services": [ + { "name": "MTConnect UDC Agent", "app": "MTConnect" } + ] + }, + "launchpaths": { + "DncMain.exe": "C:\\Program Files (x86)\\Dnc\\bin\\DncMain.exe", + "UDC.exe": "C:\\Program Files\\UDC\\UDC.exe", + "agent.exe": "C:\\ProgramData\\udc\\mtconnect_udc\\agent\\run.bat" + } +} + + diff --git a/agent/installer/install-shopdbagent.ps1 b/agent/installer/install-shopdbagent.ps1 new file mode 100644 index 00000000..5a08d3db --- /dev/null +++ b/agent/installer/install-shopdbagent.ps1 @@ -0,0 +1,355 @@ +# install-shopdbagent.ps1 +# ShopDB Endpoint Agent installer for the test endpoint. Run from an ELEVATED +# (admin) PowerShell on the endpoint. Provisions the registry secrets, copies +# the agent files to Program Files, runs a one-shot smoke test, then installs +# and starts the Windows service. +# +# Secrets are provisioned to HKLM\SOFTWARE\GE\ShopDB. On locked-down hosts +# (CyberArk/EPM) the agent may be blocked from READING that hive, so the same +# values are also stamped into the installed appsettings.json as a fallback. +# +# The agent and tray are published as single-file self-contained executables, +# so copying the two .exe files plus appsettings.json is the complete payload. + +[CmdletBinding()] +param( + # ShopDB server root URL for this instance. REQUIRED - there is no default, + # because a default here means one site's hostname shipped to every site + # (ADR-015) and, worse, a working install pointed at somebody else's server. + # + # https is required. The collector key travels in the X-API-Key header and + # every telemetry payload follows it, so plain http puts both on the wire in + # clear. Pass -AllowInsecureHttp only for a local test instance. + [Parameter(Mandatory = $true)] + [string] $BaseUrl, + + # Endpoints-only collector key: this instance's .env COLLECTOR_API_KEY_ENDPOINTS. + # REQUIRED and never defaulted - a key in source is a key in git history, and + # this one is also the command channel's kill switch. + [Parameter(Mandatory = $true)] + [string] $CollectorKey, + + # Permit an http:// BaseUrl. For a local test instance only. + [switch] $AllowInsecureHttp, + + + [string] $InstallDir = "C:\Program Files\ShopDB\EndpointAgent", + [string] $ServiceName = "ShopDbEndpointAgent", + + # Interactive tray app that shows the system-tray icon and the on-demand + # "Send Status now" action. Separate process from the SYSTEM service (a + # service cannot draw a tray icon in a user session). Pass -NoTray to skip. + [string] $TrayExeName = "shopdb-endpoint-tray.exe", + [switch] $NoTray, + + # Add a Microsoft Defender path exclusion for the install directory. The + # agent and tray are unsigned single-file self-contained executables, which + # Defender heuristics (and the tray's GDI/user32 icon P/Invoke) can flag as + # a false positive and quarantine, killing the tray before its icon appears. + # Pass -NoDefenderExclusion to skip (e.g. where policy manages exclusions + # centrally or the binaries are code-signed). + [switch] $NoDefenderExclusion +) + +# Refuse cleartext unless it was asked for explicitly. The key is a bearer +# credential for the whole fleet's telemetry and command channel. +if ($BaseUrl -notmatch '^https://' -and -not $AllowInsecureHttp) { + throw "BaseUrl must be https:// (got '$BaseUrl'). Pass -AllowInsecureHttp for a local test instance." +} + +$ErrorActionPreference = "Stop" +$sourceDir = Split-Path -Parent $MyInvocation.MyCommand.Path + +function Assert-Admin { + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + $principal = New-Object Security.Principal.WindowsPrincipal($identity) + if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { + throw "This script must be run from an elevated (Run as administrator) PowerShell." + } +} + +Assert-Admin + +if ([string]::IsNullOrWhiteSpace($BaseUrl)) { + throw "BaseUrl is not set. Pass -BaseUrl 'http://:' when running the script." +} + +# Write HKLM values through the explicit 64-bit registry view. If this script +# runs under 32-bit PowerShell (some management tooling launches x86), the +# PSDrive HKLM: silently redirects writes into WOW6432Node. The 64-bit agent +# service and 64-bit Explorer (the logon Run key) then read the 64-bit view and +# see nothing, so config "goes missing" and the tray never autostarts. Pinning +# the view removes that dependency on the launching shell's bitness. +function Set-Hklm64Value { + param( + [Parameter(Mandatory)] [string] $SubKey, + [Parameter(Mandatory)] [string] $Name, + [Parameter(Mandatory)] [string] $Value + ) + $base = [Microsoft.Win32.RegistryKey]::OpenBaseKey( + [Microsoft.Win32.RegistryHive]::LocalMachine, + [Microsoft.Win32.RegistryView]::Registry64) + try { + $key = $base.CreateSubKey($SubKey) + try { + $key.SetValue($Name, $Value, [Microsoft.Win32.RegistryValueKind]::String) + } + finally { $key.Dispose() } + } + finally { $base.Dispose() } +} + +# Tear down any previously installed agent BEFORE touching files. The agent and +# tray are single-file self-contained exes; while the old service or tray is +# running it holds those .exe files open, so Copy-Item over them fails with a +# file-in-use error. Stopping the service, deleting it, and killing any lingering +# agent/tray processes up front makes the install a clean overwrite. Best-effort +# throughout: a missing service or process is the normal fresh-install case. +function Uninstall-PreviousAgent { + param( + [Parameter(Mandatory)] [string] $Service, + [Parameter(Mandatory)] [string] $TrayExe, + [Parameter(Mandatory)] [string] $TargetDir + ) + + $existing = Get-Service -Name $Service -ErrorAction SilentlyContinue + if ($existing) { + Write-Host " stopping and removing existing service $Service ..." + if ($existing.Status -ne "Stopped") { + try { Stop-Service -Name $Service -Force -ErrorAction Stop } + catch { Write-Warning " could not stop service: $($_.Exception.Message)" } + } + # sc.exe delete only marks the service for deletion; the SCM finishes the + # removal once every handle closes. Give it a moment so the subsequent + # sc.exe create does not hit a "marked for deletion" (1072) error. + sc.exe delete $Service | Out-Null + Start-Sleep -Seconds 2 + } + + # Kill any lingering worker or tray processes that would keep the exes + # locked. Match by the installed exe base names; the service host process is + # shopdbagent, the tray is the TrayExe base name. + $trayBase = [System.IO.Path]::GetFileNameWithoutExtension($TrayExe) + foreach ($name in @("shopdbagent", $trayBase)) { + $procs = Get-Process -Name $name -ErrorAction SilentlyContinue + foreach ($p in $procs) { + Write-Host " killing lingering process $($p.ProcessName) (pid $($p.Id)) ..." + try { Stop-Process -Id $p.Id -Force -ErrorAction Stop } + catch { Write-Warning " could not kill $($p.ProcessName): $($_.Exception.Message)" } + } + } + # Also kill anything still running out of the target install directory, + # regardless of exe name. A tray from an older build (different exe name) or + # one launched from a stale path would otherwise survive the name-based sweep + # above, keep its exe locked, and leave a duplicate icon once step 5 starts + # the fresh tray. Match on the executable path so we only touch our own dir. + $targetPrefix = $TargetDir.TrimEnd('\') + '\' + foreach ($p in (Get-Process -ErrorAction SilentlyContinue)) { + $path = $null + try { $path = $p.Path } catch { $path = $null } + if ($path -and $path.StartsWith($targetPrefix, [System.StringComparison]::OrdinalIgnoreCase)) { + Write-Host " killing process from install dir $($p.ProcessName) (pid $($p.Id)) ..." + try { Stop-Process -Id $p.Id -Force -ErrorAction Stop } + catch { Write-Warning " could not kill $($p.ProcessName): $($_.Exception.Message)" } + } + } + # Brief settle so file handles are released before the copy overwrites them. + Start-Sleep -Milliseconds 500 +} + +Write-Host "== ShopDB Endpoint Agent install ==" -ForegroundColor Cyan +Write-Host "BaseUrl : $BaseUrl" +Write-Host "InstallDir : $InstallDir" +Write-Host "ServiceName : $ServiceName" + +# 0. Clear the Mark-of-the-Web on the SOURCE payload first. The operator +# typically extracts installer.zip into Downloads, so every file carries a +# Zone.Identifier ADS. SmartScreen reads that and blocks the unsigned exes with +# "Windows protected your PC" the first time they run - including the [3/5] +# smoke test below, which launches shopdbagent.exe out of $sourceDir before the +# installed copy exists. Unblocking the source folder up front keeps the smoke +# test (and any manual run from the extract folder) clean; the installed copies +# get unblocked again in step 2a after they land in Program Files. Best-effort: +# a file with no MotW is a no-op. +Write-Host "`n[0a] Clearing Mark-of-the-Web on the extracted installer files ..." +Get-ChildItem -Path $sourceDir -File | ForEach-Object { + try { Unblock-File -Path $_.FullName -ErrorAction Stop } + catch { Write-Warning " could not unblock $($_.Name): $($_.Exception.Message)" } +} + +# 0. Uninstall any previous agent first, so the file copy is a clean overwrite +# and does not fail on exes locked by a running service or tray. +Write-Host "`n[0/5] Removing any previously installed agent ..." +Uninstall-PreviousAgent -Service $ServiceName -TrayExe $TrayExeName -TargetDir $InstallDir + +# 1. Provision registry secrets (64-bit view, see Set-Hklm64Value). +$regPath = "HKLM:\SOFTWARE\GE\ShopDB" +Write-Host "`n[1/5] Provisioning registry $regPath (Registry64) ..." +Set-Hklm64Value -SubKey "SOFTWARE\GE\ShopDB" -Name "BaseUrl" -Value $BaseUrl +Set-Hklm64Value -SubKey "SOFTWARE\GE\ShopDB" -Name "CollectorKey" -Value $CollectorKey + +# 2. Copy agent files to the install directory. The agent and tray are +# single-file self-contained executables, so the .exe plus appsettings.json is +# the whole payload; there are no side-by-side runtime DLLs to copy. +Write-Host "[2/5] Copying agent files to $InstallDir ..." +New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null +$agentSource = Join-Path $sourceDir "shopdbagent.exe" +if (-not (Test-Path $agentSource)) { + throw "shopdbagent.exe not found in $sourceDir. Unzip the full installer package and run from that folder." +} +Copy-Item -Path $agentSource -Destination $InstallDir -Force +$appsettings = Join-Path $sourceDir "appsettings.json" +$installedSettings = Join-Path $InstallDir "appsettings.json" +if (Test-Path $appsettings) { + Copy-Item -Path $appsettings -Destination $InstallDir -Force +} + +# Copy the interactive tray app next to the agent so its same-dir resolution +# finds shopdbagent.exe for the "Send Status now" action. Optional: absent on a +# service-only (headless) build. +$trayInstalled = $null +if (-not $NoTray) { + $traySource = Join-Path $sourceDir $TrayExeName + if (Test-Path $traySource) { + Copy-Item -Path $traySource -Destination $InstallDir -Force + $trayInstalled = Join-Path $InstallDir $TrayExeName + Write-Host " copied tray app $TrayExeName" + } + else { + Write-Warning " tray app $TrayExeName not found in $sourceDir; skipping tray (service still installs)" + } +} + +# Fallback for locked-down endpoints: some hosts (CyberArk/EPM) gate the HKLM +# hive so the agent cannot READ the values the registry step just wrote. Stamp +# BaseUrl/CollectorKey into the installed appsettings.json too. The agent reads +# appsettings first, then lets the registry override it, so this is a safe +# no-op where the registry read works and the rescue where it does not. +if (Test-Path $installedSettings) { + try { + $json = Get-Content $installedSettings -Raw | ConvertFrom-Json + $json.baseurl = $BaseUrl + $json.apikey = $CollectorKey + $json | ConvertTo-Json -Depth 10 | Set-Content -Path $installedSettings -Encoding UTF8 + Write-Host " stamped BaseUrl/CollectorKey into appsettings.json (registry-read fallback)" + } + catch { + Write-Warning " could not stamp appsettings.json: $($_.Exception.Message)" + } +} +$exePath = Join-Path $InstallDir "shopdbagent.exe" + +# 2a. Strip the Mark-of-the-Web from the copied files. Windows tags files +# extracted from a downloaded zip with a Zone.Identifier alternate data stream; +# SmartScreen reads that and blocks the unsigned exe with "Windows protected +# your PC" the first time it runs, and the operator sees only a "Don't run" +# button. Unblock-File removes the ADS so the smoke test and the tray launch +# start clean. Best-effort: a file with no MotW is a no-op. +Write-Host "[2a/5] Clearing Mark-of-the-Web on installed files ..." +Get-ChildItem -Path $InstallDir -File | ForEach-Object { + try { Unblock-File -Path $_.FullName -ErrorAction Stop } + catch { Write-Warning " could not unblock $($_.Name): $($_.Exception.Message)" } +} + +# 2b. Defender path exclusion for the install dir. Unsigned single-file +# self-contained binaries (and the tray's icon P/Invoke) can trip Defender +# heuristics and get quarantined, which is why the tray dies before showing an +# icon. Excluding the fixed install directory is the least-privilege fix short +# of code-signing. Best-effort: Set-MpPreference is absent on servers without +# Defender and can be blocked by tamper protection; a failure is not fatal. +if (-not $NoDefenderExclusion) { + Write-Host "[2b/5] Adding Defender path exclusion for $InstallDir ..." + if (Get-Command Add-MpPreference -ErrorAction SilentlyContinue) { + try { + Add-MpPreference -ExclusionPath $InstallDir -ErrorAction Stop + Write-Host " excluded $InstallDir from Defender scanning." + } + catch { + Write-Warning " could not add Defender exclusion (tamper protection or policy?): $($_.Exception.Message)" + Write-Warning " if the tray is quarantined, code-sign the binaries or add the exclusion via policy." + } + } + else { + Write-Host " Defender cmdlets not present; skipping exclusion." + } +} + +# 3. One-shot smoke test (exact production collection path, then exits). +Write-Host "[3/5] Running one-shot smoke test (--once) ..." +$proc = Start-Process -FilePath $exePath -ArgumentList "--once" -WorkingDirectory $InstallDir -Wait -PassThru -NoNewWindow +Write-Host " smoke test exit code: $($proc.ExitCode)" + +$logDir = Join-Path $env:ProgramData "ShopDB\EndpointAgent\logs" +$logFile = Join-Path $logDir ("agent-{0}.log" -f (Get-Date -Format "yyyyMMdd")) +if (Test-Path $logFile) { + Write-Host " --- last log lines ---" -ForegroundColor DarkGray + Get-Content $logFile -Tail 20 | ForEach-Object { Write-Host " $_" -ForegroundColor DarkGray } +} +if ($proc.ExitCode -ne 0) { + throw "Smoke test failed (exit $($proc.ExitCode)). Check $logFile before installing the service." +} + +# 4. Install the Windows service. Any previous instance was already stopped, +# deleted, and its processes killed in step 0, so this is a clean create. Guard +# against the rare case where the SCM has not finished a pending deletion yet. +Write-Host "[4/5] Installing service $ServiceName ..." +$existing = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue +if ($existing) { + Write-Host " service still present (pending deletion); removing again ..." + if ($existing.Status -ne "Stopped") { Stop-Service -Name $ServiceName -Force } + sc.exe delete $ServiceName | Out-Null + Start-Sleep -Seconds 2 +} +$binPath = '"' + $exePath + '"' +sc.exe create $ServiceName binPath= $binPath start= auto DisplayName= "ShopDB Endpoint Agent" | Out-Null +sc.exe description $ServiceName "Collects endpoint telemetry for ShopDB and runs allowlisted WebUI commands." | Out-Null +sc.exe failure $ServiceName reset= 86400 actions= restart/5000/restart/5000/restart/5000 | Out-Null + +Start-Service -Name $ServiceName +Write-Host " service started." + +# 5. Register the interactive tray app to launch at user logon (per-machine). +Write-Host "[5/5] Registering tray app for logon ..." +if ($trayInstalled -and (Test-Path $trayInstalled)) { + # Register in the 64-bit Run key so 64-bit Explorer fires it at logon. A + # 32-bit write would land in WOW6432Node\...\Run, which Explorer ignores. + Set-Hklm64Value -SubKey "SOFTWARE\Microsoft\Windows\CurrentVersion\Run" ` + -Name "ShopDbEndpointTray" -Value ('"' + $trayInstalled + '"') + Write-Host " tray app registered to start at logon (Registry64): $trayInstalled" + + # Start it now so the icon appears without waiting for the next logon. The + # Run key only fires at logon, so a fresh install would otherwise show no + # tray until the operator signs out and back in. + # + # The installer runs elevated, so a direct Start-Process would launch the + # tray ELEVATED too. That is wrong for a tray app (it should run at the + # user's medium integrity) and, on EPM-managed hosts (CyberArk), elevating an + # unsigned exe triggers a broker prompt that comes back as Win32 1223 + # "The operation was canceled by the user" - which is exactly what we hit. + # Launching through explorer.exe hands the exe to the already-running user + # shell, which spawns it de-elevated in the interactive session, sidestepping + # the elevation broker entirely. + $interactiveUser = $env:USERNAME + if ($interactiveUser -and $interactiveUser -ne "SYSTEM") { + try { + # explorer.exe re-parents the launch under the user shell at + # medium integrity; it returns immediately regardless of the tray's + # own lifetime, so there is nothing to wait on here. + Start-Process -FilePath "explorer.exe" -ArgumentList "`"$trayInstalled`"" | Out-Null + Write-Host " tray app started for the current session (de-elevated via explorer)." + } + catch { + Write-Warning " could not start tray now: $($_.Exception.Message); it will start at next logon." + } + } + else { + Write-Host " no interactive user session; tray will start at next logon." + } +} +else { + Write-Host " tray skipped (headless install)." +} + +Write-Host "`nInstall complete." -ForegroundColor Green +Write-Host "Service '$ServiceName' is running and set to auto-start." +Write-Host "Logs: $logDir" diff --git a/deploy/site-profile-universal.json b/deploy/site-profile-universal.json index 7308f77d..12d928bd 100644 --- a/deploy/site-profile-universal.json +++ b/deploy/site-profile-universal.json @@ -4,6 +4,7 @@ "backups", "computers", "employees", + "endpoints", "geenforce", "guides", "knowledgebase", diff --git a/docker-compose.yml b/docker-compose.yml index 5f591efe..b8d54715 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -61,6 +61,8 @@ services: LOG_LEVEL: ${LOG_LEVEL:-INFO} ZABBIX_URL: ${ZABBIX_URL:-} ZABBIX_TOKEN: ${ZABBIX_TOKEN:-} + COLLECTOR_API_KEY: ${COLLECTOR_API_KEY:-} + COLLECTOR_API_KEY_ENDPOINTS: ${COLLECTOR_API_KEY_ENDPOINTS:-} ports: - "${API_PORT:-5001}:5001" volumes: diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 9b279c06..c069d5c7 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "shopdb-frontend", - "version": "0.7.0", + "version": "0.12.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "shopdb-frontend", - "version": "0.7.0", + "version": "0.12.0", "dependencies": { "@fontsource-variable/inter": "^5.2.8", "@fullcalendar/core": "^6.1.20", diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index d6bb4013..3fed03f9 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -146,6 +146,27 @@ export const machinesApi = { } } +// Endpoints API (plugin) - fleet telemetry read surface +export const endpointsApi = { + list(params = {}) { + return api.get('/endpoints', { params }) + }, + 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`) + } +} + // Computers API (plugin) export const computersApi = { list(params = {}) { @@ -187,6 +208,11 @@ export const computersApi = { list(params = {}) { return api.get('/computers/types', { params }) }, + // Every type, for a dropdown. perpage is capped at 100 server-side, so + // asking for 200 returns 100 and silently truncates the list. + listAll(params = {}) { + return fetchAllPages('/computers/types', params) + }, get(id) { return api.get(`/computers/types/${id}`) }, @@ -214,6 +240,50 @@ export const computersApi = { remove(id) { return api.delete(`/computers/protocols/${id}`) } + }, + // Configuration/Business policies (RMM-style). Gated server-side by the + // computers_configpolicies_enabled setting; routes return 404 while off. + policies: { + actions() { + return api.get('/computers/policies/actions') + }, + effective(assetid) { + return api.get(`/computers/policies/effective/${assetid}`) + }, + configuration: { + list() { + return api.get('/computers/policies/configuration') + }, + get(id) { + return api.get(`/computers/policies/configuration/${id}`) + }, + create(data) { + return api.post('/computers/policies/configuration', data) + }, + update(id, data) { + return api.put(`/computers/policies/configuration/${id}`, data) + }, + remove(id) { + return api.delete(`/computers/policies/configuration/${id}`) + } + }, + business: { + list() { + return api.get('/computers/policies/business') + }, + get(id) { + return api.get(`/computers/policies/business/${id}`) + }, + create(data) { + return api.post('/computers/policies/business', data) + }, + update(id, data) { + return api.put(`/computers/policies/business/${id}`, data) + }, + remove(id) { + return api.delete(`/computers/policies/business/${id}`) + } + } } } @@ -273,6 +343,12 @@ export const locationsApi = { list(params = {}) { return api.get('/locations', { params }) }, + // Every location, paged past the backend's 100-row cap. Location selector + // dropdowns must use this: list() with a large perpage is clamped to 100 and + // still returns a success response, so the tail simply goes missing. + listAll(params = {}) { + return fetchAllPages('/locations', params) + }, get(id) { return api.get(`/locations/${id}`) }, @@ -1019,6 +1095,42 @@ export const settingsApi = { form.append('kind', kind) form.append('file', file) return api.post('/settings/branding-logo', form, { headers: { 'Content-Type': 'multipart/form-data' } }) + }, + downloadCollectorScript() { + // Collect-ShopDB.ps1 with this site's CSV path baked into $NetworkStoreRoot + return api.get('/settings/asset-import/script', { responseType: 'blob' }) + }, + listImportTemplates() { + // Importable CSV tables in the order they must be uploaded + return api.get('/settings/asset-import/templates') + }, + downloadImportTemplate(tablename) { + // Generated CSV template (header + example row) for one table + return api.get(`/settings/asset-import/templates/${encodeURIComponent(tablename)}`, { responseType: 'blob' }) + }, + importAssetCsvs(files, commit = false, options = {}) { + // Multipart upload of one or more CSVs. commit=false is a dry-run preview + // that writes nothing; commit=true applies the whole set in one transaction. + // options.source picks a foreign adapter (azure, thor); default native means + // the files are already canonical per-table CSVs. options.computertype is + // stamped onto every imported computer when a source assigns a PC type. + const form = new FormData() + for (const file of files) { + form.append('files', file) + } + form.append('commit', commit ? 'true' : 'false') + if (options.source) { + form.append('source', options.source) + } + if (options.computertype) { + form.append('computertype', options.computertype) + } + if (options.category) { + form.append('category', options.category) + } + return api.post('/settings/asset-import/import', form, { + headers: { 'Content-Type': 'multipart/form-data' } + }) } } diff --git a/frontend/src/components/PluginAssetPanels.vue b/frontend/src/components/PluginAssetPanels.vue index 8362bc9f..f8e4d3ea 100644 --- a/frontend/src/components/PluginAssetPanels.vue +++ b/frontend/src/components/PluginAssetPanels.vue @@ -4,7 +4,30 @@ on any asset detail page, so a plugin adds detail-page UI with no Vue. -->
{{ panel.empty || 'Nothing to show.' }} @@ -90,6 +123,7 @@ {{ b.label }}
+ @@ -100,7 +134,8 @@ import api from '../api' import { colorStyle } from '@/utils/colorStyle' import { toApiPath, rows, panelVisible, mapTitle, mapBadge, mapMeta, manageLink, - keyvalueFields, tableColumns, cell, badges, tabSections, + keyvalueFields, tableColumns, cell, badges, tabSections, rollupStatus, + rowAction, } from './pluginAssetPanels' const props = defineProps({ @@ -115,6 +150,117 @@ 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, 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) { + const state = collapsed.value[panel.id] + if (state === undefined) return !!panel.collapsedDefault + return !!state +} +function toggleCollapse(panel) { + collapsed.value = { ...collapsed.value, [panel.id]: !isCollapsed(panel) } +} + +// Per-panel action state: busy guards a double-click while an enqueue POST is +// in flight, and the error string surfaces a rejected enqueue (bad action, or +// a 409 when a command is already queued). Keyed by panel id for the same +// reason as tabs/collapsed: panels are replaced wholesale on refetch. +const actionBusy = ref({}) +const actionError = ref({}) + +// Enqueue the inline row toggle's command (Start/Stop for an actionable +// services/processes row, e.g. MTConnect), then reload so the row reflects the +// new state. The agent long-polls, runs it, posts the result, and wakes an +// immediate telemetry re-collect, so the settled run state lands on the next +// panel refresh rather than after the full cadence. +async function runRowAction(panel, item) { + if (actionBusy.value[panel.id]) return + const info = rowAction(panel, item) + if (!info || !info.endpoint || !info.toggle) return + actionBusy.value = { ...actionBusy.value, [panel.id]: true } + actionError.value = { ...actionError.value, [panel.id]: '' } + try { + const body = { action: info.toggle } + if (info.target) body.target = info.target + // 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.' + actionError.value = { ...actionError.value, [panel.id]: message } + } finally { + actionBusy.value = { ...actionBusy.value, [panel.id]: false } + } +} + +// 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 = [] @@ -142,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 }) diff --git a/frontend/src/components/pluginAssetPanels.js b/frontend/src/components/pluginAssetPanels.js index 7aebfa4b..28fa9c52 100644 --- a/frontend/src/components/pluginAssetPanels.js +++ b/frontend/src/components/pluginAssetPanels.js @@ -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) { @@ -98,6 +108,37 @@ export function badges(panel) { return list.map((b) => ({ label: b.label, color: b.color })) } +// Header status rollup for a card. A panel opts in with +// status: { key, levels: { : 'critical'|'warning'|'ok'|'unknown' } }. +// The card header then shows a single green/yellow/red dot reflecting the worst +// row: any critical -> red, else any warning -> yellow, else ok -> green. A +// value not in levels is treated as unknown and does not raise the level. +export const STATUS_RANK = { ok: 1, unknown: 1, warning: 2, critical: 3 } +export const STATUS_COLOR = { + ok: '#2dce89', + warning: '#ffc107', + critical: '#f5365c', + unknown: '#6c757d', +} + +export function rollupStatus(panel) { + const status = panel && panel.status + if (!status || !status.key) return null + const items = rows(panel) + if (!items.length) return null + const levels = status.levels || {} + let worst = 'ok' + let sawKnown = false + for (const item of items) { + const level = levels[item[status.key]] + if (!level) continue + sawKnown = true + if (STATUS_RANK[level] > STATUS_RANK[worst]) worst = level + } + if (!sawKnown) return null + return { level: worst, color: STATUS_COLOR[worst] || STATUS_COLOR.unknown } +} + // tabs: [{ label, fields: [{label, value, mono}] }]. One section is shown at a // time, so a panel with 30 values stays the height of its largest section // instead of dragging one multicol column far past the other. @@ -114,6 +155,30 @@ export function tabSections(panel) { .filter((s) => s.fields.length > 0) } +// Inline row action for a list panel. A monitored services/processes row may +// carry action: { running, start, stop, status } when it has a registered +// command (e.g. MTConnect). The card renders a single toggle on that row: when +// running it offers Stop, otherwise Start. Rows with no action are badge-only. +// commandendpoint on the panel is where the enqueue POST goes. +export function rowAction(panel, item) { + const action = item && item.action + if (!action || (!action.start && !action.stop)) return null + const running = action.running === true + return { + running, + // The action name to enqueue for the primary toggle button. + toggle: running ? action.stop : action.start, + toggleLabel: running ? 'Stop' : 'Start', + // status is a non-mutating probe, always available when declared. + status: action.status || null, + // Generic actions (service_*/process_*) carry the subject to act on; fixed + // actions (mtconnect_*) leave this null. + target: action.target || null, + // The declared enqueue endpoint template; the caller substitutes assetid. + endpoint: panel.commandendpoint || null, + } +} + export function panelVisible(panel) { if (panel.render === 'tabs') return tabSections(panel).length > 0 if (panel.render === 'list') return rows(panel).length > 0 || !!panel.empty diff --git a/frontend/src/components/pluginAssetPanels.spec.js b/frontend/src/components/pluginAssetPanels.spec.js index c48b00be..184bbe42 100644 --- a/frontend/src/components/pluginAssetPanels.spec.js +++ b/frontend/src/components/pluginAssetPanels.spec.js @@ -1,7 +1,8 @@ import { describe, it, expect } from 'vitest' import { toApiPath, subst, rows, mapTitle, mapBadge, mapMeta, manageLink, - keyvalueFields, tableColumns, badges, panelVisible, tabSections, + keyvalueFields, tableColumns, badges, panelVisible, tabSections, rollupStatus, + rowAction, } from './pluginAssetPanels' const warrantyPanel = { @@ -71,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({ @@ -130,3 +159,72 @@ describe('tabSections', () => { expect(panelVisible({ render: 'tabs', _data: { sections: [] } })).toBe(false) }) }) + +describe('rollupStatus', () => { + const status = { + key: 'statecolor', + levels: { green: 'ok', yellow: 'warning', red: 'critical' }, + } + + it('returns null when the panel declares no status', () => { + expect(rollupStatus({ _data: [{ statecolor: 'green' }] })).toBeNull() + }) + + it('returns null when there are no rows', () => { + expect(rollupStatus({ status, _data: [] })).toBeNull() + }) + + it('is ok (green) when every row is ok', () => { + const result = rollupStatus({ status, _data: [{ statecolor: 'green' }, { statecolor: 'green' }] }) + expect(result).toEqual({ level: 'ok', color: '#2dce89' }) + }) + + it('is warning (yellow) when a warning is present but no critical', () => { + const result = rollupStatus({ status, _data: [{ statecolor: 'green' }, { statecolor: 'yellow' }] }) + expect(result).toEqual({ level: 'warning', color: '#ffc107' }) + }) + + it('is critical (red) when any row is stopped', () => { + const result = rollupStatus({ status, _data: [{ statecolor: 'yellow' }, { statecolor: 'red' }] }) + expect(result).toEqual({ level: 'critical', color: '#f5365c' }) + }) + + it('ignores unmapped values without raising the level', () => { + const result = rollupStatus({ status, _data: [{ statecolor: 'gray' }, { statecolor: 'green' }] }) + expect(result).toEqual({ level: 'ok', color: '#2dce89' }) + }) + + it('returns null when no row value maps to a known level', () => { + expect(rollupStatus({ status, _data: [{ statecolor: 'gray' }] })).toBeNull() + }) +}) + +describe('rowAction (inline MTConnect toggle)', () => { + const panel = { id: 'endpoints-services', render: 'list', commandendpoint: '/api/endpoints/5/commands' } + + it('returns null for rows without an action', () => { + expect(rowAction(panel, { name: 'Some Service' })).toBeNull() + expect(rowAction(panel, { name: 'x', action: { status: 'mtconnect_status' } })).toBeNull() + }) + + it('offers Stop when the row is running', () => { + const info = rowAction(panel, { + name: 'MTConnect', + action: { running: true, start: 'mtconnect_start', stop: 'mtconnect_stop', status: 'mtconnect_status' }, + }) + expect(info.running).toBe(true) + expect(info.toggle).toBe('mtconnect_stop') + expect(info.toggleLabel).toBe('Stop') + expect(info.endpoint).toBe('/api/endpoints/5/commands') + }) + + it('offers Start when the row is not running', () => { + const info = rowAction(panel, { + name: 'MTConnect', + action: { running: false, start: 'mtconnect_start', stop: 'mtconnect_stop', status: 'mtconnect_status' }, + }) + expect(info.running).toBe(false) + expect(info.toggle).toBe('mtconnect_start') + expect(info.toggleLabel).toBe('Start') + }) +}) diff --git a/frontend/src/router/routes/core.js b/frontend/src/router/routes/core.js index 0d98321a..b2bc3864 100644 --- a/frontend/src/router/routes/core.js +++ b/frontend/src/router/routes/core.js @@ -150,6 +150,12 @@ export default [ component: () => import('../../views/settings/BrandingSettings.vue'), meta: { requiresAuth: true, requiresAdmin: true } }, + { + path: 'settings/importsiteassets', + name: 'import-site-assets', + component: () => import('../../views/settings/ImportSiteAssets.vue'), + meta: { requiresAuth: true, requiresAdmin: true } + }, { path: 'settings/printing', name: 'printing-settings', diff --git a/frontend/src/views/AppLayout.vue b/frontend/src/views/AppLayout.vue index 036dc27d..84452a9c 100644 --- a/frontend/src/views/AppLayout.vue +++ b/frontend/src/views/AppLayout.vue @@ -113,7 +113,7 @@ import ToastHost from '../components/ToastHost.vue' import { Sun, Moon, LayoutDashboard, Calendar, Map, Cog, Monitor, Printer, Globe, Usb, AppWindow, BookOpen, BarChart3, Bell, Image, ShieldCheck, Ruler, - Box, KeyRound, LogOut, Wrench + Box, KeyRound, LogOut, Wrench, Activity } from 'lucide-vue-next' import { useAuthStore } from '../stores/auth' import { currentTheme, toggleTheme } from '../stores/theme' @@ -183,6 +183,7 @@ const iconMap = { 'ruler': Ruler, 'box': Box, 'wrench': Wrench, + 'activity': Activity, } // Default navigation (used as fallback if API fails) diff --git a/frontend/src/views/reports/ReportsIndex.vue b/frontend/src/views/reports/ReportsIndex.vue index 0298ad05..8cef2891 100644 --- a/frontend/src/views/reports/ReportsIndex.vue +++ b/frontend/src/views/reports/ReportsIndex.vue @@ -360,8 +360,10 @@ async function loadFilterOptions(fields) { filterOptions.value.assettypes = response.data.data || [] } if (fields.includes('location') && !filterOptions.value.locations.length) { - const response = await locationsApi.list({ perpage: 100 }) - filterOptions.value.locations = response.data.data || [] + // listAll: 100 is the server-side cap, not a generous limit, and the + // location list is past it - a report filtered by a late-alphabet + // location could not be built. + filterOptions.value.locations = await locationsApi.listAll() } if (fields.includes('application') && !filterOptions.value.applications.length) { // listAll: 100 is the server-side cap, not a generous limit, and the diff --git a/frontend/src/views/settings/ImportSiteAssets.vue b/frontend/src/views/settings/ImportSiteAssets.vue new file mode 100644 index 00000000..679eedc1 --- /dev/null +++ b/frontend/src/views/settings/ImportSiteAssets.vue @@ -0,0 +1,966 @@ + + + + + diff --git a/frontend/src/views/settings/SiteSettings.vue b/frontend/src/views/settings/SiteSettings.vue index 09723667..ef6f178c 100644 --- a/frontend/src/views/settings/SiteSettings.vue +++ b/frontend/src/views/settings/SiteSettings.vue @@ -119,7 +119,8 @@ const LABELS = { usb_directory_mode: 'USB Directory Mode', site_timezone: 'Site Timezone', audit_retentiondays: 'Audit Log Retention (days)', - setup_complete: 'Setup Complete' + setup_complete: 'Setup Complete', + asset_import_csv_location: 'Asset Import CSV Location' } function prettyLabel(key) { return LABELS[key] || key @@ -228,7 +229,10 @@ async function load() { try { const response = await settingsApi.list() const all = response.data?.data || response.data || [] - items.value = all.filter(s => s.category === 'site') + // The Asset Collector CSV location is managed on the dedicated Import Site + // Assets page. + const importKeys = ['asset_import_csv_location'] + items.value = all.filter(s => s.category === 'site' && !importKeys.includes(s.key)) } catch (err) { console.error('Error loading site settings:', err) error.value = 'Could not load settings.' diff --git a/frontend/src/views/settings/settingsNav.js b/frontend/src/views/settings/settingsNav.js index c1fb32d9..a5b87238 100644 --- a/frontend/src/views/settings/settingsNav.js +++ b/frontend/src/views/settings/settingsNav.js @@ -4,7 +4,7 @@ // Order: site identity + the reference-data catalogs users touch daily come // first, then the platform/system groups (integrations, communication, search, // plugins, access) cluster together at the end. -import { Factory, MapPin, Tag, Package, Droplets, Monitor, MonitorSmartphone, Laptop, Cog, Building, Globe, Link, History, FileText, Users, Puzzle, Bell, Network, Home, Wrench, Printer, Router, Palette, SlidersHorizontal, Contact, Mail, ShieldCheck, KeyRound, Fingerprint, Search, Key } from 'lucide-vue-next' +import { Factory, MapPin, Tag, Package, Droplets, Monitor, MonitorSmartphone, Laptop, Cog, Building, Globe, Link, History, FileText, Users, Puzzle, Bell, Network, Home, Wrench, Printer, Router, Palette, SlidersHorizontal, Contact, Mail, ShieldCheck, KeyRound, Fingerprint, Search, Key, Upload } from 'lucide-vue-next' export const settingsGroups = [ { @@ -13,6 +13,7 @@ export const settingsGroups = [ { to: '/settings/site', icon: Home, title: 'Site & Facility', description: 'Site URL/FQDN, facility name, PC access domain, employee-ID pattern, printer hostname template' }, { to: '/settings/floormap', icon: MapPin, title: 'Floor Map', description: 'Facility floor-plan blueprint image and pixel dimensions (width, height) for the site map' }, { to: '/settings/branding', icon: Palette, title: 'Branding', description: 'Site, QR, and badge logos, favicon, and brand primary, accent, sidebar, and theme colors' }, + { to: '/settings/importsiteassets', icon: Upload, title: 'Import Site Assets', description: 'Seed Machines, PCs, and Applications via the Asset Collector script, existing databases, or Intune/Azure exports; set the shared CSV collection location and download collected files' }, ], }, { diff --git a/plugins/computers/api/__init__.py b/plugins/computers/api/__init__.py index 0bea25bc..5e14bb0f 100644 --- a/plugins/computers/api/__init__.py +++ b/plugins/computers/api/__init__.py @@ -1,5 +1,6 @@ """Computers plugin API.""" from .routes import computers_bp +from . import policy_routes # noqa: F401 registers policy routes on computers_bp __all__ = ['computers_bp'] diff --git a/plugins/computers/api/policy_routes.py b/plugins/computers/api/policy_routes.py new file mode 100644 index 00000000..e8e749be --- /dev/null +++ b/plugins/computers/api/policy_routes.py @@ -0,0 +1,399 @@ +"""CRUD API for Configuration and Business Policies. + +Registered on computers_bp under /api/computers/policies/... Kept in its own +module so the whole policy surface can be removed by not importing it - part of +the exit gate. Every route first checks the computers_configpolicies_enabled +switch and returns 404 while it is off, so enabling/disabling the feature never +half-exposes it. + +Write-time validation lives here (not in the model): scope ASTs go through +validate_scope, and action names are checked against the endpoints command +allowlist, so a bad tree or an unknown action never reaches the database. +""" + +from flask import request + +from shopdb.api import (db, AuditLog, success_response, error_response, + ErrorCodes, require_permission) + +from .routes import computers_bp +from ..models import (ConfigurationPolicy, ConfigurationPolicyAction, + BusinessPolicy, BusinessPolicyItem, POLICY_SOURCE_LOCAL, + ACTION_MODE_DESIRED, ACTION_MODE_GATE) +from ..policyflag import policy_flag_enabled +from ..services.policyscope import validate_scope, ScopeError + +_MODES = {ACTION_MODE_DESIRED, ACTION_MODE_GATE} + + +def _feature_gate(): + """Return a 404 response when the policy feature is off, else None.""" + if not policy_flag_enabled(): + return error_response(ErrorCodes.NOT_FOUND, + 'Policy feature is not enabled', http_code=404) + return None + + +def _known_actions(): + """The code-level allowlist of action names a policy may reference.""" + from plugins.endpoints.commands import COMMAND_ALLOWLIST + return set(COMMAND_ALLOWLIST.keys()) + + +def _configpolicy_dict(policy): + data = policy.to_dict() + data['actions'] = [a.to_dict() for a in policy.actions] + return data + + +def _businesspolicy_dict(policy): + data = policy.to_dict() + data['items'] = [{ + 'businesspolicyitemid': it.businesspolicyitemid, + 'configurationpolicyid': it.configurationpolicyid, + 'sortorder': it.sortorder, + 'name': it.configurationpolicy.name if it.configurationpolicy else None, + } for it in policy.items] + return data + + +# ============================================================================= +# Configuration Policies +# ============================================================================= + +@computers_bp.route('/policies/configuration', methods=['GET']) +@require_permission('computers.view') +def list_configuration_policies(): + gate = _feature_gate() + if gate: + return gate + policies = ConfigurationPolicy.query.order_by(ConfigurationPolicy.name).all() + return success_response([_configpolicy_dict(p) for p in policies]) + + +@computers_bp.route('/policies/configuration/', methods=['GET']) +@require_permission('computers.view') +def get_configuration_policy(policy_id: int): + gate = _feature_gate() + if gate: + return gate + policy = db.session.get(ConfigurationPolicy, policy_id) + if not policy: + return error_response(ErrorCodes.NOT_FOUND, + 'Configuration policy not found', http_code=404) + return success_response(_configpolicy_dict(policy)) + + +def _apply_actions(policy, actions): + """Replace a policy's actions from request data, validating each. + + Returns an error_response on failure, else None. + """ + known = _known_actions() + policy.actions.clear() + for row in actions or []: + name = (row.get('action') or '').strip() + if name not in known: + return error_response(ErrorCodes.VALIDATION_ERROR, + f'Unknown action: {name!r}') + mode = (row.get('mode') or ACTION_MODE_GATE).strip() + if mode not in _MODES: + return error_response(ErrorCodes.VALIDATION_ERROR, + f'Unknown action mode: {mode!r}') + policy.actions.append(ConfigurationPolicyAction( + action=name, + mode=mode, + desiredstate=row.get('desiredstate'), + params=row.get('params'), + intervalminutes=row.get('intervalminutes'), + sortorder=row.get('sortorder', 0) or 0, + )) + return None + + +@computers_bp.route('/policies/configuration', methods=['POST']) +@require_permission('computers.create') +def create_configuration_policy(): + gate = _feature_gate() + if gate: + return gate + data = request.get_json() or {} + name = (data.get('name') or '').strip() + if not name: + return error_response(ErrorCodes.VALIDATION_ERROR, 'name is required') + if ConfigurationPolicy.query.filter_by(name=name).first(): + return error_response(ErrorCodes.CONFLICT, + f"Configuration policy '{name}' already exists", + http_code=409) + + scope = data.get('scopejson') + if scope is not None: + try: + validate_scope(scope) + except ScopeError as exc: + return error_response(ErrorCodes.VALIDATION_ERROR, + f'Invalid scope: {exc}') + + policy = ConfigurationPolicy( + name=name, + description=data.get('description'), + scopejson=scope, + source=POLICY_SOURCE_LOCAL, + ) + err = _apply_actions(policy, data.get('actions')) + if err: + return err + + db.session.add(policy) + AuditLog.log('created', 'ConfigurationPolicy', entityname=name) + db.session.commit() + return success_response(_configpolicy_dict(policy), + message='Configuration policy created', + http_code=201) + + +@computers_bp.route('/policies/configuration/', methods=['PUT']) +@require_permission('computers.edit') +def update_configuration_policy(policy_id: int): + gate = _feature_gate() + if gate: + return gate + policy = db.session.get(ConfigurationPolicy, policy_id) + if not policy: + return error_response(ErrorCodes.NOT_FOUND, + 'Configuration policy not found', http_code=404) + if policy.source != POLICY_SOURCE_LOCAL: + return error_response(ErrorCodes.CONFLICT, + 'Policy is managed externally and is read-only', + http_code=409) + data = request.get_json() or {} + + if 'name' in data: + newname = (data['name'] or '').strip() + if newname and newname != policy.name: + if ConfigurationPolicy.query.filter_by(name=newname).first(): + return error_response(ErrorCodes.CONFLICT, + f"Configuration policy '{newname}' already exists", + http_code=409) + policy.name = newname + if 'description' in data: + policy.description = data['description'] + if 'isactive' in data: + policy.isactive = data['isactive'] + if 'scopejson' in data: + scope = data['scopejson'] + if scope is not None: + try: + validate_scope(scope) + except ScopeError as exc: + return error_response(ErrorCodes.VALIDATION_ERROR, + f'Invalid scope: {exc}') + policy.scopejson = scope + if 'actions' in data: + err = _apply_actions(policy, data['actions']) + if err: + return err + + AuditLog.log('updated', 'ConfigurationPolicy', entityname=policy.name) + db.session.commit() + return success_response(_configpolicy_dict(policy), + message='Configuration policy updated') + + +@computers_bp.route('/policies/configuration/', methods=['DELETE']) +@require_permission('computers.delete') +def delete_configuration_policy(policy_id: int): + gate = _feature_gate() + if gate: + return gate + policy = db.session.get(ConfigurationPolicy, policy_id) + if not policy: + return error_response(ErrorCodes.NOT_FOUND, + 'Configuration policy not found', http_code=404) + if policy.source != POLICY_SOURCE_LOCAL: + return error_response(ErrorCodes.CONFLICT, + 'Policy is managed externally and is read-only', + http_code=409) + inuse = BusinessPolicyItem.query.filter_by( + configurationpolicyid=policy_id).count() + if inuse: + return error_response( + ErrorCodes.CONFLICT, + f'Cannot delete: used by {inuse} business policy item(s)', + http_code=409) + db.session.delete(policy) + AuditLog.log('deleted', 'ConfigurationPolicy', entityname=policy.name) + db.session.commit() + return success_response(None, message='Configuration policy deleted') + + +# ============================================================================= +# Business Policies +# ============================================================================= + +@computers_bp.route('/policies/business', methods=['GET']) +@require_permission('computers.view') +def list_business_policies(): + gate = _feature_gate() + if gate: + return gate + policies = (BusinessPolicy.query + .order_by(BusinessPolicy.priority, BusinessPolicy.businesspolicyid) + .all()) + return success_response([_businesspolicy_dict(p) for p in policies]) + + +@computers_bp.route('/policies/business/', methods=['GET']) +@require_permission('computers.view') +def get_business_policy(policy_id: int): + gate = _feature_gate() + if gate: + return gate + policy = db.session.get(BusinessPolicy, policy_id) + if not policy: + return error_response(ErrorCodes.NOT_FOUND, + 'Business policy not found', http_code=404) + return success_response(_businesspolicy_dict(policy)) + + +def _apply_items(policy, items): + """Replace a business policy's configuration-policy items, validating each.""" + policy.items.clear() + seen = set() + for order, row in enumerate(items or []): + cpid = row.get('configurationpolicyid') + if cpid is None: + return error_response(ErrorCodes.VALIDATION_ERROR, + 'item requires configurationpolicyid') + if cpid in seen: + return error_response(ErrorCodes.VALIDATION_ERROR, + f'duplicate configuration policy {cpid}') + if not db.session.get(ConfigurationPolicy, cpid): + return error_response(ErrorCodes.VALIDATION_ERROR, + f'unknown configuration policy {cpid}') + seen.add(cpid) + policy.items.append(BusinessPolicyItem( + configurationpolicyid=cpid, + sortorder=row.get('sortorder', order), + )) + return None + + +@computers_bp.route('/policies/business', methods=['POST']) +@require_permission('computers.create') +def create_business_policy(): + gate = _feature_gate() + if gate: + return gate + data = request.get_json() or {} + name = (data.get('name') or '').strip() + if not name: + return error_response(ErrorCodes.VALIDATION_ERROR, 'name is required') + if BusinessPolicy.query.filter_by(name=name).first(): + return error_response(ErrorCodes.CONFLICT, + f"Business policy '{name}' already exists", + http_code=409) + policy = BusinessPolicy( + name=name, + description=data.get('description'), + priority=data.get('priority', 100), + source=POLICY_SOURCE_LOCAL, + ) + err = _apply_items(policy, data.get('items')) + if err: + return err + db.session.add(policy) + AuditLog.log('created', 'BusinessPolicy', entityname=name) + db.session.commit() + return success_response(_businesspolicy_dict(policy), + message='Business policy created', http_code=201) + + +@computers_bp.route('/policies/business/', methods=['PUT']) +@require_permission('computers.edit') +def update_business_policy(policy_id: int): + gate = _feature_gate() + if gate: + return gate + policy = db.session.get(BusinessPolicy, policy_id) + if not policy: + return error_response(ErrorCodes.NOT_FOUND, + 'Business policy not found', http_code=404) + if policy.source != POLICY_SOURCE_LOCAL: + return error_response(ErrorCodes.CONFLICT, + 'Policy is managed externally and is read-only', + http_code=409) + data = request.get_json() or {} + if 'name' in data: + newname = (data['name'] or '').strip() + if newname and newname != policy.name: + if BusinessPolicy.query.filter_by(name=newname).first(): + return error_response(ErrorCodes.CONFLICT, + f"Business policy '{newname}' already exists", + http_code=409) + policy.name = newname + if 'description' in data: + policy.description = data['description'] + if 'priority' in data: + policy.priority = data['priority'] + if 'isactive' in data: + policy.isactive = data['isactive'] + if 'items' in data: + err = _apply_items(policy, data['items']) + if err: + return err + AuditLog.log('updated', 'BusinessPolicy', entityname=policy.name) + db.session.commit() + return success_response(_businesspolicy_dict(policy), + message='Business policy updated') + + +@computers_bp.route('/policies/business/', methods=['DELETE']) +@require_permission('computers.delete') +def delete_business_policy(policy_id: int): + gate = _feature_gate() + if gate: + return gate + policy = db.session.get(BusinessPolicy, policy_id) + if not policy: + return error_response(ErrorCodes.NOT_FOUND, + 'Business policy not found', http_code=404) + if policy.source != POLICY_SOURCE_LOCAL: + return error_response(ErrorCodes.CONFLICT, + 'Policy is managed externally and is read-only', + http_code=409) + db.session.delete(policy) + AuditLog.log('deleted', 'BusinessPolicy', entityname=policy.name) + db.session.commit() + return success_response(None, message='Business policy deleted') + + +# ============================================================================= +# Action catalog (for the UI) and effective-policy preview +# ============================================================================= + +@computers_bp.route('/policies/actions', methods=['GET']) +@require_permission('computers.view') +def list_policy_actions(): + """The allowlisted action names + labels a policy may reference.""" + gate = _feature_gate() + if gate: + return gate + from plugins.endpoints.commands import COMMAND_ALLOWLIST + return success_response([ + {'action': name, 'label': meta.get('label'), + 'description': meta.get('description')} + for name, meta in COMMAND_ALLOWLIST.items() + ]) + + +@computers_bp.route('/policies/effective/', methods=['GET']) +@require_permission('computers.view') +def get_effective_policy(asset_id: int): + """Preview the resolved effective policy for one endpoint (debug/UI).""" + gate = _feature_gate() + if gate: + return gate + from ..services.policyresolver import effective_policy_for + effective = effective_policy_for(asset_id) + return success_response(effective.to_dict()) diff --git a/plugins/computers/api/routes.py b/plugins/computers/api/routes.py index 5bace5d4..9adb608a 100644 --- a/plugins/computers/api/routes.py +++ b/plugins/computers/api/routes.py @@ -575,6 +575,9 @@ def create_computer(): vendorid=data.get('vendorid'), modelnumberid=data.get('modelnumberid'), loggedinuser=data.get('loggedinuser'), + azuredeviceid=data.get('azuredeviceid'), + azureobjectid=data.get('azureobjectid'), + azuredevicecategory=data.get('azuredevicecategory'), lastreporteddate=data.get('lastreporteddate'), lastboottime=data.get('lastboottime') ) @@ -678,8 +681,9 @@ def update_computer(computer_id: int): # Update computer fields computer_fields = ['computertypeid', 'hostname', 'osid', 'vendorid', - 'modelnumberid', 'loggedinuser', 'lastreporteddate', - 'lastboottime'] + 'modelnumberid', 'loggedinuser', 'azuredeviceid', + 'azureobjectid', 'azuredevicecategory', + 'lastreporteddate', 'lastboottime'] for key in computer_fields: if key in data: old_val = getattr(comp, key) diff --git a/plugins/computers/frontend/routes.js b/plugins/computers/frontend/routes.js index 828d9122..3d7d0895 100644 --- a/plugins/computers/frontend/routes.js +++ b/plugins/computers/frontend/routes.js @@ -62,6 +62,18 @@ export default [ name: 'access-protocols', component: () => import('./views/AccessProtocolsList.vue'), meta: { requiresAuth: true, requiresAdmin: true, plugin: 'computers' } + }, + { + path: 'settings/configuration-policies', + name: 'configuration-policies', + component: () => import('./views/ConfigurationPoliciesList.vue'), + meta: { requiresAuth: true, requiresAdmin: true, plugin: 'computers' } + }, + { + path: 'settings/business-policies', + name: 'business-policies', + component: () => import('./views/BusinessPoliciesList.vue'), + meta: { requiresAuth: true, requiresAdmin: true, plugin: 'computers' } } ] diff --git a/plugins/computers/frontend/views/BusinessPoliciesList.vue b/plugins/computers/frontend/views/BusinessPoliciesList.vue new file mode 100644 index 00000000..4a6b20ae --- /dev/null +++ b/plugins/computers/frontend/views/BusinessPoliciesList.vue @@ -0,0 +1,223 @@ + + + + + diff --git a/plugins/computers/frontend/views/ConfigurationPoliciesList.vue b/plugins/computers/frontend/views/ConfigurationPoliciesList.vue new file mode 100644 index 00000000..17a2fc8f --- /dev/null +++ b/plugins/computers/frontend/views/ConfigurationPoliciesList.vue @@ -0,0 +1,223 @@ + + + + + diff --git a/plugins/computers/frontend/views/PCDetail.vue b/plugins/computers/frontend/views/PCDetail.vue index b288c17c..04efec4d 100644 --- a/plugins/computers/frontend/views/PCDetail.vue +++ b/plugins/computers/frontend/views/PCDetail.vue @@ -3,6 +3,8 @@ + + +
+

Entra Device Info

+
+
+ Device ID + {{ computer.computer.azuredeviceid }} +
+
+ Object ID + {{ computer.computer.azureobjectid }} +
+
+ Device Category + {{ computer.computer.azuredevicecategory }} +
+
+
@@ -226,7 +250,7 @@ - + @@ -253,10 +277,10 @@ + + + + diff --git a/plugins/computers/migrations/versions/0002_computer_azuredeviceid.py b/plugins/computers/migrations/versions/0002_computer_azuredeviceid.py new file mode 100644 index 00000000..72c5fe0b --- /dev/null +++ b/plugins/computers/migrations/versions/0002_computer_azuredeviceid.py @@ -0,0 +1,40 @@ +"""Add azuredeviceid to computers (Entra/Azure device-group imports). + +The core chain created computers WITHOUT an azuredeviceid column. This plugin +revision adds it so BOTH fresh installs (core chain builds the table, then this +adds the column) and existing installs get it. Guarded/idempotent: skips when +the table is absent (plugin disabled) or the column already exists (e.g. a test +DB built by db.create_all() from the model). + +Revision ID: computers0002azureid +Revises: computers0001anchor +""" +from alembic import op +import sqlalchemy as sa + + +revision = 'computers0002azureid' +down_revision = 'computers0001anchor' +branch_labels = None +depends_on = None + + +def upgrade(): + bind = op.get_bind() + insp = sa.inspect(bind) + if 'computers' not in insp.get_table_names(): + return + cols = {c['name'] for c in insp.get_columns('computers')} + if 'azuredeviceid' not in cols: + op.add_column('computers', + sa.Column('azuredeviceid', sa.String(length=64), nullable=True)) + + +def downgrade(): + bind = op.get_bind() + insp = sa.inspect(bind) + if 'computers' not in insp.get_table_names(): + return + cols = {c['name'] for c in insp.get_columns('computers')} + if 'azuredeviceid' in cols: + op.drop_column('computers', 'azuredeviceid') diff --git a/plugins/computers/migrations/versions/0003_computer_azure_fields.py b/plugins/computers/migrations/versions/0003_computer_azure_fields.py new file mode 100644 index 00000000..6ce93b29 --- /dev/null +++ b/plugins/computers/migrations/versions/0003_computer_azure_fields.py @@ -0,0 +1,45 @@ +"""Add azureobjectid and azuredevicecategory to computers. + +The Azure device-group import now also records the Entra directory object id +(the CSV 'id' column) and an operator-supplied device category (the group +membership label, e.g. SFLD_DYN_Device_Group_...). This revision adds both +columns. Guarded/idempotent: skips when the table is absent (plugin disabled) +or a column already exists (e.g. a test DB built by db.create_all()). + +Revision ID: computers0003azurefields +Revises: computers0002azureid +""" +from alembic import op +import sqlalchemy as sa + + +revision = 'computers0003azurefields' +down_revision = 'computers0002azureid' +branch_labels = None +depends_on = None + + +def upgrade(): + bind = op.get_bind() + insp = sa.inspect(bind) + if 'computers' not in insp.get_table_names(): + return + cols = {c['name'] for c in insp.get_columns('computers')} + if 'azureobjectid' not in cols: + op.add_column('computers', + sa.Column('azureobjectid', sa.String(length=64), nullable=True)) + if 'azuredevicecategory' not in cols: + op.add_column('computers', + sa.Column('azuredevicecategory', sa.String(length=255), nullable=True)) + + +def downgrade(): + bind = op.get_bind() + insp = sa.inspect(bind) + if 'computers' not in insp.get_table_names(): + return + cols = {c['name'] for c in insp.get_columns('computers')} + if 'azuredevicecategory' in cols: + op.drop_column('computers', 'azuredevicecategory') + if 'azureobjectid' in cols: + op.drop_column('computers', 'azureobjectid') diff --git a/plugins/computers/migrations/versions/0004_policies.py b/plugins/computers/migrations/versions/0004_policies.py new file mode 100644 index 00000000..d5c7f737 --- /dev/null +++ b/plugins/computers/migrations/versions/0004_policies.py @@ -0,0 +1,154 @@ +"""Add Configuration and Business Policy tables (RMM-style policies). + +Creates configurationpolicies, configurationpolicyactions, businesspolicies, +and businesspolicyitems. These back the DT-authored policy feature that is gated +by the computers_configpolicies_enabled setting; the tables land regardless so +the switch can be flipped without a schema change, and the downgrade drops all +four for a clean rollback of the whole scope. + +Idempotent: each create_table is skipped when the table already exists (e.g. a +test DB built by db.create_all()). + +Revision ID: computers0004policies +Revises: computers0003azurefields +""" +from alembic import op +import sqlalchemy as sa + + +revision = 'computers0004policies' +down_revision = 'computers0003azurefields' +branch_labels = None +depends_on = None + + +def upgrade(): + bind = op.get_bind() + insp = sa.inspect(bind) + existing = set(insp.get_table_names()) + + if 'configurationpolicies' not in existing: + op.create_table( + 'configurationpolicies', + sa.Column('configurationpolicyid', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=150), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('scopejson', sa.JSON(), nullable=True), + sa.Column('source', sa.String(length=50), nullable=False, + server_default='local'), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False, + server_default=sa.true()), + sa.PrimaryKeyConstraint('configurationpolicyid'), + sa.UniqueConstraint('name', name='uq_configurationpolicy_name'), + ) + op.create_index('idx_configpolicy_source', 'configurationpolicies', + ['source']) + op.create_index('idx_configpolicy_active', 'configurationpolicies', + ['isactive']) + + if 'configurationpolicyactions' not in existing: + op.create_table( + 'configurationpolicyactions', + sa.Column('configurationpolicyactionid', sa.Integer(), + nullable=False), + sa.Column('configurationpolicyid', sa.Integer(), nullable=False), + sa.Column('action', sa.String(length=100), nullable=False), + sa.Column('mode', sa.String(length=20), nullable=False, + server_default='gate'), + sa.Column('desiredstate', sa.JSON(), nullable=True), + sa.Column('params', sa.JSON(), nullable=True), + sa.Column('intervalminutes', sa.Integer(), nullable=True), + sa.Column('sortorder', sa.Integer(), nullable=False, + server_default='0'), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False, + server_default=sa.true()), + sa.ForeignKeyConstraint( + ['configurationpolicyid'], + ['configurationpolicies.configurationpolicyid'], + ondelete='CASCADE'), + sa.PrimaryKeyConstraint('configurationpolicyactionid'), + sa.UniqueConstraint('configurationpolicyid', 'action', + name='uq_configpolicyaction'), + ) + op.create_index('idx_configpolicyaction_policy', + 'configurationpolicyactions', + ['configurationpolicyid']) + + if 'businesspolicies' not in existing: + op.create_table( + 'businesspolicies', + sa.Column('businesspolicyid', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=150), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('priority', sa.Integer(), nullable=False, + server_default='100'), + sa.Column('source', sa.String(length=50), nullable=False, + server_default='local'), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False, + server_default=sa.true()), + sa.PrimaryKeyConstraint('businesspolicyid'), + sa.UniqueConstraint('name', name='uq_businesspolicy_name'), + ) + op.create_index('idx_businesspolicy_priority', 'businesspolicies', + ['priority']) + op.create_index('idx_businesspolicy_active', 'businesspolicies', + ['isactive']) + + if 'businesspolicyitems' not in existing: + op.create_table( + 'businesspolicyitems', + sa.Column('businesspolicyitemid', sa.Integer(), nullable=False), + sa.Column('businesspolicyid', sa.Integer(), nullable=False), + sa.Column('configurationpolicyid', sa.Integer(), nullable=False), + sa.Column('sortorder', sa.Integer(), nullable=False, + server_default='0'), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False, + server_default=sa.true()), + sa.ForeignKeyConstraint( + ['businesspolicyid'], ['businesspolicies.businesspolicyid'], + ondelete='CASCADE'), + sa.ForeignKeyConstraint( + ['configurationpolicyid'], + ['configurationpolicies.configurationpolicyid'], + ondelete='CASCADE'), + sa.PrimaryKeyConstraint('businesspolicyitemid'), + sa.UniqueConstraint('businesspolicyid', 'configurationpolicyid', + name='uq_businesspolicyitem'), + ) + op.create_index('idx_bpitem_business', 'businesspolicyitems', + ['businesspolicyid']) + op.create_index('idx_bpitem_config', 'businesspolicyitems', + ['configurationpolicyid']) + + +def downgrade(): + bind = op.get_bind() + insp = sa.inspect(bind) + existing = set(insp.get_table_names()) + + if 'businesspolicyitems' in existing: + op.drop_index('idx_bpitem_config', table_name='businesspolicyitems') + op.drop_index('idx_bpitem_business', table_name='businesspolicyitems') + op.drop_table('businesspolicyitems') + if 'businesspolicies' in existing: + op.drop_index('idx_businesspolicy_active', table_name='businesspolicies') + op.drop_index('idx_businesspolicy_priority', table_name='businesspolicies') + op.drop_table('businesspolicies') + if 'configurationpolicyactions' in existing: + op.drop_index('idx_configpolicyaction_policy', + table_name='configurationpolicyactions') + op.drop_table('configurationpolicyactions') + if 'configurationpolicies' in existing: + op.drop_index('idx_configpolicy_active', + table_name='configurationpolicies') + op.drop_index('idx_configpolicy_source', + table_name='configurationpolicies') + op.drop_table('configurationpolicies') diff --git a/plugins/computers/models/__init__.py b/plugins/computers/models/__init__.py index eee75210..d5813968 100644 --- a/plugins/computers/models/__init__.py +++ b/plugins/computers/models/__init__.py @@ -7,6 +7,15 @@ from .computer import ( AccessProtocol, ComputerAccess, ) +from .policy import ( + ConfigurationPolicy, + ConfigurationPolicyAction, + BusinessPolicy, + BusinessPolicyItem, + POLICY_SOURCE_LOCAL, + ACTION_MODE_DESIRED, + ACTION_MODE_GATE, +) __all__ = [ 'Computer', @@ -14,4 +23,11 @@ __all__ = [ 'ComputerInstalledApp', 'AccessProtocol', 'ComputerAccess', + 'ConfigurationPolicy', + 'ConfigurationPolicyAction', + 'BusinessPolicy', + 'BusinessPolicyItem', + 'POLICY_SOURCE_LOCAL', + 'ACTION_MODE_DESIRED', + 'ACTION_MODE_GATE', ] diff --git a/plugins/computers/models/computer.py b/plugins/computers/models/computer.py index 6a35b4fc..c0ca3463 100644 --- a/plugins/computers/models/computer.py +++ b/plugins/computers/models/computer.py @@ -85,6 +85,23 @@ class Computer(BaseModel): lastreporteddate = db.Column(db.DateTime, nullable=True) lastboottime = db.Column(db.DateTime, nullable=True) + # Entra/Azure device identity (set by Azure device-group imports) + azuredeviceid = db.Column( + db.String(64), + nullable=True, + comment='Entra/Azure deviceId GUID' + ) + azureobjectid = db.Column( + db.String(64), + nullable=True, + comment='Entra/Azure directory object id GUID' + ) + azuredevicecategory = db.Column( + db.String(255), + nullable=True, + comment='Entra device category / group membership label set at import' + ) + # Remote access is now modeled per-protocol via the accessmethods # relationship (AccessProtocol / ComputerAccess), replacing isvnc/iswinrm. diff --git a/plugins/computers/models/policy.py b/plugins/computers/models/policy.py new file mode 100644 index 00000000..a95ea5a4 --- /dev/null +++ b/plugins/computers/models/policy.py @@ -0,0 +1,197 @@ +"""Configuration and business policy models (RMM-style). + +DT staff author reusable Configuration Policies (a single rule binding +allowlisted endpoint actions to endpoints matched by a boolean scope) and group +them into ordered Business Policies assigned to a fleet. When an endpoint reports +in, the server folds the matching Business Policies in priority order to compute +that endpoint's effective policy (first match wins per action). + +These models live in the computers plugin so policies sit next to ComputerType. +The action VOCABULARY stays authoritative in plugins/endpoints/commands.py; this +layer references action NAMES only. The `source` column is the seam for a future +GE-Enforce sync (local-authored rows vs imported/managed rows). + +The whole surface is gated by the computers_configpolicies_enabled setting; the +models register unconditionally (SQLAlchemy needs them), but routes, panels, and +enforcement stay inert while the flag is off, and the migration downgrade drops +every table for a clean rollback. +""" + +from shopdb.api import db, BaseModel + + +# Provenance of a policy row. +# leaves room for `geenforce` (or another manager) to own rows later without a +# schema change - an imported row is read-only in the UI and reconciled by its +# owner. Kept a plain string, not an enum column, so adding a source is data. +POLICY_SOURCE_LOCAL = 'local' + + +class ConfigurationPolicy(BaseModel): + """A single reusable policy: a scope plus the actions it applies. + + Scope is a boolean expression (AND/OR/NOT over PC types and applications) + stored as a small typed JSON AST in scopejson; see services/policyscope.py + for the evaluator. Actions live in ConfigurationPolicyAction rows. + """ + __tablename__ = 'configurationpolicies' + + configurationpolicyid = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(150), unique=True, nullable=False) + description = db.Column(db.Text) + + # Boolean target AST: {"type":"and","nodes":[{"type":"pctype","id":3}, + # {"type":"not","node":{"type":"app","id":12}}]}. Validated server-side + # (known node types, depth cap) before it is stored - see policyscope. + scopejson = db.Column(db.JSON, nullable=True) + + # GE-Enforce interop seam (see POLICY_SOURCE_LOCAL). + source = db.Column(db.String(50), default=POLICY_SOURCE_LOCAL, nullable=False) + + # createddate, modifieddate, isactive come from BaseModel. + + actions = db.relationship( + 'ConfigurationPolicyAction', + back_populates='configurationpolicy', + cascade='all, delete-orphan', + ) + + __table_args__ = ( + db.Index('idx_configpolicy_source', 'source'), + db.Index('idx_configpolicy_active', 'isactive'), + ) + + def __repr__(self): + return f"" + + +# What a policy action does to a matched endpoint. +# desired: reconciled desired-state. desiredstate says what it should be +# (e.g. running=true for mtconnect); the server enqueues the +# drift-correcting command when telemetry disagrees. +# gate: allowlist-gating only. The action is PERMITTED (and shown) on +# matched endpoints; it constrains what may be enqueued, it does not +# drive anything on its own. +ACTION_MODE_DESIRED = 'desired' +ACTION_MODE_GATE = 'gate' + + +class ConfigurationPolicyAction(BaseModel): + """One allowlisted action carried by a ConfigurationPolicy. + + `action` is a name from plugins/endpoints/commands.py COMMAND_ALLOWLIST; + it is validated against that allowlist at write time. `mode` selects + desired-state reconciliation vs allowlist-gating. + """ + __tablename__ = 'configurationpolicyactions' + + configurationpolicyactionid = db.Column(db.Integer, primary_key=True) + configurationpolicyid = db.Column( + db.Integer, + db.ForeignKey('configurationpolicies.configurationpolicyid', + ondelete='CASCADE'), + nullable=False, + ) + + # Allowlisted action name (endpoints commands.py). Not an FK - the allowlist + # is code, not a table - so it is validated in the API layer. + action = db.Column(db.String(100), nullable=False) + + mode = db.Column(db.String(20), default=ACTION_MODE_GATE, nullable=False) + + # Desired-state target for mode=desired, as JSON so different actions can + # express different targets (e.g. {"running": true}). Null for gate mode. + desiredstate = db.Column(db.JSON, nullable=True) + + # Optional action parameters passed through to the command (JSON), and an + # optional reconcile cadence in minutes (null = reconcile on every report). + params = db.Column(db.JSON, nullable=True) + intervalminutes = db.Column(db.Integer, nullable=True) + + sortorder = db.Column(db.Integer, default=0, nullable=False) + + configurationpolicy = db.relationship( + 'ConfigurationPolicy', back_populates='actions') + + __table_args__ = ( + db.UniqueConstraint('configurationpolicyid', 'action', + name='uq_configpolicyaction'), + db.Index('idx_configpolicyaction_policy', 'configurationpolicyid'), + ) + + def __repr__(self): + return f"" + + +class BusinessPolicy(BaseModel): + """An ordered collection of Configuration Policies assigned to a fleet. + + Priority orders Business Policies against each other when several match one + endpoint: lower priority number is evaluated first, and the first policy to + speak on a given action name wins (first-match-wins per action). + """ + __tablename__ = 'businesspolicies' + + businesspolicyid = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(150), unique=True, nullable=False) + description = db.Column(db.Text) + + # Lower runs first. Ties broken by businesspolicyid for a stable order. + priority = db.Column(db.Integer, default=100, nullable=False) + + source = db.Column(db.String(50), default=POLICY_SOURCE_LOCAL, nullable=False) + + # createddate, modifieddate, isactive come from BaseModel. + + items = db.relationship( + 'BusinessPolicyItem', + back_populates='businesspolicy', + cascade='all, delete-orphan', + order_by='BusinessPolicyItem.sortorder', + ) + + __table_args__ = ( + db.Index('idx_businesspolicy_priority', 'priority'), + db.Index('idx_businesspolicy_active', 'isactive'), + ) + + def __repr__(self): + return f"" + + +class BusinessPolicyItem(BaseModel): + """Membership of a Configuration Policy in a Business Policy, ordered. + + sortorder sets the walk order WITHIN a Business Policy; combined with the + Business Policy priority it gives a fully deterministic fold order for the + effective-policy resolver. + """ + __tablename__ = 'businesspolicyitems' + + businesspolicyitemid = db.Column(db.Integer, primary_key=True) + businesspolicyid = db.Column( + db.Integer, + db.ForeignKey('businesspolicies.businesspolicyid', ondelete='CASCADE'), + nullable=False, + ) + configurationpolicyid = db.Column( + db.Integer, + db.ForeignKey('configurationpolicies.configurationpolicyid', + ondelete='CASCADE'), + nullable=False, + ) + sortorder = db.Column(db.Integer, default=0, nullable=False) + + businesspolicy = db.relationship('BusinessPolicy', back_populates='items') + configurationpolicy = db.relationship('ConfigurationPolicy') + + __table_args__ = ( + db.UniqueConstraint('businesspolicyid', 'configurationpolicyid', + name='uq_businesspolicyitem'), + db.Index('idx_bpitem_business', 'businesspolicyid'), + db.Index('idx_bpitem_config', 'configurationpolicyid'), + ) + + def __repr__(self): + return (f"") diff --git a/plugins/computers/plugin.py b/plugins/computers/plugin.py index dd15876f..c724fa54 100644 --- a/plugins/computers/plugin.py +++ b/plugins/computers/plugin.py @@ -12,8 +12,16 @@ from shopdb.plugins.base import BasePlugin, PluginMeta from shopdb.api import db, AssetType from .models import Computer, ComputerType, ComputerInstalledApp, AccessProtocol, ComputerAccess +from .models import ( + ConfigurationPolicy, + ConfigurationPolicyAction, + BusinessPolicy, + BusinessPolicyItem, +) from .api import computers_bp +from .policyflag import policy_flag_enabled as _policy_flag_enabled + logger = logging.getLogger(__name__) # Marker stamped on PC->measuringtool "controls" links this collector creates @@ -32,6 +40,14 @@ MEASURINGTOOL_LINK_ORIGIN = 'collector:measuringtool' # touched. MACHINE_LINK_ORIGIN = 'collector:machine' +# Marker stamped on the PC->machine link built from a CSV import's machine +# number column. A DISTINCT origin from the collector's so the two provenances +# stay separable: an import establishes a link once from a spreadsheet, the +# collector maintains it continuously from the floor, and a site may use one, +# the other, or both. Same archive discipline - only rows carrying this label +# are touched by a re-import. +MACHINE_LINK_IMPORT_ORIGIN = 'import:machine' + # Marker stamped on both links a part-marker PC produces: PC controls marker, # and marker partof the operation it serves. Same archive discipline again. PARTMARKER_LINK_ORIGIN = 'collector:partmarker' @@ -84,12 +100,229 @@ class ComputersPlugin(BasePlugin): def get_models(self) -> List[Type]: """Return list of SQLAlchemy model classes.""" - return [Computer, ComputerType, ComputerInstalledApp, AccessProtocol, ComputerAccess] + return [Computer, ComputerType, ComputerInstalledApp, AccessProtocol, ComputerAccess, + ConfigurationPolicy, ConfigurationPolicyAction, BusinessPolicy, BusinessPolicyItem] def init_app(self, app: Flask, db_instance) -> None: """Initialize plugin with Flask app.""" + self._register_import_hook() logger.info(f"Computers plugin initialized (v{self.meta.version})") + # -- bulk-import machine link ------------------------------------------- + + def _register_import_hook(self): + """Own a `machineassetnumber` column on computers.csv. + + A site with a spreadsheet knows "this PC drives machine 3015" and wants + to write that number in the PC row, not hand-build an assetrelationships + sheet keyed on ids it never sees. The core importer stays ignorant of + what the column means: it accepts the header, keeps it out of the + computers write, and hands the value to this hook after each PC row is + written, so the PC's asset id exists. Registration is idempotent, so a + plugin reload or a test re-init does not double-fire. + """ + from shopdb.api import register_import_hook + + register_import_hook( + 'computers', + {'machineassetnumber': + 'the asset number of the machine this PC drives (from ' + 'machines.csv); builds the PC-controls-machine link. A number ' + 'that has no machine, or one already driven by another PC, is ' + 'reported as a warning and skipped - the PC still imports.'}, + self._import_machine_link) + + def _import_machine_link(self, rowvalues, virtualvalues, line): + """Build a PC->machine controls link from an import row's machine number. + + Runs inside the importer's transaction - a dry run rolls it back - so it + reuses the collector's mature _sync_machine_link rather than inventing a + second linker, but under the import origin (so an import never archives + a collector's rows and vice versa) and in preview mode (so a bulk import + never emails an alert per row; the findings are the report's amber + warnings instead). + + Returns RowWarning list, never raises: a machine number that resolves to + no asset is an amber note beside a PC that still imports, not a failed + file. Every warning names the PC, the number, and the one fix. + """ + from shopdb.api import RowWarning + + machinenumber = (virtualvalues.get('machineassetnumber') or '').strip() + if not machinenumber: + return [] + + assetid = rowvalues.get('assetid') + comp = Computer.query.filter_by(assetid=assetid).first() if assetid else None + if comp is None: + # The PC row was written but its Computer extension is not readable + # here - nothing to link to. Amber, not fatal. + return [RowWarning( + line, 'machineassetnumber', + reason='PC row not found to link to machine {!r}'.format( + machinenumber), + action='re-run the import; if it recurs, check the computers ' + 'row imported', + target=machinenumber)] + + hostname = comp.hostname or (assetid and str(assetid)) or '?' + + # IN-FILE FAN-OUT GUARD. Two rows in one computers.csv both naming the + # same machine is a spreadsheet mistake - a copied cell, a wrong + # workstation number - not a swap. But _incumbent_has_yielded reads a + # freshly-imported PC (no lastreporteddate) as having yielded, so + # without this the SECOND row would silently archive the first row's + # link and steal the machine, and the file would import clean with the + # wrong PC on the bay. So the first claim on a machine this run wins and + # is remembered; a later row for the same machine is warned and skipped. + # The claim set lives on db.session.info, which Flask-SQLAlchemy resets + # at request teardown, so it is scoped to exactly this one import and + # never leaks into the next. + machine = self._asset_by_number(machinenumber) + if machine is not None and machine.assetid == assetid: + # The number names this very PC. Happens on a PC first seen before + # the machine-number convention, whose own asset number IS the + # machine number. Skip - a PC does not control itself. + return [RowWarning( + line, 'machineassetnumber', + reason='machine number {!r} is this PC\'s own asset number; ' + 'not linked'.format(machinenumber), + action='give this PC a hostname-based asset number, or point ' + 'machineassetnumber at the machine it drives', + subject=hostname, target=machinenumber)] + + if machine is not None: + # A location-only machine is a virtual bay marker, not a real + # machine, so nothing controls it. Skip rather than build a link + # that would read as a PC driving a location. + skip = self._location_only_skip( + machine, hostname, machinenumber, line) + if skip is not None: + return [skip] + + if machine is not None and machine.assetid != assetid: + # ALREADY-CONTROLS-DIFFERENT. Unlike the collector - which reads a + # new machine number as a PC moved to a new bay and silently + # re-bays it - an import naming a machine when this PC already + # actively controls a DIFFERENT one is far more likely a wrong + # workstation number in the sheet than a real move. Warn and skip + # rather than archive a link a person may have set on purpose. + other = self._active_controlled_machine(assetid, machine.assetid) + if other is not None: + return [RowWarning( + line, 'machineassetnumber', + reason='{} already controls machine {}; not moved to {}' + .format(hostname, other, machine.assetnumber), + action='if this PC really moved, clear the existing link ' + 'first; otherwise correct the machine number on ' + 'this row', + subject=hostname, target=machinenumber)] + + if machine is not None and machine.assetid != assetid: + claimed = db.session.info.setdefault('import_machine_claims', {}) + # Keyed by machine asset id -> (claimant PC asset id, its hostname). + # The id is what the guard compares on (the same PC re-stating its + # own link is not a conflict); the hostname is only for the message. + firstclaimant = claimed.get(machine.assetid) + if firstclaimant is not None and firstclaimant[0] != assetid: + return [RowWarning( + line, 'machineassetnumber', + reason='machine {} was already claimed by {} earlier in ' + 'this import; {} not linked'.format( + machine.assetnumber, firstclaimant[1], hostname), + action='a machine drives one PC - correct the machine ' + 'number on one of these rows so each PC names its ' + 'own machine', + subject=hostname, target=machinenumber)] + claimed[machine.assetid] = (assetid, hostname) + + # _sync_machine_link appends plain strings to its warnings list. Collect + # them and translate to structured, actionable RowWarnings so the UI can + # show the PC, the machine number, and the corrective step. + rawwarnings = [] + self._sync_machine_link( + comp, machinenumber, rawwarnings, + origin_label=MACHINE_LINK_IMPORT_ORIGIN, preview=True) + + out = [] + for text in rawwarnings: + out.append(RowWarning( + line, 'machineassetnumber', + reason=text, + action=self._machine_link_action(text, machinenumber), + subject=hostname, target=machinenumber)) + return out + + @staticmethod + def _active_controlled_machine(pcassetid, excludeassetid): + """Asset number of a machine this PC actively controls under the import + origin, other than excludeassetid, or None. + + Scoped to MACHINE_LINK_IMPORT_ORIGIN so an import only reasons about its + own links: a collector link or a hand-made one is the collector's or a + person's to move, not the import's to block on. + """ + from shopdb.api import AssetRelationship, RelationshipType, Asset + + controls = RelationshipType.query.filter_by( + relationshiptype='controls').first() + if not controls: + return None + rel = AssetRelationship.query.filter( + AssetRelationship.sourceassetid == pcassetid, + AssetRelationship.relationshiptypeid == controls.relationshiptypeid, + AssetRelationship.label == MACHINE_LINK_IMPORT_ORIGIN, + AssetRelationship.targetassetid != excludeassetid, + AssetRelationship.isactive.is_(True), + ).first() + if rel is None: + return None + asset = db.session.get(Asset, rel.targetassetid) + return asset.assetnumber if asset else str(rel.targetassetid) + + @staticmethod + def _location_only_skip(machine, hostname, machinenumber, line): + """A RowWarning if the resolved asset is a location-only marker, else None. + + A machine flagged islocationonly is a virtual bay, not a real machine, + so a PC does not control it. Guarded behind the machines plugin being + installed - a site without it cannot have a location-only machine, and + the import must not fail because the model is absent. + """ + from shopdb.api import RowWarning + try: + from plugins.machines.models import Machine + except ImportError: + return None + row = Machine.query.filter_by(assetid=machine.assetid).first() + if row is None or not row.islocationonly: + return None + return RowWarning( + line, 'machineassetnumber', + reason='machine {} is a location-only marker, not a real machine; ' + 'not linked'.format(machine.assetnumber), + action='point machineassetnumber at a real machine, or clear it ' + 'for this PC', + subject=hostname, target=machinenumber) + + @staticmethod + def _machine_link_action(text, machinenumber): + """The one corrective step for a machine-link warning, by its shape.""" + lowered = text.lower() + if 'no asset for machine number' in lowered: + return ('create the machine asset with number {!r} (import ' + 'machines.csv first), or correct the number on this PC ' + 'row'.format(machinenumber)) + if 'still held by' in lowered: + return ('confirm which PC drives this machine; retire or move the ' + 'old PC off In Use to hand the machine over') + if 'taken over from' in lowered: + return ('expected on a PC swap; set the old PC to Retired, ' + 'Inventory or In Repair') + if 'relationship type missing' in lowered: + return 'run flask seed reference-data, then re-import' + return 'review this PC and its machine number' + # -- ADR-006 collector contract ----------------------------------------- def get_collector_schema(self) -> Optional[Dict]: @@ -173,6 +406,36 @@ class ComputersPlugin(BasePlugin): 'site\'s URL and ranges', 'position': 26, }, + ] + self._policy_settings_cards() + + def _policy_settings_cards(self): + """Policy admin cards, shown only while the feature flag is on. + + Gating the cards on the switch keeps the settings menu honest: the + Configuration/Business Policy screens appear exactly when the feature is + enabled and vanish on rollback, matching the 404 the routes return. + """ + if not self._configpolicies_enabled_safe(): + return [] + return [ + { + 'group': 'Computers', + 'to': '/settings/configuration-policies', + 'icon': 'shield', + 'title': 'Configuration policies', + 'description': 'Reusable rules binding endpoint actions to PC ' + 'types and applications', + 'position': 27, + }, + { + 'group': 'Computers', + 'to': '/settings/business-policies', + 'icon': 'layers', + 'title': 'Business policies', + 'description': 'Ordered collections of configuration policies ' + 'assigned by priority', + 'position': 28, + }, ] def get_settings_defaults(self) -> List[dict]: @@ -214,8 +477,41 @@ class ComputersPlugin(BasePlugin): 'Off while machine numbers are shared between ' 'PCs; the collector response still warns.', }, + { + 'key': 'computers_configpolicies_enabled', + 'value': 'false', + 'valuetype': 'boolean', + 'category': 'computers', + 'description': 'Master switch for RMM-style Configuration and ' + 'Business Policies. Off by default: models exist ' + 'but the CRUD routes, WebUI, and endpoint ' + 'enforcement stay inert so the whole feature can ' + 'be rolled back without a schema change.', + }, ] + def _configpolicies_enabled(self): + """Whether the Configuration/Business Policy feature is turned on. + + Off by default. This is the single exit gate for the policy scope: the + tables and models load regardless (SQLAlchemy needs them), but every + route, panel, and enforcement path checks this first and no-ops while it + is false, so the feature can be disabled without a migration. + """ + return _policy_flag_enabled() + + def _configpolicies_enabled_safe(self): + """Flag read that never raises when the settings table is absent. + + get_settings_cards can be called in contexts without a database (the + plugin contract tests build hooks against a bare app), so a missing + settings table must read as "off" rather than propagate. + """ + try: + return _policy_flag_enabled() + except Exception: + return False + def _machinelink_alerts_enabled(self): """Whether the PC-to-machine alerts may be sent. @@ -534,7 +830,8 @@ class ComputersPlugin(BasePlugin): 'with POST /api/printers/assignments/seed-from-observed.' % ' and '.join(sent)) - def _sync_machine_link(self, comp, machinenumber, warnings): + def _sync_machine_link(self, comp, machinenumber, warnings, + origin_label=MACHINE_LINK_ORIGIN, preview=False): """Link a PC to the machine it drives, from the reported machine number. Until this existed the machine number was collected and then discarded, @@ -556,6 +853,15 @@ class ComputersPlugin(BasePlugin): cadence, alerting on every pass. The incumbent therefore keeps the machine while it is still alive, and the challenger is recorded as a dormant link. See _incumbent_has_yielded for what alive means. + + origin_label separates provenance: the collector stamps + MACHINE_LINK_ORIGIN, a CSV import stamps MACHINE_LINK_IMPORT_ORIGIN, and + each only ever archives its own rows so the two sources coexist. + + preview=True is for a dry-run import: DB writes still happen in the + caller's transaction (which it rolls back), but the alert fan-out - the + one side effect a rollback cannot undo - is suppressed so a preview + never emails anyone. """ from shopdb.api import AssetRelationship, RelationshipType, Asset @@ -587,7 +893,7 @@ class ComputersPlugin(BasePlugin): mine = AssetRelationship.query.filter( AssetRelationship.sourceassetid == pcasset.assetid, AssetRelationship.relationshiptypeid == controls.relationshiptypeid, - AssetRelationship.label == MACHINE_LINK_ORIGIN, + AssetRelationship.label == origin_label, ).all() link = None @@ -597,11 +903,23 @@ class ComputersPlugin(BasePlugin): else: rel.isactive = False + # The unique constraint on assetrelationships is (source, target, type) + # and does NOT include label, so an import (import:machine) and the + # collector (collector:machine) cannot each hold their OWN row for the + # same PC->machine control. If our label-scoped lookup found nothing but + # a row for this exact triple exists under the other provenance, adopt + # it rather than insert a duplicate that would violate the constraint. + if link is None: + link = AssetRelationship.query.filter_by( + sourceassetid=pcasset.assetid, + targetassetid=machine.assetid, + relationshiptypeid=controls.relationshiptypeid).first() + # Whoever actively holds this machine now, if it is not this PC. held = AssetRelationship.query.filter( AssetRelationship.targetassetid == machine.assetid, AssetRelationship.relationshiptypeid == controls.relationshiptypeid, - AssetRelationship.label == MACHINE_LINK_ORIGIN, + AssetRelationship.label == origin_label, AssetRelationship.sourceassetid != pcasset.assetid, AssetRelationship.isactive.is_(True), ).all() @@ -624,9 +942,10 @@ class ComputersPlugin(BasePlugin): sourceassetid=pcasset.assetid, targetassetid=machine.assetid, relationshiptypeid=controls.relationshiptypeid, - label=MACHINE_LINK_ORIGIN, + label=origin_label, isactive=False)) - self._alert_machine_contested(names, comp, machine) + if not preview: + self._alert_machine_contested(names, comp, machine) else: link.isactive = False return [{'assetid': machine.assetid, @@ -640,7 +959,7 @@ class ComputersPlugin(BasePlugin): sourceassetid=pcasset.assetid, targetassetid=machine.assetid, relationshiptypeid=controls.relationshiptypeid, - label=MACHINE_LINK_ORIGIN, + label=origin_label, isactive=True) db.session.add(link) else: @@ -654,7 +973,8 @@ class ComputersPlugin(BasePlugin): warnings.append( 'machine {} was taken over from {}; check that PC'.format( machine.assetnumber, oldname)) - self._alert_pc_superseded(oldname, comp, machine) + if not preview: + self._alert_pc_superseded(oldname, comp, machine) return [{'assetid': machine.assetid, 'machinenumber': machine.assetnumber, @@ -757,8 +1077,8 @@ class ComputersPlugin(BasePlugin): # # A row made by hand carries neither label and is deliberately NOT # counted: a person's link is not the collector's to archive. - ourlabels = (label, MACHINE_LINK_ORIGIN, MEASURINGTOOL_LINK_ORIGIN, - PARTMARKER_LINK_ORIGIN) + ourlabels = (label, MACHINE_LINK_ORIGIN, MACHINE_LINK_IMPORT_ORIGIN, + MEASURINGTOOL_LINK_ORIGIN, PARTMARKER_LINK_ORIGIN) others = AssetRelationship.query.filter( AssetRelationship.targetassetid == deviceasset.assetid, AssetRelationship.relationshiptypeid == controls.relationshiptypeid, @@ -1310,15 +1630,17 @@ class ComputersPlugin(BasePlugin): sourceassetid=pcasset.assetid, targetassetid=adopted.assetid, relationshiptypeid=controls.relationshiptypeid).first() - if claimed is not None and claimed.label == MACHINE_LINK_ORIGIN: + if claimed is not None and claimed.label in ( + MACHINE_LINK_ORIGIN, MACHINE_LINK_IMPORT_ORIGIN): # LEAVE IT ALONE. On a CMM the instrument IS the reported bay, - # so the machine sync has already made the row for this exact - # triple - and it finds its own rows BY that label. Relabelling - # hid the link from it, so the next cycle built a second row for - # the same triple and MySQL rejected it: the PC reported 200, - # then 500 forever after. The row's lifecycle (including staying - # dormant while a bay is contested) belongs to the machine sync; - # adoption only needs the identity, which it has. + # so the machine sync (or a CSV import's machine link) has + # already made the row for this exact triple - and each finds + # its own rows BY that label. Relabelling hid the link from it, + # so the next cycle built a second row for the same triple and + # MySQL rejected it: the PC reported 200, then 500 forever + # after. The row's lifecycle (including staying dormant while a + # bay is contested) belongs to the machine sync; adoption only + # needs the identity, which it has. pass elif claimed is not None: # Relabelling is what adoption MEANS for any other row: the link diff --git a/plugins/computers/policyflag.py b/plugins/computers/policyflag.py new file mode 100644 index 00000000..98d132ad --- /dev/null +++ b/plugins/computers/policyflag.py @@ -0,0 +1,19 @@ +"""Single source of truth for the Configuration/Business Policy feature gate. + +Kept in its own module (not on the plugin class) so the CRUD routes, the panel +surface, and the endpoints enforcement path can all consult the same switch +without importing ComputersPlugin. The flag is the computers_configpolicies_enabled +Setting row seeded by the plugin's get_settings_defaults; while it is off, every +policy code path no-ops, which is the exit gate for rolling the feature back. +""" + + +def policy_flag_enabled(): + """True when computers_configpolicies_enabled is set truthy.""" + from shopdb.api import Setting + + setting = Setting.query.filter_by( + key='computers_configpolicies_enabled').first() + if not setting: + return False + return (setting.value or '').strip().lower() in ('true', '1', 'yes') diff --git a/plugins/computers/services/__init__.py b/plugins/computers/services/__init__.py new file mode 100644 index 00000000..c43ac045 --- /dev/null +++ b/plugins/computers/services/__init__.py @@ -0,0 +1 @@ +"""Computers plugin services: pure policy logic (scope + resolution).""" diff --git a/plugins/computers/services/policyresolver.py b/plugins/computers/services/policyresolver.py new file mode 100644 index 00000000..b71592b6 --- /dev/null +++ b/plugins/computers/services/policyresolver.py @@ -0,0 +1,168 @@ +"""Effective-policy resolution: the single swappable policy entry point. + +This module answers one question for the rest of the system: "given an endpoint, +what actions apply and how?" It is the boundary the endpoints allowlist seam +calls - never the policy tables directly - so the policy ASSEMBLY ENGINE can be +swapped without touching enforcement. Today it folds the local computers-plugin +tables; a future GE-Enforce integration can replace build_effective_policy (or +feed it) and every consumer keeps working unchanged. + +Resolution model (per the design): + - Build an endpoint context (PC type + installed appids) from the Computer row. + - Walk every active Business Policy in priority order (lower first, ties by id). + - Within each, walk its Configuration Policies in sortorder. + - A Configuration Policy contributes only if its scope matches the endpoint. + - For each action name, FIRST MATCH WINS: once an action is decided it is not + overridden by a later, lower-priority policy. + +The result is an EffectivePolicy: a flat, resolved map of action name -> decision +(mode desired/gate, plus desiredstate/params), which is exactly what enforcement +and the allowlist need. +""" + +from .policyscope import evaluate_scope + + +class EffectiveAction: + """One resolved action decision for an endpoint.""" + + __slots__ = ('action', 'mode', 'desiredstate', 'params', 'intervalminutes', + 'businesspolicyid', 'configurationpolicyid') + + def __init__(self, action, mode, desiredstate, params, intervalminutes, + businesspolicyid, configurationpolicyid): + self.action = action + self.mode = mode + self.desiredstate = desiredstate + self.params = params + self.intervalminutes = intervalminutes + self.businesspolicyid = businesspolicyid + self.configurationpolicyid = configurationpolicyid + + def to_dict(self): + return { + 'action': self.action, + 'mode': self.mode, + 'desiredstate': self.desiredstate, + 'params': self.params, + 'intervalminutes': self.intervalminutes, + 'businesspolicyid': self.businesspolicyid, + 'configurationpolicyid': self.configurationpolicyid, + } + + +class EffectivePolicy: + """The folded, resolved policy for a single endpoint. + + actions maps action name -> EffectiveAction (first-match-wins already + applied). Convenience views expose the gate allowlist and desired-state set + that enforcement and the endpoints seam consume. + """ + + def __init__(self, actions): + self.actions = actions + + def allowed_actions(self): + """Set of action names permitted on this endpoint (gate + desired).""" + return set(self.actions.keys()) + + def desired_actions(self): + """action name -> EffectiveAction for mode == 'desired' only.""" + return {name: eff for name, eff in self.actions.items() + if eff.mode == 'desired'} + + def to_dict(self): + return {name: eff.to_dict() for name, eff in self.actions.items()} + + +def build_endpoint_context(computer): + """Resolve a Computer row into the context the scope evaluator needs. + + Returns {'computertypeid': int|None, 'appids': set[int]}. + """ + appids = set() + for link in getattr(computer, 'installedapps', None) or (): + if getattr(link, 'isactive', True) and link.appid is not None: + appids.add(link.appid) + return { + 'computertypeid': getattr(computer, 'computertypeid', None), + 'appids': appids, + } + + +def _iter_matching_configpolicies(business_policies, context): + """Yield (businesspolicyid, ConfigurationPolicy) in deterministic order. + + Business Policies by (priority, businesspolicyid); their items by sortorder. + Only active policies whose scope matches the context are yielded. + """ + ordered_bp = sorted( + business_policies, + key=lambda bp: (bp.priority, bp.businesspolicyid), + ) + for business_policy in ordered_bp: + if not business_policy.isactive: + continue + for item in sorted(business_policy.items, + key=lambda it: (it.sortorder, it.businesspolicyitemid)): + config_policy = item.configurationpolicy + if config_policy is None or not config_policy.isactive: + continue + if evaluate_scope(config_policy.scopejson, context): + yield business_policy.businesspolicyid, config_policy + + +def build_effective_policy(computer, business_policies): + """Fold matching policies into an EffectivePolicy for one endpoint. + + computer: the Computer row. business_policies: iterable of BusinessPolicy + (with items/configurationpolicy loaded). Pure given its inputs so it stays + unit-testable and reusable by an alternate assembly engine. + """ + context = build_endpoint_context(computer) + resolved = {} + for businesspolicyid, config_policy in _iter_matching_configpolicies( + business_policies, context): + for action_row in config_policy.actions: + if not action_row.isactive: + continue + name = action_row.action + if name in resolved: + # First match wins - a higher-priority policy already decided + # this action; later policies do not override it. + continue + resolved[name] = EffectiveAction( + action=name, + mode=action_row.mode, + desiredstate=action_row.desiredstate, + params=action_row.params, + intervalminutes=action_row.intervalminutes, + businesspolicyid=businesspolicyid, + configurationpolicyid=config_policy.configurationpolicyid, + ) + return EffectivePolicy(resolved) + + +def effective_policy_for(assetid): + """Resolve the effective policy for an endpoint by its assetid. + + This is the public entry point the endpoints plugin calls. It returns an + empty EffectivePolicy when the feature is off or the asset is not a known + computer, so callers get a safe "no policy" answer without special-casing. + + NOTE for GE-Enforce interop: this function is the seam. To hand policy + assembly to GE-Enforce later, replace the body below (or dispatch on source) + while keeping this signature and the EffectivePolicy return type intact. + """ + from ..policyflag import policy_flag_enabled + if not policy_flag_enabled(): + return EffectivePolicy({}) + + from ..models import Computer, BusinessPolicy + + computer = Computer.query.filter_by(assetid=assetid).first() + if computer is None: + return EffectivePolicy({}) + + business_policies = BusinessPolicy.query.filter_by(isactive=True).all() + return build_effective_policy(computer, business_policies) diff --git a/plugins/computers/services/policyscope.py b/plugins/computers/services/policyscope.py new file mode 100644 index 00000000..049c272d --- /dev/null +++ b/plugins/computers/services/policyscope.py @@ -0,0 +1,96 @@ +"""Boolean scope evaluation for Configuration Policies. + +A policy's scopejson is a small typed boolean AST over two endpoint dimensions, +PC type and installed application. This module is the ONLY place that interprets +that AST: it validates the shape (known node types, bounded depth) and evaluates +a validated tree against a resolved endpoint context. + +Node grammar (all dict nodes carry a "type"): + {"type": "and", "nodes": [, ...]} + {"type": "or", "nodes": [, ...]} + {"type": "not", "node": } + {"type": "pctype", "id": } + {"type": "app", "id": } + {"type": "true"} # matches everything (an unscoped/global policy) + +Kept pure and dependency-free (no db, no Flask) so it is trivially unit-tested +and so a future GE-Enforce assembly engine can reuse the exact same evaluator on +the exact same AST when it composes policies. +""" + +# Bound the tree so a hand-crafted or imported AST cannot cause pathological +# recursion. Ten levels is far past any policy a human would author. +MAX_SCOPE_DEPTH = 10 + +_LOGICAL = {'and', 'or', 'not'} +_LEAF = {'pctype', 'app', 'true'} + + +class ScopeError(ValueError): + """Raised when a scope AST is malformed. Carries a human-readable reason.""" + + +def validate_scope(node, _depth=0): + """Validate a scope AST, raising ScopeError on any problem. + + Returns None on success. Call this at write time (CRUD) so a bad tree never + reaches the database; evaluate_scope then trusts the stored shape. + """ + if _depth > MAX_SCOPE_DEPTH: + raise ScopeError(f'scope nested deeper than {MAX_SCOPE_DEPTH} levels') + if not isinstance(node, dict): + raise ScopeError('scope node must be an object') + + ntype = node.get('type') + if ntype not in _LOGICAL and ntype not in _LEAF: + raise ScopeError(f'unknown scope node type: {ntype!r}') + + if ntype in ('and', 'or'): + nodes = node.get('nodes') + if not isinstance(nodes, list) or not nodes: + raise ScopeError(f'{ntype} requires a non-empty "nodes" list') + for child in nodes: + validate_scope(child, _depth + 1) + elif ntype == 'not': + child = node.get('node') + if child is None: + raise ScopeError('not requires a "node"') + validate_scope(child, _depth + 1) + elif ntype in ('pctype', 'app'): + ident = node.get('id') + if not isinstance(ident, int) or isinstance(ident, bool): + raise ScopeError(f'{ntype} requires an integer "id"') + # 'true' carries no payload. + + +def evaluate_scope(node, context): + """Evaluate a validated scope AST against an endpoint context. + + context is expected to expose: + context['computertypeid'] -> int or None + context['appids'] -> set/collection of installed appids + + Returns True when the endpoint falls in scope. An empty/None scope means the + policy is unscoped and matches everything (same as {"type": "true"}), which + keeps a freshly-created policy usable before a scope is drawn. + """ + if node is None: + return True + + ntype = node['type'] + if ntype == 'true': + return True + if ntype == 'and': + return all(evaluate_scope(child, context) for child in node['nodes']) + if ntype == 'or': + return any(evaluate_scope(child, context) for child in node['nodes']) + if ntype == 'not': + return not evaluate_scope(node['node'], context) + if ntype == 'pctype': + return context.get('computertypeid') == node['id'] + if ntype == 'app': + return node['id'] in (context.get('appids') or ()) + + # validate_scope should have caught this; be strict rather than silently + # matching an endpoint against an unknown node. + raise ScopeError(f'unknown scope node type at evaluation: {ntype!r}') diff --git a/plugins/endpoints/AGENT-CONTRACT.md b/plugins/endpoints/AGENT-CONTRACT.md new file mode 100644 index 00000000..6c6b2764 --- /dev/null +++ b/plugins/endpoints/AGENT-CONTRACT.md @@ -0,0 +1,274 @@ +# Endpoint agent contract (C# build reference) + +Companion to DESIGN-DECISIONS-agent.md. This is the concrete wire contract the +future C# agent targets, derived from `EndpointsPlugin.get_collector_schema()` +and the generic collector dispatcher in `shopdb/core/api/collector.py`. When the +schema changes, update this file in the same change. + +## 1. Transport + +- Method/URL: `POST {baseurl}/api/collector/endpoints` +- Auth header: `X-API-Key: ` (Bearer token also accepted). The + key is a UI-managed, endpoints-only service token minted from Settings -> + Endpoint agent key (section 6). A bootstrap `.env` collector key still works + until the first UI key is generated, then it is switched off (section 6). No + querystring fallback. +- Body: `application/json`, a single flat object (NOT an array). One POST per + report per endpoint. +- Identity field: `hostname` (required, case-insensitive). + +## 2. Request payload + +Only `hostname` is required. Every other field is optional; the server upserts +only the keys that are present, so a lightweight report can omit inventory +arrays. `adopt` is first-contact only. + +```jsonc +{ + "hostname": "SHOP-PC-014", // required, resolved to computers.hostname + "adopt": true, // first run ONLY; delegates PC creation to computers plugin + "loggedinuser": "j.smith", + "serialnumber": "5CD1234ABC", // adoption subset (forwarded to computers) + "pctype": "CNC Controller", // adoption subset + "machinenumber": "12345", // adoption subset; "" or "9999" == none + "agentversion": "1.0.0", + "lastboottime": "2025-01-14T06:12:00Z", // ISO-8601 UTC + "uptimeseconds": 86400, + "idleseconds": 120, + "cpupercent": 12.5, + "memorypercent": 47.0, + "services": [ /* opaque array, stored as JSON */ ], + "ports": [ /* opaque array */ ], + "interfaces": [ + { + "alias": "Intel(R) Ethernet Connection", // adapter description + "ipv4": "10.20.30.40", // "" if none + "mac": "AA:BB:CC:DD:EE:FF", + "gateway": "10.20.30.1", // "" for the static machine-IPS NIC + "subnet": "255.255.255.0", + "dhcp": false + } + ], + "scheduledtasks": [ /* opaque array */ ], + "events": [ + { + "eventid": 6008, // required per item; non-catalog IDs are dropped, not errored + "eventchannel": "System", + "eventsource": "EventLog", + "eventtime": "2025-01-14T06:11:58Z", + "eventmessage": "The previous system shutdown was unexpected.", + "fields": { "any": "extra" } // opaque object, stored as JSON + } + ] +} +``` + +Notes: +- `adopt=true` is sent ONLY by the installer's first run. Steady-state reports + omit it (or send `false`). After adoption, an unknown hostname is a hard + validation error again (DESIGN-DECISIONS-agent.md 1a). +- The adoption subset (`serialnumber`, `pctype`, `machinenumber`, plus + `loggedinuser`) is forwarded to the `computers` collector so the newly created + PC is not a bare shell. Send them on first contact. +- `services`/`ports`/`interfaces`/`scheduledtasks` are stored verbatim as JSON; + the server does not validate their inner shape yet. Keep them stable so the UI + can render them later. `interfaces` reports EVERY hardware NIC (not just the + primary) so the gatewayless static NIC that links a PC to its machine's IPS is + preserved (DESIGN-DECISIONS-agent.md 9.3); each entry is + `{alias, ipv4, mac, gateway, subnet, dhcp}`. +- `machinenumber` is discovered on-box by the agent from the eDNC registry + (`HKLM\SOFTWARE\[WOW6432Node\]GE Aircraft Engines\DNC\General:MachineNo`) or + the UDC `udc_settings_*.json` (`GeneralSettings.MachineNumber`), in that order + (DESIGN-DECISIONS-agent.md 9.4/9.5). `""` or `"9999"` means none. + +## 3. Response + +Success (HTTP 200), ADR-006 envelope: + +```jsonc +{ + "status": "ok", + "action": "created", // "created" first time, "updated" thereafter + "assetid": 4821, + "identityvalue": "SHOP-PC-014", + "warnings": [ + "adopted new computer for hostname: 'SHOP-PC-014'" // present only on adoption + ] +} +``` + +Validation failure (HTTP 400) - e.g. unknown hostname with `adopt` absent: + +```jsonc +{ "status": "error", "error": { "code": "VALIDATION_ERROR", + "message": "could not resolve hostname to a computer: 'SHOP-PC-014'" } } +``` + +Agent handling: treat non-200 as retryable except a `VALIDATION_ERROR` that +names an unresolved hostname on a NON-first-contact report - that means the PC +was removed server-side; surface it, do not spin. + +## 4. C# DTOs + +Match the JSON keys EXACTLY (lowercase, no underscores) to satisfy the naming +rule; use attributes so C# properties can stay PascalCase without a camelCase +serializer changing the wire keys. + +```csharp +using System.Text.Json.Serialization; + +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("machinenumber")] public string? MachineNumber { get; set; } + [JsonPropertyName("agentversion")] public string? AgentVersion { get; set; } + [JsonPropertyName("lastboottime")] public DateTimeOffset? LastBootTime { get; set; } + [JsonPropertyName("uptimeseconds")] public long? UptimeSeconds { get; set; } + [JsonPropertyName("idleseconds")] public long? IdleSeconds { get; set; } + [JsonPropertyName("cpupercent")] public double? CpuPercent { get; set; } + [JsonPropertyName("memorypercent")] public double? MemoryPercent { get; set; } + [JsonPropertyName("services")] public object[]? Services { get; set; } + [JsonPropertyName("ports")] public object[]? Ports { get; set; } + [JsonPropertyName("interfaces")] public object[]? Interfaces { get; set; } + [JsonPropertyName("scheduledtasks")]public object[]? ScheduledTasks { get; set; } + [JsonPropertyName("events")] public EndpointEventRecord[]? Events { get; set; } +} + +public sealed class EndpointEventRecord +{ + [JsonPropertyName("eventid")] public int EventId { get; set; } + [JsonPropertyName("eventchannel")] public string? EventChannel { get; set; } + [JsonPropertyName("eventsource")] public string? EventSource { get; set; } + [JsonPropertyName("eventtime")] public DateTimeOffset? EventTime { get; set; } + [JsonPropertyName("eventmessage")] public string? EventMessage { get; set; } + [JsonPropertyName("fields")] public Dictionary? Fields { get; set; } +} + +public sealed class CollectorResponse +{ + [JsonPropertyName("status")] public string Status { get; set; } = ""; + [JsonPropertyName("action")] public string? Action { get; set; } + [JsonPropertyName("assetid")] public int? AssetId { get; set; } + [JsonPropertyName("identityvalue")] public string? IdentityValue { get; set; } + [JsonPropertyName("warnings")] public string[]? Warnings { get; set; } +} +``` + +Serializer settings: `DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull` +so omitted fields are not sent (server upserts only present keys). Send UTC ISO-8601. + +## 5. "Build site agent" settings flow (build cadence) + +Goal: an operator in ShopDB Settings produces an instance-locked `.exe` that +knows its server URL and its collector key, needing no manual config on the +client. + +1. Settings -> "Build site agent": operator picks/creates a collector-scoped + managed token (the same token accepted by `X-API-Key` on + `/api/collector/endpoints`). This binds the build to one ShopDB instance. +2. Server stamps a small config (base URL + token + reporting cadence) into the + agent build artifact. Instance-locked = the token only works against that + server, and the server can revoke it to kill a leaked build. +3. First run on a client: + - Collect the ShopDB PowerShell asset-collector data set (minus the CSV + emit): hostname, logged-in user, serial number, pc type, machine number, + boot time, plus the curated event tail. + - POST once with `adopt=true`. The endpoints plugin delegates PC creation to + the `computers` collector, then writes the first `EndpointState` in the + same commit. The endpoint appears in the Fleet UI immediately - no second + call, no separate enrollment endpoint. +4. Subsequent runs POST on the configured cadence with `adopt` omitted; strict + hostname resolution applies from then on. + +Open item (from DESIGN-DECISIONS-agent.md 1a): whether `adopt` stays a +per-report flag or is promoted to a site setting (strict vs self-adopting +fleet). Default proposed: agent sends `adopt=true` on first run only. + +## 6. UI-managed agent key (kill switch) + +The endpoint agent authenticates with a single UI-managed key scoped to ONLY +`collector.ingest.endpoints`, so rotating or revoking it stops all +endpoint-agent traffic without affecting the computers or geenforce collectors. + +- Settings -> Endpoint agent key (admin only) generates the key. The full secret + is shown ONCE at generation and stored only as a hash afterwards; configure it + as the agent `X-API-Key`. +- Generating a key is a hard cutover: any previous endpoint key is revoked in the + same request, and the bootstrap `.env` fallback for endpoints is switched off + (`plugin.endpoints.envkeyfallback` -> false), so the managed key becomes the + sole credential. +- Regenerate to rotate (old key dies immediately). Revoke to stop all + endpoint-agent reporting until a new key is generated - the kill switch for a + leaked agent. +- Backend: `GET/POST /api/endpoints/agentkey`, `POST + /api/endpoints/agentkey/regenerate`, `DELETE /api/endpoints/agentkey`. These + use the `mint_service_token`/`list_service_tokens`/`revoke_service_tokens` + contract helpers (shopdb.api, contract 0.22.0). + +The bootstrap `.env` key (`COLLECTOR_API_KEY[_ENDPOINTS]`) remains only as a +first-run path before a UI key exists; it cannot be the runtime kill switch +because container env is injected at boot and cannot be rotated live. + +## 7. Command channel (WebUI -> agent) + +The WebUI can send an endpoint an ALLOWLISTED command (never an arbitrary +command string). Delivery is agent-initiated long-poll, so shop-floor PCs open +no inbound port: the agent makes an outbound request the server holds open until +a command is queued, runs it locally, and posts the result back. All three +routes reuse the same endpoints collector key (section 6) via `X-API-Key`, so +the command channel shares the kill switch and adds no second secret. + +Allowlisted actions (server source of truth: `plugins/endpoints/commands.py`): + +- `mtconnect_start` - run the MTConnect UDC agent start script + (`C:\ProgramData\UDC\MTConnect_UDC\Agent\run.bat`). +- `mtconnect_stop` - stop the running MTConnect UDC agent process. +- `mtconnect_status` - report whether the MTConnect UDC agent is running. + +### 7.1 Claim the next command (long-poll) + +`GET {baseurl}/api/endpoints/commands/next?hostname=` + +- Auth: `X-API-Key` (endpoints collector key). +- The server holds the request open up to ~25s. If a command is queued it is + atomically claimed (marked running) and returned; only one poll ever claims a + given command. +- Command queued (HTTP 200): + +```jsonc +{ "status": "ok", "data": { "endpointcommandid": 91, "action": "mtconnect_start" } } +``` + +- Nothing queued within the hold window: HTTP 204 (no body). Re-poll. + +### 7.2 Report a command result + +`POST {baseurl}/api/endpoints/commands//result` + +- Auth: `X-API-Key` (endpoints collector key). +- Body: + +```jsonc +{ + "succeeded": true, // required; false marks the command failed + "message": "MTConnect started", // short summary, optional (<= 2000 chars) + "mtconnectrunning": true, // optional; observed run state after the action + "detail": { "any": "extra" } // optional structured detail, stored as JSON +} +``` + +- When `mtconnectrunning` is present the server caches it on the endpoint's + `EndpointState` (`mtconnectrunning` + `mtconnectcheckedat`) so the WebUI toggle + shows a real state between polls. Every `mtconnect_*` action SHOULD probe and + report the resulting run state so the toggle never lies. + +### 7.3 Enqueue (WebUI side, for reference) + +`POST /api/endpoints//commands` with `{ "action": "mtconnect_start" }`, +gated by user JWT + `endpoints.command`. Only one command may be in flight per +endpoint (a second enqueue returns HTTP 409 until the first finalizes). + diff --git a/plugins/endpoints/DESIGN-DECISIONS-agent.md b/plugins/endpoints/DESIGN-DECISIONS-agent.md new file mode 100644 index 00000000..ab50920b --- /dev/null +++ b/plugins/endpoints/DESIGN-DECISIONS-agent.md @@ -0,0 +1,443 @@ +# Agent Design Decisions (running preference list) + +Server-side decisions made while building the `endpoints` plugin that the +future C# agent (and the ServiceNow ticketing surface) must honor. This is a +living document: add to it as the agent build raises questions. ASCII only, no +em-dashes, no emojis, per repo style. + +## Scope of this document + +The `endpoints` plugin (server) is the authoritative contract. The C# agent is +a separate deliverable in its own solution/repo. Anything the agent sends must +match what this plugin ingests; when the two disagree, this plugin wins and the +agent changes. + +## 1. Transport and identity + +- Agent posts one JSON object per host to `POST /api/collector/endpoints` + (the generic ADR-006 collector route, auto-registered by the core loader). +- Auth is a collector-scoped credential in the request HEADER, not a user JWT + and not a querystring. Server reads `COLLECTOR_API_KEY_ENDPOINTS` first, then + falls back to `COLLECTOR_API_KEY`. Provision the key the same way the + computers reporter does: out of band, e.g. `HKLM:\SOFTWARE\GE\ShopDB + CollectorKey`. The key is never baked into a distributed script/binary. +- Identity field is `hostname` (case-insensitive). The server resolves it to an + existing computer asset via the `computers` plugin. In STEADY STATE an unknown + hostname is REJECTED with a validation error, never auto-created by this + plugin. Decision: the fleet is provisioned in ShopDB first (imports/Azure + device sync) OR adopted at first contact via the delegation shim in section + 1a; the `endpoints` plugin itself does not get to invent assets. + +## 1a. First-contact adoption (delegation shim, not a design reversal) + +The agent installer's first run reaches a PC that may not exist in ShopDB yet. +Rather than letting `endpoints` create assets (which would break ADR-001 and the +section 1 rule that this plugin never invents assets), first contact DELEGATES +adoption to the `computers` collector, which is already the sole authoritative +creator of computer Assets and PC rows. + +Decision: + +- The `endpoints` plugin NEVER constructs an `Asset` or a `Computer` row itself. + Section 1 stands. This is a clarification, not a reversal. +- When `apply_collector_payload` cannot resolve the hostname AND the payload + opts in to adoption (see the `adopt` flag below), the plugin calls the + `computers` plugin's `apply_collector_payload` with the PC-identifying subset + of the payload, flushes, then re-resolves the hostname and proceeds with + telemetry. All of this runs inside the ONE transaction the generic collector + dispatcher owns (`shopdb/core/api/collector.py`): a `ValueError` still rolls + the whole thing back, so a half-adopted PC can never be committed. +- Steady-state reports send `adopt=false` (or omit it once a site chooses strict + mode); only the installer's first run sends `adopt=true`. After adoption the + strict "must already resolve" invariant holds for every subsequent report. + +First-contact payload (the PC-identifying subset the shim forwards to the +`computers` collector - every field except `hostname` is optional but recommended +so the adopted PC is not a bare shell): + +- `hostname` (required), `loggedinuser`, `serialnumber`, `pctype`, + `machinenumber` (only when the PC is bound to a machine; `''`/`'9999'` are + treated as none by the computers collector). + +Net effect for the agent: + +- ONE POST to `/api/collector/endpoints` with `adopt=true` on first run creates + the Asset + PC (via the shim) and writes the first `EndpointState` in the same + commit, so the endpoint appears in the UI immediately (no second call, no + separate enrollment endpoint). +- OPEN: whether to keep `adopt` per-report or promote it to a site setting + (strict vs. self-adopting fleet). Default proposed: adoption allowed, agent + sends `adopt=true` only on first run. + +## 2. Report shape (current server contract) + +Top-level payload fields the server currently reads (see +`plugins/endpoints/plugin.py` `get_collector_schema`): + +- `hostname` (required), `loggedinuser`, `agentversion` +- Metrics: `lastboottime` (ISO-8601), `uptimeseconds`, `idleseconds`, + `cpupercent`, `memorypercent` +- Inventory arrays (stored as latest-snapshot JSON, not history): + `services`, `ports`, `interfaces`, `scheduledtasks` +- `events`: array of curated Windows Event Log records (see section 4) + +Decision: inventory and metrics are a SNAPSHOT overwrite (one `EndpointState` +row per asset), not time-series. The agent sends the full current list each +cycle; the server does not diff or version them. If per-sample history is later +required, it goes in a new append-only table and the contract stays additive. + +## 3. Reporting rhythm + +- Default report interval: 15 minutes (`report_interval_minutes` in the plugin + manifest settings; a site setting can override). Agent design should make the + interval configurable and jittered to avoid a fleet-wide thundering herd. +- There is NO separate heartbeat. `lastreportedat` is stamped on every report; + "quiet" endpoints are those past the quiet-report window (default 24h, site + setting `endpoints_quietreporthours`). Agent does not need a distinct ping. + +## 4. Curated Windows Event catalog (LOCKED to the server list) + +The server stores ONLY these IDs; anything else in `events` is ignored (not an +error). Source of truth: `CURATED_EVENT_IDS` in `plugins/endpoints/plugin.py`. + +| Event ID | Channel | Meaning | +|----------|--------------|--------------------------------------------| +| 4624 | Security | Successful logon | +| 4625 | Security | Failed logon | +| 4634 | Security | Logoff | +| 4672 | Security | Special privileges assigned to new logon | +| 4697 | Security | Service installed | +| 4698 | Security | Scheduled task created | +| 41 | System | Kernel-Power, unexpected shutdown | +| 4618 | Security | Monitored security event pattern | +| 55 | System | NTFS file system corruption | +| 7011 | System | Service Control Manager timeout | +| 10016 | System | DCOM permission error | +| 1001 | Application | Application error / bugcheck report | + +- Per event, the agent sends: `eventid` (required), `eventchannel`, + `eventsource`, `eventtime` (ISO-8601, event time not report time), + `eventmessage`, and `fields` (an object of extracted structured values, e.g. + target account on a 4625). `fields` is stored as `payloadjson`. +- OPEN QUESTION for the agent build: which IDs need structured `fields` parsing + vs. raw message pass-through. Start with pass-through message; add structured + parsing per ID as alerting rules demand it. The teammate flagged event parsing + as the hard part; keep the catalog fixed and small until baselined. +- Scheduled-task history being disabled on some hosts means 4698 and task + "last run" may be sparse; the agent should read current task state directly, + not rely on event history alone. + +## 5. Monitored services/processes + +Known targets to watch (non-exhaustive, confirm during agent build): UDC, eDnc, +Intune-related services. Server stores whatever list the agent sends in +`servicesjson` without a fixed schema yet. Decision deferred: once the watched +set is final, consider promoting it to a server-known enum for alerting. + +## 6. ServiceNow ticketing surface (future, creds confirmed available) + +Not built server-side yet. Recorded here so the agent and a future server +endpoint agree: + +- Interactive C# piece (system-tray/desktop launcher) owns the 5-click-max flow: + network precheck -> issue dropdown -> optional screenshot -> submit. +- Fixed dropdown reasons (from the source conversation): Can't logon to CSF; + Data collections down; Can't download program; Machine communication failing; + Network drives down; Other (free text). +- Screenshot saved to local `Pictures\Screenshots` before submission. +- OPEN: whether ticket submission goes direct to the ServiceNow API from the + agent, or is proxied through a ShopDB server endpoint (preferred: proxy, so + ServiceNow creds live server-side and the agent carries only its collector + key). Decide before wiring the integration. + +## 7. Runtime split (from the planning conversation) + +- Interactive bits (tray launcher, ticketing, screenshot): C# / .NET, likely a + Windows service + tray app. +- Collections MAY be locally hosted PowerShell scripts the service invokes, + where that is simpler than reimplementing WMI/CIM queries in C#. +- Code signing and Intune deployment are agent-side concerns, out of scope for + the server plugin but must be planned before fleet rollout. + +### 7a. Tray deployment and on-demand "Send Status" (built) + +The tray icon is a PER-USER process, not a service. A Windows service runs in +session 0 and cannot draw a tray icon in the operator's session, so the tray +ships as a second executable (`shopdb-endpoint-tray.exe`) that the installer +copies next to `shopdbagent.exe`, registers for logon autostart via the HKCU +Run key (`ShopDbEndpointTray`), and launches for the current user. Because the +Run key is per-user, the installer must be run from the operator's own session; +`-NoTray` yields a headless, service-only install. + +DECISION - "Send Status now" reuses `--once`, it does not reimplement collection. +The tray's Send Status action shells out to `shopdbagent.exe --once`, the same +production collect-and-report path the service uses on its cadence. Consequences: +the tray holds NO collector key (the agent exe reads it from registry/appsettings +itself), there is one collection code path to maintain, and an operator can force +the ShopDB monitor page to refresh immediately instead of waiting for the next +cadence tick. The tray surfaces the agent's exit code as a balloon tip (0 = +sent, non-zero = check the rolling agent log). Note: `--once` honors +`adoptonfirstrun`, so an on-demand send can carry `adopt=true`; the server dedups +on hostname, so this is safe. + +The ServiceNow ticket action stays a scaffold (section 6). A future desktop +shortcut to the same tray exe is the intended explicitly-launchable entry point +for that workflow; nothing about the Send Status wiring blocks it. + +## 8. Web UI display (server-side, built) + +The collected data surfaces through ShopDB's existing declaration-driven UI +contract (ADR-010). The plugin declares WHAT to show; generic core components +render it. No plugin-specific Vue components were written. Audience is DT +support / IT, NOT the shop-floor operator (the operator's surface is the +agent-side ticket tool). + +Surfaces implemented: + +- Per-PC asset panels (computer assets only), on the PC detail page: + - Endpoint status (keyvalue): logged-in user, uptime, idle, last boot, + CPU/memory %, agent version, last reported. Feeds + `GET /api/endpoints/asset/{assetid}/status`. + - Services and processes (list + badge): monitored services with a green/red + run-state badge. Feeds `.../services`. + - Recent events (list + severity badge): last 10 curated events. Feeds + `.../recentevents`. +- Dashboard exception cards (hide when empty): + - Endpoints gone quiet (warning): `.../dashboard/quiet`. + - Endpoints with crashes or shutdowns (critical): `.../dashboard/stability`, + bounded to a 48h window and the critical event catalog. + +DECISION - freshness and cost. There is NO live streaming and NO polling. The +freshness ceiling is the agent report cadence (~15 min), so a websocket/poll +buys nothing but load. Data is fetched on page/dashboard load only, matching the +existing computers-quiet card. Asset panels are single-asset indexed reads +(negligible). Dashboard cards run once per dashboard load, cap at 50 rows, and +the stability card is time-bounded so it does not grow with total history. If +fleet-wide aggregation ever gets heavy, memoize dashboard aggregates with the +core `cache` helper for ~60s - documented lever, not built yet. + +Agent implication: the agent does not drive any UI refresh. Its only job is to +report on its rhythm; the UI reflects whatever the last report wrote. The +`services` payload should send a per-service run STATE (e.g. running/stopped) so +the badge can color it; a missing/unknown state renders as a gray Unknown badge. + +## 9. Shop-floor collection knowledge base (reference, NOT a schema change) + +This section is a body of reference, not a change request. Nothing here alters +the shape of ShopDB as it stands. It records WHAT a shop-floor endpoint really +is, WHICH signals identify it, and WHY each signal matters to the enterprise, so +that whoever grows the collector understands the stakes behind each field before +touching the wire contract. The source material is the site's own extant +tooling: `scripts/site_imports/assetcollector/Collect-ShopDB.ps1` (the mature +CSV collector), the migration/assessment flow under `scripts/.temp` +(`Assess-Orchestrator.ps1`, `Setup_ShopPC.ps1`, `Setup_InspectionPC.ps1`, +`eDncRegToUDC.ps1`, `machinetypeselector.bat`), and the eDNC/UDC/MTConnect config +formats those scripts read. Treat it as the "why" that the terse contract in +sections 1 through 8 cannot carry. + +### 9.0 Why this matters beyond one plugin + +This collector is a wedge into an enterprise-wide body of knowledge. A shop-floor +PC is never just a PC: it is the human-facing half of a machine tool, the thing +that feeds NC programs to a controller and streams data back off it. Getting its +identity, hardware, network, and application picture right lets ShopDB answer +questions that today require a person to walk the floor: which controller does +this box talk to, is its data path healthy, is the hardware under warranty, what +is it actually running. Every signal below earns its place by answering one of +those questions. + +### 9.1 The endpoint taxonomy (how the floor classifies its own PCs) + +`machinetypeselector.bat` plus the two `Setup_*` scripts show the site already +thinks in three roles. The collector should be able to DERIVE the role from +on-box evidence rather than ask an operator: + +- Shop PC: runs UDC (Universal Data Collector) and/or eDNC, wired to one CNC + machine (FANUC / Siemens / Okuma controller). This is the ~2/3 majority. +- Inspection PC: metrology stack. Marked by `C:\GE Aerospace\Genspect\genspect.true` + (a flag file `Setup_InspectionPC.ps1` writes) and/or `C:\GE Aerospace\Gagecal` + (GageCal 6.4). +- Non-shop-floor PC: office box, no machine link, minimal shop apps. + +Role is derivable from application/marker presence (section 9.4), which is a more +honest signal than the raw WMI `Model` string the current `pctype` probe sends. + +### 9.2 Hardware facts, and WHY + +`Collect-ShopDB.ps1 :: Get-PcFacts` reads `Win32_ComputerSystem`, +`Win32_OperatingSystem`, and `Win32_BIOS` for: vendor/manufacturer, model, OS +caption, serial (BIOS serial first, ComputerSystem serial as fallback), last +boot, logged-in user. + +Why hardware is not cosmetic: + +- Dell Warranty API linkage. Vendor + model family + service tag (BIOS serial) + is exactly the tuple the Dell Warranty API keys on. Capturing it lets ShopDB + resolve warranty status and hardware line remotely, so a support tech can + diagnose a hardware-level fault (failing disk, EOL platform, out-of-warranty + chassis) without physically finding the machine. The serial is the join key; + the model family is the diagnostic context. +- Remote hardware diagnosis generally. Knowing the exact hardware line turns + "some PC is flaky" into "this is a known-bad model/BIOS combination," which is + an enterprise-scale pattern once the fleet reports uniformly. + +Collection note: prefer BIOS `SerialNumber` (the Dell service tag) and capture +manufacturer + model as distinct fields, since the warranty join needs the raw +service tag, not a normalized `pctype`. + +### 9.3 Network interfaces, and WHY (the second-NIC reality) + +`Get-PcFacts` and `Assess-Orchestrator.ps1 :: Get-EthernetNetworkSnapshot` +enumerate hardware adapters only and capture alias, IPv4, MAC, gateway, and +subnet (the orchestrator even reconstructs the mask from prefix length). + +Why the interface list is first-class, not trivia: + +- The second interface IS the machine link. Upwards of two-thirds of shop-floor + PCs have a SECOND network interface with a STATIC, manually mapped IP address + whose sole purpose is to talk to the related machine's onboard IPS + (industrial PC / controller network). That private NIC is physical evidence of + the PC-to-machine relationship and often the only place the machine-side + address is recorded. Losing it loses the wiring diagram of the floor. +- MAC as stable identity. MAC is the most stable hardware identifier across + re-image and rename, which is why every extant script grabs it. It is the + durable anchor when hostname/asset number churn during migration. + +Collection note: send ALL hardware interfaces (alias, IPv4, MAC, and where +available gateway/subnet), not just the primary. The primary/gateway NIC is the +corporate LAN; the gatewayless static NIC is very likely the machine IPS link and +is the interesting one. + +### 9.4 Installed applications, and WHY (the extrapolation engine) + +`Get-PcFacts` reads the registry Uninstall keys (name, version, publisher, +skipping `SystemComponent`). On these boxes the installed-app set is small and +highly diagnostic: they primarily run a handful of known apps. + +Why the app inventory is the richest signal we have: + +- Apps imply role (section 9.1): UDC/eDNC => Shop PC; Genspect/GageCal => + Inspection PC; neither => office. +- Apps imply the MACHINE binding. This is the crux. If UDC or eDNC (or MTConnect) + is installed, its config file carries the four-digit number ShopDB calls the + Machine Number, and from it we can extrapolate the ShopDB `machines` entry the + PC should be related to. The same value appears under different names across + the app generations: + - eDNC `.reg` / live registry: `MachineNo` (under + `HKLM\SOFTWARE\[WOW6432Node\]GE Aircraft Engines\DNC\General`). + - UDC `udc_settings_*.json`: `GeneralSettings.MachineNumber`. + - MTConnect: workstation / workstation ID / MachineNo, carried in the + adapter/agent config under the fixed directory tree documented in section + 9.9. + Treat "MachineNo", "workstation", "workstation ID", and "MachineNumber" as the + same concept: the ShopDB Machine Number. Version and publisher are worth + keeping too (they date the app generation, which tells you which config format + to parse). + +Collection note: the app inventory is not just an asset list; it is the pointer +that tells the collector WHICH config file to open next to find the machine +binding. + +### 9.5 The machine binding (DNC/UDC/MTConnect config), and WHY + +`Collect-ShopDB.ps1 :: Resolve-DncSource` resolves the machine config with a +SOURCE-PRIORITY chain, then three parsers normalize the formats into one shape: + +- Priority: explicit JSON path -> explicit `.reg` path -> live registry + (`HKLM\...\GE Aircraft Engines\DNC`) -> local `.reg` export -> local + `udc_settings_*.json`. This ordering makes one probe work across both the + eDNC-era and UDC-era boxes. +- Parsers: `ConvertFrom-RegExport` (parses exported `.reg`, including + `dword:` decoding), `ConvertFrom-LiveRegistry`, `ConvertFrom-UdcJson`. +- Extracted: machine number, site, control type, DNC type, and connection detail + (TCP address/port for ethernet; COM port/baud for serial). +- Controller vendor is DERIVED, not stored raw: `Resolve-ControllerVendor` maps + `NcIF = EFOCAS -> FANUC`, `Cnc = SIEMENS -> Siemens`, `Cnc = OKUMA -> Okuma`. + `eDncRegToUDC.ps1` confirms the same mapping and the eFocas/PPDCS key layout. + +Why it matters: this binding is what upgrades a bare endpoint into "the PC that +controls machine 1234 over eFOCAS at 10.x.x.x." That relationship is the point of +the whole exercise; the hardware and app facts are supporting evidence for it. + +### 9.6 Printers, and WHY + +`Assess-Orchestrator.ps1 :: Get-PrinterSnapshot` / `Get-PrinterPortSnapshot` +capture name, driver, port, host address, shared/published, and the default +flag, filtering out virtual outputs (Print to PDF, XPS, Fax, OneNote). Shop and +inspection PCs are tied to specific physical/label printers, so the printer set +is part of a workstation's operational identity and a common migration break +point (a replaced PC that lost its label printer is a floor stoppage). + +### 9.7 Patterns from the extant tooling worth carrying forward + +Two engineering habits in these scripts are worth honoring in the collector, +because they encode hard-won migration lessons: + +- Field-level idempotent merge (`Merge-CsvStore`): an empty incoming cell NEVER + overwrites a previously captured value; later partial runs fill gaps without + wiping earlier data. The server's endpoints ingest already mirrors this instinct + (`if 'field' in payload`), and it should stay that way: a partial report means + "leave the rest alone," not "clear it." +- Normalized intermediate shape + sidecars: heterogeneous sources (registry, + `.reg`, JSON, WMI) all converge on one normalized record before import, with + phase-2 data (interfaces, installed apps, PC-to-machine link) written as JSON + sidecars. The same decoupling lets a future non-agent source (Intune/Azure + `Get-MgDevice`, a MES/CNC database) feed the same model without a new pipeline. + +### 9.9 MTConnect footprint, and WHY (three parallel stacks per box) + +Source: `scripts/.temp/Install_MTConnect_ExisDir.ps1`, +`Install_MTConnect_ExisDir_BatConvert.ps1`, its usage guide +`Guide-Install_MTConnect_ExisDir_BatConvert.md`, and +`Firewall_Rules_MTConnect_All.bat`. MTConnect is the standardized data-off-the- +machine layer, and the install scripts reveal its exact on-disk shape, which is +what a collector needs to detect it reliably. + +The critical structural fact: an endpoint can host up to THREE parallel MTConnect +stacks, one per data path, each an Adapter (talks to the machine) plus an Agent +(serves the MTConnect HTTP feed): + +- Machine-direct: `C:\MTConnect\Adapter` + `C:\MTConnect\Agent` (Agent exe + `C:\MTConnect\Agent\Agent.exe`). +- eDNC-fed: `C:\MTConnect_eDNC\Adapter` + `C:\MTConnect_eDNC\Agent` + (`...\Agent\agent.exe`). The eDNC adapter is `DNCAdapter.exe` driven by + `DNCAdapter.ini`, tying this path back to the eDNC config world in section 9.5. +- UDC-fed: `C:\ProgramData\udc\mtconnect_udc\agent` (`...\agent\agent.exe`, + launched via `run.bat`). This binds MTConnect to the UDC install and therefore + to the same `udc_settings_*.json` that carries `GeneralSettings.MachineNumber`. + +Why the collector should care about these paths: + +- Presence is role/path evidence. Which of the three trees exist tells you HOW the + machine's data is being lifted (direct vs eDNC vs UDC), which is a real + operational distinction, not a cosmetic one. +- The MTConnect Agent is a network service. The firewall bat and the + `Ensure-FirewallProgramRule` calls open inbound rules named + `Allow_MTConnect_Machine`, `Allow_MTConnect_UDC`, `Allow_MTConnect_eDNC` for + each `Agent.exe`. That means each agent listens on an HTTP port, so its live + MTConnect feed (which publishes a device UUID / station identity) is itself a + future data source the collector could probe, not just a process to count. +- Machine Number lives here too. Because the eDNC path is `DNCAdapter.ini` and the + UDC path is the UDC settings JSON, the MTConnect footprint is a second route to + the same four-digit Machine Number described in 9.4/9.5, cross-checkable against + the eDNC/UDC values. + +Collection note: detect MTConnect by the three directory trees, capture which +Agent exes exist and whether their service/process is running (section 5 already +covers MTConnect as a monitored service), and record the data path +(direct/eDNC/UDC). +(`Install_MTConnect_ExisDir_BatConvert.ps1`) is a deployment concern, not a +collection concern, but it explains why these `install.bat`/`start.bat` files +were rewritten to self-elevate without a `pause`: so they can run unattended +under DSC on locked-down machines. Do not treat a self-elevating batch as +tampering. + +### 9.8 Boundary (explicit) + +None of section 9 is a ShopDB schema proposal. Where a signal above has no home +in the current model, that is fine: it stays reference until a separate, deliberate +decision (its own ADR / contract bump) decides to store it. The value here is +comprehension: anyone extending the collector should know that hardware feeds +warranty, the second NIC is the machine link, and the installed apps are the +thread that leads to the Machine Number. Build the agent with that understanding; +do not let this document, by itself, move the schema. diff --git a/plugins/endpoints/__init__.py b/plugins/endpoints/__init__.py new file mode 100644 index 00000000..f33cbea9 --- /dev/null +++ b/plugins/endpoints/__init__.py @@ -0,0 +1,5 @@ +"""Endpoints plugin package.""" + +from .plugin import EndpointsPlugin + +__all__ = ['EndpointsPlugin'] diff --git a/plugins/endpoints/api/__init__.py b/plugins/endpoints/api/__init__.py new file mode 100644 index 00000000..6ad50266 --- /dev/null +++ b/plugins/endpoints/api/__init__.py @@ -0,0 +1,5 @@ +"""Endpoints plugin API.""" + +from .routes import endpoints_bp + +__all__ = ['endpoints_bp'] diff --git a/plugins/endpoints/api/routes.py b/plugins/endpoints/api/routes.py new file mode 100644 index 00000000..07355173 --- /dev/null +++ b/plugins/endpoints/api/routes.py @@ -0,0 +1,724 @@ +"""Endpoints plugin API endpoints (read + dashboard). + +Ingest is NOT here: agent reports arrive on the generic collector route +POST /api/collector/endpoints (ADR-006), authorized by a collector-scoped +service token, and are handled by EndpointsPlugin.apply_collector_payload. +These routes are the human-facing read surface, gated by user JWT + RBAC. +""" + +import json +import time +from datetime import datetime, timedelta, timezone + +from flask import Blueprint, request, current_app +from flask_jwt_extended import current_user + +from shopdb.api import ( + db, Asset, Setting, success_response, error_response, paginated_response, + ErrorCodes, get_pagination_params, paginate_query, require_permission, + require_role, mint_service_token, list_service_tokens, revoke_service_tokens, + AuditLog, collector_authorized, +) + +from ..models import EndpointState, EndpointEvent, EndpointCommand +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__) + + +def _naive_utcnow(): + return datetime.now(timezone.utc).replace(tzinfo=None) + + +def _format_uptime(seconds): + """Whole-seconds uptime -> 'Nd Nh Nm', or None.""" + if seconds is None: + return None + try: + seconds = int(seconds) + except (TypeError, ValueError): + return None + days, rem = divmod(seconds, 86400) + hours, rem = divmod(rem, 3600) + minutes = rem // 60 + parts = [] + if days: + parts.append('{}d'.format(days)) + if hours: + parts.append('{}h'.format(hours)) + parts.append('{}m'.format(minutes)) + return ' '.join(parts) + + +# ============================================================================= +# Endpoint state +# ============================================================================= + +@endpoints_bp.route('/', methods=['GET']) +@require_permission('endpoints.view') +def list_endpoints(): + """List reported endpoint states. + + Joins through Computer so each row carries computerid and can deep-link + into the existing PC detail page (/pcs/{computerid}). Endpoint state only + exists for hostnames resolved to a Computer, so the join is not lossy. + """ + from plugins.computers.models import Computer + + page, per_page = get_pagination_params(request) + + query = (db.session.query(EndpointState, Computer.computerid) + .join(Asset, Asset.assetid == EndpointState.assetid) + .join(Computer, Computer.assetid == EndpointState.assetid) + .filter(Asset.isactive.is_(True))) + + if search := request.args.get('search'): + query = query.filter(EndpointState.hostname.ilike(f'%{search}%')) + + query = query.order_by(EndpointState.lastreportedat.desc()) + + items, total = paginate_query(query, page, per_page) + data = [] + for state, computerid in items: + row = state.to_dict() + row['computerid'] = computerid + row['uptimedisplay'] = _format_uptime(state.uptimeseconds) + data.append(row) + + return paginated_response(data, page, per_page, total) + + +@endpoints_bp.route('/', methods=['GET']) +@require_permission('endpoints.view') +def get_endpoint(asset_id: int): + """Get the current state for one endpoint by asset id.""" + state = EndpointState.query.filter_by(assetid=asset_id).first() + + if not state: + return error_response( + ErrorCodes.NOT_FOUND, + f'No endpoint state for asset {asset_id}', + http_code=404, + ) + + return success_response(state.to_dict()) + + +@endpoints_bp.route('//events', methods=['GET']) +@require_permission('endpoints.view') +def list_endpoint_events(asset_id: int): + """List curated Windows events for one endpoint, newest first.""" + page, per_page = get_pagination_params(request) + + query = EndpointEvent.query.filter_by(assetid=asset_id) + + if eventid := request.args.get('eventid'): + try: + query = query.filter(EndpointEvent.eventid == int(eventid)) + except (TypeError, ValueError): + return error_response(ErrorCodes.VALIDATION_ERROR, + 'eventid must be an integer') + + query = query.order_by(EndpointEvent.eventtime.desc()) + + items, total = paginate_query(query, page, per_page) + data = [e.to_dict() for e in items] + + return paginated_response(data, page, per_page, total) + + +# ============================================================================= +# Asset panels (ADR-010) - shaped for the generic PluginAssetPanels renderer +# ============================================================================= + +@endpoints_bp.route('/asset//status', methods=['GET']) +@require_permission('endpoints.view') +def panel_status(asset_id: int): + """Keyvalue panel: the endpoint's current status snapshot. + + Single indexed lookup on endpointstates.assetid. Returns the {fields:[...]} + shape the keyvalue renderer expects; an absent state yields no fields so the + panel hides itself. + """ + state = EndpointState.query.filter_by(assetid=asset_id).first() + if not state: + return success_response({'fields': []}) + + def field(label, value, mono=False): + return {'label': label, 'value': value, 'mono': mono} + + fields = [ + field('Logged in user', state.loggedinuser), + field('Uptime', _format_uptime(state.uptimeseconds)), + field('Idle', _format_uptime(state.idleseconds)), + field('Last boot', + state.lastboottime.isoformat() + 'Z' if state.lastboottime else None), + field('CPU %', state.cpupercent), + field('Memory %', state.memorypercent), + field('Agent', state.agentversion, mono=True), + field('Last reported', + state.lastreportedat.isoformat() + 'Z' if state.lastreportedat else None), + ] + fields = [f for f in fields if f['value'] not in (None, '')] + return success_response({'fields': fields}) + + +@endpoints_bp.route('/asset//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//services', methods=['GET']) +@require_permission('endpoints.view') +def panel_services(asset_id: int): + """List panel: monitored services/processes with a run-state badge per row. + + Reshapes the latest servicesjson snapshot into rows the list renderer maps + to name + status badge. Tolerant of a missing or unexpected snapshot shape: + an unknown state renders as an Unknown badge rather than failing. + """ + state = EndpointState.query.filter_by(assetid=asset_id).first() + if not state or not state.servicesjson: + return success_response([]) + + try: + services = json.loads(state.servicesjson) + except (TypeError, ValueError): + return success_response([]) + + rows = [] + for entry in (services or []): + if isinstance(entry, dict): + name = entry.get('name') or entry.get('service') or 'Unknown' + raw = (entry.get('state') or entry.get('status') or '').strip().lower() + app = entry.get('app') + kind = entry.get('kind') + else: + name, raw, app, kind = str(entry), '', None, None + 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'), + } + targets = row_actions_for(app, kind, name) + if targets: + row['action'] = { + 'running': running, + 'start': targets['start'], + 'stop': targets['stop'], + 'status': targets['status'], + 'target': targets['target'], + 'targetkind': targets['targetkind'], + } + rows.append(row) + return success_response(rows) + + +@endpoints_bp.route('/asset//recentevents', methods=['GET']) +@require_permission('endpoints.view') +def panel_recent_events(asset_id: int): + """List panel: the endpoint's most recent curated events, newest first. + + Indexed range scan on (assetid, eventtime), capped small - this is the + at-a-glance history card, not the full report (that is the events route + with pagination). + """ + events = (EndpointEvent.query + .filter_by(assetid=asset_id) + .order_by(EndpointEvent.eventtime.desc()) + .limit(10) + .all()) + + rows = [{ + 'title': event_title(e.eventid), + 'severity': event_severity(e.eventid), + 'eventtime': e.eventtime.isoformat() + 'Z' if e.eventtime else None, + } for e in events] + return success_response(rows) + + +@endpoints_bp.route('/asset//interfaces', methods=['GET']) +@require_permission('endpoints.view') +def panel_interfaces(asset_id: int): + """Table panel: every hardware NIC the agent last reported. + + Reshapes the latest interfacesjson snapshot into one row per adapter with + the {alias, ipv4, mac, gateway, subnet, dhcp} shape the collector emits. A + gatewayless adapter is flagged as the likely machine-IPS link (see + DESIGN-DECISIONS-agent.md 9.3): those NICs speak to the machine, not the + corporate LAN. Tolerant of a missing or unexpected snapshot: an unreadable + payload yields no rows so the panel hides itself. + """ + state = EndpointState.query.filter_by(assetid=asset_id).first() + if not state or not state.interfacesjson: + return success_response([]) + + try: + interfaces = json.loads(state.interfacesjson) + except (TypeError, ValueError): + return success_response([]) + + rows = [] + for entry in (interfaces or []): + if not isinstance(entry, dict): + continue + gateway = (entry.get('gateway') or '').strip() + ipv4 = (entry.get('ipv4') or '').strip() + rows.append({ + 'alias': entry.get('alias') or 'Unknown', + 'ipv4': ipv4 or '-', + 'mac': (entry.get('mac') or '').strip() or '-', + 'gateway': gateway or '-', + 'subnet': (entry.get('subnet') or '').strip() or '-', + 'dhcp': 'DHCP' if entry.get('dhcp') else 'Static', + # A gatewayless NIC with an address is the machine link; the gateway + # NIC is the corporate LAN. + 'role': 'Machine link' if (ipv4 and not gateway) else 'LAN', + }) + return success_response(rows) + + +# ============================================================================= +# Dashboard +# ============================================================================= + +@endpoints_bp.route('/dashboard/summary', methods=['GET']) +@require_permission('endpoints.view') +def dashboard_summary(): + """High-level fleet counts for the endpoints dashboard.""" + total = db.session.query(EndpointState).join( + Asset, Asset.assetid == EndpointState.assetid + ).filter(Asset.isactive.is_(True)).count() + + hours = _quiet_hours() + cutoff = _naive_utcnow() - timedelta(hours=hours) + + reporting = db.session.query(EndpointState).join( + Asset, Asset.assetid == EndpointState.assetid + ).filter( + Asset.isactive.is_(True), + EndpointState.lastreportedat.isnot(None), + EndpointState.lastreportedat >= cutoff, + ).count() + + return success_response({ + 'total': total, + 'reporting': reporting, + 'quiet': total - reporting, + 'quiethours': hours, + }) + + +@endpoints_bp.route('/dashboard/quiet', methods=['GET']) +@require_permission('endpoints.view') +def dashboard_quiet(): + """Endpoints that reported before, then went quiet past the window. + + Never-reported endpoints are excluded: those are records without an agent, + not agents that stopped. Silence is the only signal - there is no heartbeat + separate from the report. + """ + hours = _quiet_hours() + cutoff = _naive_utcnow() - timedelta(hours=hours) + now = _naive_utcnow() + + from plugins.computers.models import Computer + + rows = (db.session.query(EndpointState, Computer.computerid) + .join(Asset, Asset.assetid == EndpointState.assetid) + .join(Computer, Computer.assetid == EndpointState.assetid) + .filter(Asset.isactive.is_(True), + EndpointState.lastreportedat.isnot(None), + EndpointState.lastreportedat < cutoff) + .all()) + + out = [{ + 'computerid': computerid, + 'assetid': s.assetid, + 'hostname': s.hostname, + 'lastreported': s.lastreportedat.isoformat() + 'Z', + 'quietdays': (now - s.lastreportedat).days, + } for s, computerid in rows] + + out.sort(key=lambda r: -r['quietdays']) + return success_response(out[:50]) + + +@endpoints_bp.route('/dashboard/stability', methods=['GET']) +@require_permission('endpoints.view') +def dashboard_stability(): + """Endpoints with recent crash/shutdown/corruption events. + + Bounded to a fixed 48h window and the critical event catalog, capped at 50 + rows: cost is a range scan on eventtime filtered to a short id list, not a + scan of all history. Joins through Computer so each row links to the PC + detail page. + """ + from plugins.computers.models import Computer + + if not CRITICAL_EVENT_IDS: + return success_response([]) + + cutoff = _naive_utcnow() - timedelta(hours=48) + + rows = (db.session.query( + Computer.computerid, + EndpointState.hostname, + db.func.count(EndpointEvent.endpointeventid), + db.func.max(EndpointEvent.eventtime), + ) + .select_from(EndpointEvent) + .join(Asset, Asset.assetid == EndpointEvent.assetid) + .join(Computer, Computer.assetid == EndpointEvent.assetid) + .outerjoin(EndpointState, + EndpointState.assetid == EndpointEvent.assetid) + .filter(Asset.isactive.is_(True), + EndpointEvent.eventid.in_(CRITICAL_EVENT_IDS), + EndpointEvent.eventtime.isnot(None), + EndpointEvent.eventtime >= cutoff) + .group_by(Computer.computerid, EndpointState.hostname) + .all()) + + out = [{ + 'computerid': computerid, + 'hostname': hostname, + 'eventcount': count, + 'lastevent': lasttime.isoformat() + 'Z' if lasttime else None, + } for computerid, hostname, count, lasttime in rows] + + out.sort(key=lambda r: -r['eventcount']) + return success_response(out[:50]) + + +def _quiet_hours() -> int: + """Quiet-report window in hours, from a site setting, default 24.""" + hours = 24 + setting = Setting.query.filter_by(key='endpoints_quietreporthours').first() + if setting and (setting.value or '').strip(): + try: + hours = int(setting.value) + except (TypeError, ValueError): + pass + return hours + + +# ============================================================================= +# Endpoint agent key (UI-managed collector.ingest.endpoints kill switch) +# ============================================================================= + +# The single permission the endpoint agent key authorizes. A managed token +# scoped to ONLY this authorizes the endpoints collector and nothing else, so +# rotating/revoking it stops endpoint-agent traffic without touching the +# computers/geenforce collectors. +AGENT_KEY_SCOPE = 'collector.ingest.endpoints' +AGENT_KEY_NAME = 'Endpoint agent key' + + +def _agentkey_status_payload(): + """Current state of the endpoint agent key for the settings page. + + Reports whether an active managed key exists (and its non-secret metadata) + plus whether the bootstrap .env fallback is still accepted. Never exposes a + secret; the full secret is only ever returned once, at regenerate time. + """ + tokens = list_service_tokens(AGENT_KEY_SCOPE) + active = tokens[0] if tokens else None + envfallback = Setting.get('plugin.endpoints.envkeyfallback', default=True) + provisioned = Setting.get('plugin.endpoints.agentkeyprovisioned', + default=False) + return { + 'provisioned': bool(provisioned) or active is not None, + 'envkeyfallback': bool(envfallback), + 'key': active.to_dict() if active is not None else None, + } + + +@endpoints_bp.route('/agentkey', methods=['GET']) +@require_role('admin') +def get_agentkey(): + """Report the endpoint agent key state (no secret).""" + return success_response(_agentkey_status_payload()) + + +@endpoints_bp.route('/agentkey/regenerate', methods=['POST']) +@require_role('admin') +def regenerate_agentkey(): + """Mint a fresh endpoint agent key, revoking any previous one. + + Hard cutover: the old key is deactivated in the same call, and the .env + fallback is switched off, so the new managed token becomes the sole + credential and revoking it is a true kill switch. The full secret is + returned ONCE here and never again. + """ + try: + token, secret = mint_service_token( + AGENT_KEY_SCOPE, AGENT_KEY_NAME, current_user.userid) + except PermissionError as error: + return error_response(ErrorCodes.FORBIDDEN, str(error), http_code=403) + + Setting.set('plugin.endpoints.agentkeyprovisioned', True, + valuetype='boolean', category='plugin.endpoints') + Setting.set('plugin.endpoints.envkeyfallback', False, + valuetype='boolean', category='plugin.endpoints') + + result = _agentkey_status_payload() + # The secret appears here and NOWHERE else, ever. Not stored, not logged. + result['secret'] = secret + result['warning'] = ('Save this key now. It will not be shown again. ' + 'Configure it as the agent X-API-Key.') + return success_response(result, message='Endpoint agent key generated', + http_code=201) + + +@endpoints_bp.route('/agentkey', methods=['DELETE']) +@require_role('admin') +def revoke_agentkey(): + """Revoke the endpoint agent key: stop all managed-token endpoint traffic. + + Leaves envkeyfallback off (the kill switch stays armed). To restore + reporting an operator regenerates a new key. + """ + revoked = revoke_service_tokens(AGENT_KEY_SCOPE) + Setting.set('plugin.endpoints.agentkeyprovisioned', False, + valuetype='boolean', category='plugin.endpoints') + result = _agentkey_status_payload() + result['revoked'] = revoked + return success_response(result, message='Endpoint agent key revoked') + + +# ============================================================================= +# Endpoint commands (WebUI -> agent, allowlisted actions only) +# ============================================================================= +# +# Operators enqueue a named, server-allowlisted action for one endpoint (JWT + +# endpoints.command). The agent long-polls /commands/next with its collector +# token, claims the single next pending command, runs it locally, and reports +# the outcome to /commands//result. The wire never carries a command string +# - only an allowlisted action name - so a compromised WebUI cannot ask an +# endpoint to run something arbitrary. + +# How long the agent claim poll is held open server-side before returning empty. +# Kept below the agent HttpClient timeout and typical proxy idle timeouts so a +# quiet fleet re-polls cleanly instead of tripping a read timeout. +_CLAIM_HOLD_SECONDS = 25 +_CLAIM_POLL_INTERVAL = 1.0 + + +def _collector_auth_or_error(): + """Authorize an agent-facing command route on the endpoints collector token. + + Reuses the collector auth path so the command channel shares the endpoint + agent key (kill switch) and introduces no second secret. + """ + return collector_authorized('endpoints') + + +def _resolve_asset_for_hostname(hostname): + """Resolve a reported hostname to its asset id via the computers plugin.""" + from plugins.computers.models import Computer + + computer = (Computer.query + .filter(db.func.lower(Computer.hostname) == hostname.lower()) + .first()) + return computer.assetid if computer is not None else None + + +@endpoints_bp.route('//commands', methods=['GET']) +@require_permission('endpoints.view') +def list_endpoint_commands(asset_id: int): + """Recent commands for one endpoint, newest first (operator view).""" + rows = (EndpointCommand.query + .filter_by(assetid=asset_id) + .order_by(EndpointCommand.createdat.desc()) + .limit(20) + .all()) + return success_response([row.to_dict() for row in rows]) + + +@endpoints_bp.route('//commands', methods=['POST']) +@require_permission('endpoints.command') +def enqueue_endpoint_command(asset_id: int): + """Queue an allowlisted command for one endpoint. + + Rejects unknown actions and refuses to stack a second pending/running + command on the same endpoint, so the queue stays at-most-one-in-flight and + the WebUI toggle cannot race itself. + """ + payload = request.get_json(silent=True) or {} + action = (payload.get('action') or '').strip() + if not is_allowed_action(action): + 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, + f'Action not permitted by policy for this endpoint: {action!r}') + + # Generic actions (service_* / process_*) must carry the subject to act on; + # fixed-target actions (mtconnect_*) must not. Validate both directions so a + # caller cannot send a targetless generic action (nothing to act on) or + # smuggle a target onto a pinned action. + target = (payload.get('target') or '').strip() or None + targetkind = action_targetkind(action) + if requires_target(action): + if not target: + return error_response( + ErrorCodes.VALIDATION_ERROR, + f'Action {action!r} requires a target') + elif target is not None: + return error_response( + ErrorCodes.VALIDATION_ERROR, + f'Action {action!r} does not accept a target') + + state = EndpointState.query.filter_by(assetid=asset_id).first() + if state is None: + return error_response(ErrorCodes.NOT_FOUND, + f'No endpoint state for asset {asset_id}', + http_code=404) + + pending = (EndpointCommand.query + .filter(EndpointCommand.assetid == asset_id, + EndpointCommand.status.in_(( + EndpointCommand.STATUS_PENDING, + EndpointCommand.STATUS_RUNNING))) + .first()) + if pending is not None: + return error_response( + ErrorCodes.VALIDATION_ERROR, + 'A command is already in flight for this endpoint', + http_code=409) + + command = EndpointCommand( + assetid=asset_id, + action=action, + target=target, + targetkind=targetkind if target else None, + status=EndpointCommand.STATUS_PENDING, + requestedby=getattr(current_user, 'userid', None), + ) + db.session.add(command) + + AuditLog.log('created', 'EndpointCommand', + entityname=action, + details=f'action={action} target={target} asset={asset_id}') + + db.session.commit() + return success_response(command.to_dict(), + message='Command queued', http_code=201) + + +@endpoints_bp.route('/commands/next', methods=['GET']) +def claim_next_command(): + """Agent long-poll: claim the next pending command for a hostname. + + Collector-token auth (not JWT). Holds the request open briefly and returns + the single oldest pending command for the caller's endpoint, atomically + marking it running so no other poll claims it. Returns 204 when nothing is + queued within the hold window - the agent simply re-polls. + """ + error = _collector_auth_or_error() + if error is not None: + return error + + hostname = (request.args.get('hostname') or '').strip() + if not hostname: + return error_response(ErrorCodes.VALIDATION_ERROR, + 'hostname is required') + + asset_id = _resolve_asset_for_hostname(hostname) + if asset_id is None: + return error_response(ErrorCodes.NOT_FOUND, + f'Unknown hostname: {hostname!r}', http_code=404) + + deadline = time.monotonic() + _CLAIM_HOLD_SECONDS + while True: + command = (EndpointCommand.query + .filter_by(assetid=asset_id, + status=EndpointCommand.STATUS_PENDING) + .order_by(EndpointCommand.createdat.asc()) + .first()) + if command is not None: + command.status = EndpointCommand.STATUS_RUNNING + command.claimedat = _naive_utcnow() + db.session.commit() + return success_response({ + 'endpointcommandid': command.endpointcommandid, + 'action': command.action, + 'target': command.target, + 'targetkind': command.targetkind, + }) + + # Nothing queued: release the session so a concurrent enqueue is visible + # on the next poll, wait a beat, then re-check until the hold expires. + db.session.rollback() + if time.monotonic() >= deadline: + return ('', 204) + time.sleep(_CLAIM_POLL_INTERVAL) + + +@endpoints_bp.route('/commands//result', methods=['POST']) +def report_command_result(command_id: int): + """Agent reports a claimed command's outcome (collector-token auth). + + Finalizes the command row and, for MTConnect actions that observed the run + state, caches it on the EndpointState so the WebUI toggle reflects reality + between polls. Both writes land in one commit. + """ + error = _collector_auth_or_error() + if error is not None: + return error + + command = db.session.get(EndpointCommand, command_id) + if command is None: + return error_response(ErrorCodes.NOT_FOUND, + f'No command {command_id}', http_code=404) + + payload = request.get_json(silent=True) or {} + succeeded = bool(payload.get('succeeded')) + command.status = (EndpointCommand.STATUS_SUCCEEDED if succeeded + else EndpointCommand.STATUS_FAILED) + command.resultmessage = (payload.get('message') or '')[:2000] or None + detail = payload.get('detail') + command.resultjson = json.dumps(detail, separators=(',', ':')) \ + if detail is not None else None + command.completedat = _naive_utcnow() + + # Cache the observed MTConnect state so the toggle shows a real value. The + # agent sends mtconnectrunning on any mtconnect_* result that probed it. + if 'mtconnectrunning' in payload: + state = EndpointState.query.filter_by(assetid=command.assetid).first() + if state is not None: + running = payload.get('mtconnectrunning') + state.mtconnectrunning = bool(running) if running is not None else None + state.mtconnectcheckedat = _naive_utcnow() + + db.session.commit() + return success_response(command.to_dict(), message='Result recorded') + diff --git a/plugins/endpoints/commands.py b/plugins/endpoints/commands.py new file mode 100644 index 00000000..b846842e --- /dev/null +++ b/plugins/endpoints/commands.py @@ -0,0 +1,198 @@ +"""Server-side allowlist of endpoint agent commands. + +The WebUI never sends an arbitrary command string. It sends an allowlisted +ACTION NAME; the concrete on-endpoint behavior (which script to run, how to stop +a process) lives in the agent. This module is the single source of truth for +which action names are legal and what they mean, so both the enqueue route and +the settings/UI surface validate against the same set. + +The structure anticipates per-PC-type allowlists: today every computer may run +every action in COMMAND_ALLOWLIST, but ALLOWED_ACTIONS_FOR is the seam a later +change swaps for a type-scoped lookup (inspection PCs vs shopfloor PCs). +""" + +# action name -> operator-facing metadata. label/description feed the UI; the +# agent maps the action name to its local handler (see CommandExecutor). +# +# Two families of action: +# * Named MTConnect actions - fixed target, no parameters. The original, +# bay-specific controls kept for the inline services-panel toggle. +# * Generic RMM actions (service_* / process_*) - the target is supplied by +# the policy action's params ('target' = a Windows service name or a process +# image like 'DncMain.exe'). This is what populates the Configuration Policy +# builder with reusable controls that any scope (PC type or application) can +# attach. The 'targetkind' hint tells the builder which target field to show. +# NOTE: agent-side execution of the generic actions is not wired yet; the +# server allowlist and policy plumbing land first (the whole feature is +# flag-gated), so a generic action saved today is authored and gated but is +# a no-op on the endpoint until the CommandExecutor handlers ship. +COMMAND_ALLOWLIST = { + 'service_start': { + 'label': 'Start service', + 'description': 'Start a Windows service by name on the endpoint.', + 'targetkind': 'service', + }, + 'service_stop': { + 'label': 'Stop service', + 'description': 'Stop a running Windows service by name on the endpoint.', + 'targetkind': 'service', + }, + 'service_status': { + 'label': 'Check service', + 'description': 'Report whether a named Windows service is running.', + 'targetkind': 'service', + }, + 'process_start': { + 'label': 'Start process', + 'description': 'Launch a process by executable path on the endpoint.', + 'targetkind': 'process', + }, + 'process_stop': { + 'label': 'Stop process', + 'description': 'Stop a running process by image name on the endpoint.', + 'targetkind': 'process', + }, + 'process_status': { + 'label': 'Check process', + 'description': 'Report whether a named process is running.', + 'targetkind': 'process', + }, + 'mtconnect_start': { + 'label': 'Start MTConnect', + 'description': 'Run the MTConnect UDC agent start script on the endpoint.', + }, + 'mtconnect_stop': { + 'label': 'Stop MTConnect', + 'description': 'Stop the running MTConnect UDC agent on the endpoint.', + }, + 'mtconnect_status': { + '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 +# 'app' label; 'kind' pins the toggle to the authoritative row (for MTConnect +# the agent.exe PROCESS is the source of truth - run.bat launches it and the +# Windows service state is not reliable), so the service row stays badge-only. +ROW_COMMAND_TARGETS = { + ('MTConnect', 'process'): { + 'start': 'mtconnect_start', + 'stop': 'mtconnect_stop', + 'status': 'mtconnect_status', + }, +} + + +def row_actions_for(app, kind, name=None): + """Return the {start, stop, status, target, targetkind} map for a row. + + Two paths: + * A row registered in ROW_COMMAND_TARGETS (MTConnect) keeps its fixed, + named actions with no wire target - agent.exe is the source of truth and + the behavior is pinned in the agent. + * Any other process/service row maps to the generic primitives + (process_* / service_*) with the row's OWN reported name as the wire + target. This is what lets a new app light up start/stop/status without + an agent rebuild: the target rides the wire. + + Rows with no usable kind return None and render as a plain status badge. + """ + if not app and not name: + return None + fixed = ROW_COMMAND_TARGETS.get((app, (kind or '').lower())) + if fixed: + return {**fixed, 'target': None, 'targetkind': None} + normalized = (kind or '').lower() + if normalized == 'process' and name: + return { + 'start': 'process_start', + 'stop': 'process_stop', + 'status': 'process_status', + 'target': name, + 'targetkind': 'process', + } + if normalized == 'service' and name: + return { + 'start': 'service_start', + 'stop': 'service_stop', + 'status': 'service_status', + 'target': name, + 'targetkind': 'service', + } + return None + + +def is_allowed_action(action): + """True when action is a known, allowlisted command name.""" + return action in COMMAND_ALLOWLIST + + +def action_targetkind(action): + """The targetkind an action requires ('service'/'process'), or None. + + Generic actions declare a targetkind in COMMAND_ALLOWLIST; fixed-target + actions (mtconnect_*) declare none and must not carry a wire target. + """ + meta = COMMAND_ALLOWLIST.get(action) or {} + return meta.get('targetkind') + + +def requires_target(action): + """True when the action is generic and needs a wire target.""" + return action_targetkind(action) is not None + + +def ALLOWED_ACTIONS_FOR(pctype=None, assetid=None): + """The action names an endpoint may run. + + Base allowlist is flat (every action for every computer). When the computers + Configuration/Business Policy feature is enabled AND an assetid is given, the + result is narrowed to the actions the endpoint's effective policy permits - + this is the allowlist-gating half of the policy design. With the feature off + (or no assetid), it returns the full base allowlist, so nothing changes until + a site opts in. Policy resolution is imported lazily to keep this module free + of a hard dependency on the computers plugin. + """ + base = set(COMMAND_ALLOWLIST.keys()) + if assetid is None: + return base + try: + from plugins.computers.services.policyresolver import effective_policy_for + except Exception: + return base + effective = effective_policy_for(assetid) + allowed = effective.allowed_actions() + if not allowed: + # No policy spoke for this endpoint. With the feature off the resolver + # returns empty and we fall back to the base allowlist (unchanged + # behavior). This is intentionally permissive; deny-by-default would be + # a separate, explicit setting. + return base + # Never widen past the code-level allowlist: policy can only gate within it. + return base & allowed + diff --git a/plugins/endpoints/eventcatalog.py b/plugins/endpoints/eventcatalog.py new file mode 100644 index 00000000..5f6c8903 --- /dev/null +++ b/plugins/endpoints/eventcatalog.py @@ -0,0 +1,46 @@ +"""Curated Windows event catalog: id -> human title and severity. + +Single server-side source of truth shared by the recent-events asset panel and +the stability dashboard card, so a 4625 reads as "Failed logon (warning)" in +both places without either restating the mapping. The set of IDs here MUST stay +in step with CURATED_EVENT_IDS in plugin.py; a test asserts they match. + +Severity vocabulary matches the dashboard card renderer: 'critical', 'warning', +'info'. The IDs marked critical are the ones an operator/tech should act on +without being asked - unexpected shutdown, crash, filesystem corruption, SCM +timeout. Logon activity is informational history unless it is a failure. +""" + +# id -> (title, severity, channel) +EVENT_CATALOG = { + 4624: ('Successful logon', 'info', 'Security'), + 4625: ('Failed logon', 'warning', 'Security'), + 4634: ('Logoff', 'info', 'Security'), + 4672: ('Special privileges assigned', 'info', 'Security'), + 4697: ('Service installed', 'warning', 'Security'), + 4698: ('Scheduled task created', 'warning', 'Security'), + 41: ('Unexpected shutdown', 'critical', 'System'), + 4618: ('Monitored security event', 'warning', 'Security'), + 55: ('File system corruption', 'critical', 'System'), + 7011: ('Service timeout', 'critical', 'System'), + 10016: ('DCOM permission error', 'warning', 'System'), + 1001: ('Application crash', 'critical', 'Application'), +} + +# The IDs a tech should see surfaced fleet-wide on the dashboard stability card. +CRITICAL_EVENT_IDS = tuple( + eventid for eventid, (_t, severity, _c) in EVENT_CATALOG.items() + if severity == 'critical' +) + + +def event_title(eventid): + """Human title for a curated event id, or a generic fallback.""" + entry = EVENT_CATALOG.get(eventid) + return entry[0] if entry else 'Event {}'.format(eventid) + + +def event_severity(eventid): + """Severity ('critical'|'warning'|'info') for a curated event id.""" + entry = EVENT_CATALOG.get(eventid) + return entry[1] if entry else 'info' diff --git a/plugins/endpoints/frontend/routes.js b/plugins/endpoints/frontend/routes.js new file mode 100644 index 00000000..3e07f25d --- /dev/null +++ b/plugins/endpoints/frontend/routes.js @@ -0,0 +1,26 @@ +/** + * Endpoints plugin routes + * + * Groundwork for the fleet view. There is no endpoint-specific detail route + * yet: rows link into the existing PC detail page (/pcs/:id) so telemetry + * stays interoperable with the computers plugin instead of duplicating it. + */ +export default [ + { + path: 'endpoints', + name: 'endpoints', + component: () => import('./views/EndpointsList.vue'), + meta: { plugin: 'endpoints' } + }, + { + path: 'settings/endpoints/agentkey', + name: 'endpoints-agentkey-settings', + component: () => import('./views/AgentKeySettings.vue'), + meta: { requiresAuth: true, requiresAdmin: true, plugin: 'endpoints' } + } +] + +// No full-screen routes: every view in this plugin renders inside +// AppLayout. Declared so routes.gen.js can read `.toplevel` off the module +// without Rollup warning about a missing export. +export const toplevel = [] diff --git a/plugins/endpoints/frontend/views/AgentKeySettings.vue b/plugins/endpoints/frontend/views/AgentKeySettings.vue new file mode 100644 index 00000000..8e67d7e5 --- /dev/null +++ b/plugins/endpoints/frontend/views/AgentKeySettings.vue @@ -0,0 +1,293 @@ + + + + + diff --git a/plugins/endpoints/frontend/views/EndpointsList.vue b/plugins/endpoints/frontend/views/EndpointsList.vue new file mode 100644 index 00000000..802ccbef --- /dev/null +++ b/plugins/endpoints/frontend/views/EndpointsList.vue @@ -0,0 +1,153 @@ + + + + + diff --git a/plugins/endpoints/manifest.json b/plugins/endpoints/manifest.json new file mode 100644 index 00000000..3b8c3fdf --- /dev/null +++ b/plugins/endpoints/manifest.json @@ -0,0 +1,21 @@ +{ + "name": "endpoints", + "version": "0.1.0", + "description": "Endpoint RMM telemetry: metrics, services, inventory, and curated Windows event records reported by the ShopDB agent", + "author": "ShopDB Team", + "dependencies": ["computers"], + "core_version": ">=0.20.0,<1.0.0", + "api_prefix": "/api/endpoints", + "provides": { + "features": [ + "endpoint_metrics", + "service_state", + "inventory_snapshot", + "event_records" + ] + }, + "settings": { + "report_interval_minutes": 15, + "quiet_report_hours": 24 + } +} diff --git a/plugins/endpoints/migrations/env.py b/plugins/endpoints/migrations/env.py new file mode 100644 index 00000000..15f32823 --- /dev/null +++ b/plugins/endpoints/migrations/env.py @@ -0,0 +1,15 @@ +"""Alembic environment for the endpoints plugin migration chain. + +Delegates to the shared runner in shopdb.plugins.alembic_template, which filters +the metadata to this plugin's tables and drives Alembic against the per-plugin +version table alembic_version_endpoints. See ADR-008 for the ownership model. +This plugin is NEW: its 0001 baseline really CREATES its tables, because the +core chain never built them. +""" +import os + +os.environ['PLUGIN_NAME'] = 'endpoints' + +from shopdb.plugins.alembic_template import run_migrations # noqa: E402 + +run_migrations() diff --git a/plugins/endpoints/migrations/script.py.mako b/plugins/endpoints/migrations/script.py.mako new file mode 100644 index 00000000..4f44da1d --- /dev/null +++ b/plugins/endpoints/migrations/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/plugins/endpoints/migrations/versions/0001_endpoints_baseline.py b/plugins/endpoints/migrations/versions/0001_endpoints_baseline.py new file mode 100644 index 00000000..99c3ab28 --- /dev/null +++ b/plugins/endpoints/migrations/versions/0001_endpoints_baseline.py @@ -0,0 +1,96 @@ +"""endpoints plugin baseline (real create). + +A NEW plugin: the core chain never knew about endpointstates or endpointevents, +so this per-plugin chain is their sole authoritative creator. + +Emits explicit Alembic ops rather than using the create_plugin_tables helper: +the helper builds a per-plugin MetaData filtered to the plugin's own tables, so +the foreign key to the core assets table cannot resolve at CreateTable-compile +time (NoReferencedTableError). Tables inherit the connection's default charset, +matching how the core chain creates its own. +""" +from alembic import op + +from shopdb.plugins.alembic_template import ( + create_index_if_missing, + create_table_if_missing, +) +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'endpoints0001baseline' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + create_table_if_missing( + 'endpointstates', + sa.Column('endpointstateid', sa.Integer(), nullable=False), + sa.Column('assetid', sa.Integer(), nullable=False), + sa.Column('hostname', sa.String(length=100), nullable=True), + sa.Column('loggedinuser', sa.String(length=100), nullable=True), + sa.Column('agentversion', sa.String(length=40), nullable=True), + sa.Column('lastboottime', sa.DateTime(), nullable=True), + sa.Column('uptimeseconds', sa.BigInteger(), nullable=True), + sa.Column('idleseconds', sa.BigInteger(), nullable=True), + sa.Column('cpupercent', sa.Float(), nullable=True), + sa.Column('memorypercent', sa.Float(), nullable=True), + sa.Column('servicesjson', sa.Text(length=16777215), nullable=True), + sa.Column('portsjson', sa.Text(length=16777215), nullable=True), + sa.Column('interfacesjson', sa.Text(length=16777215), nullable=True), + sa.Column('scheduledtasksjson', sa.Text(length=16777215), + nullable=True), + sa.Column('lastreportedat', sa.DateTime(), nullable=True), + sa.Column('createdat', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['assetid'], ['assets.assetid'], + ondelete='CASCADE'), + sa.PrimaryKeyConstraint('endpointstateid'), + sa.UniqueConstraint('assetid'), + ) + create_index_if_missing('ix_endpointstates_assetid', + 'endpointstates', ['assetid']) + create_index_if_missing('ix_endpointstates_lastreportedat', + 'endpointstates', ['lastreportedat']) + + create_table_if_missing( + 'endpointevents', + sa.Column('endpointeventid', sa.Integer(), nullable=False), + sa.Column('assetid', sa.Integer(), nullable=False), + sa.Column('eventid', sa.Integer(), nullable=False), + sa.Column('eventchannel', sa.String(length=50), nullable=True), + sa.Column('eventsource', sa.String(length=255), nullable=True), + sa.Column('eventtime', sa.DateTime(), nullable=True), + sa.Column('eventmessage', sa.Text(), nullable=True), + sa.Column('payloadjson', sa.Text(length=16777215), nullable=True), + sa.Column('recordedat', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['assetid'], ['assets.assetid'], + ondelete='CASCADE'), + sa.PrimaryKeyConstraint('endpointeventid'), + ) + create_index_if_missing('ix_endpointevents_assetid', 'endpointevents', + ['assetid']) + create_index_if_missing('ix_endpointevents_eventid', 'endpointevents', + ['eventid']) + create_index_if_missing('ix_endpointevents_eventtime', 'endpointevents', + ['eventtime']) + # Alerting/history reads by "this endpoint's events over time", so the + # composite is the index that actually gets used. + create_index_if_missing('idx_endpointevents_assetevent', 'endpointevents', + ['assetid', 'eventtime']) + + +def downgrade(): + op.drop_index('idx_endpointevents_assetevent', + table_name='endpointevents') + op.drop_index('ix_endpointevents_eventtime', table_name='endpointevents') + op.drop_index('ix_endpointevents_eventid', table_name='endpointevents') + op.drop_index('ix_endpointevents_assetid', table_name='endpointevents') + op.drop_table('endpointevents') + + op.drop_index('ix_endpointstates_lastreportedat', + table_name='endpointstates') + op.drop_index('ix_endpointstates_assetid', table_name='endpointstates') + op.drop_table('endpointstates') diff --git a/plugins/endpoints/migrations/versions/0002_endpoint_commands.py b/plugins/endpoints/migrations/versions/0002_endpoint_commands.py new file mode 100644 index 00000000..d157e4cc --- /dev/null +++ b/plugins/endpoints/migrations/versions/0002_endpoint_commands.py @@ -0,0 +1,84 @@ +"""endpoint commands queue + cached MTConnect state. + +Adds the endpointcommands queue (one WebUI-issued, server-allowlisted action +per row, claimed and finalized by the agent long-poll) and two cached MTConnect +run-state columns on endpointstates so the WebUI toggle can show a real state +between command polls. + +Idempotent: create_table_if_missing guards the new table, and the column adds +check the live schema first so a db.create_all() site (which already built the +columns) re-runs clean on strict MySQL 8. +""" +from alembic import op + +from shopdb.plugins.alembic_template import ( + create_index_if_missing, + create_table_if_missing, +) +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'endpoints0002commands' +down_revision = 'endpoints0001baseline' +branch_labels = None +depends_on = None + + +def _has_column(table_name, column_name): + from sqlalchemy import inspect + + inspector = inspect(op.get_bind()) + if not inspector.has_table(table_name): + return False + return any(col['name'] == column_name + for col in inspector.get_columns(table_name)) + + +def upgrade(): + if not _has_column('endpointstates', 'mtconnectrunning'): + op.add_column('endpointstates', + sa.Column('mtconnectrunning', sa.Boolean(), + nullable=True)) + if not _has_column('endpointstates', 'mtconnectcheckedat'): + op.add_column('endpointstates', + sa.Column('mtconnectcheckedat', sa.DateTime(), + nullable=True)) + + create_table_if_missing( + 'endpointcommands', + sa.Column('endpointcommandid', sa.Integer(), nullable=False), + sa.Column('assetid', sa.Integer(), nullable=False), + sa.Column('action', sa.String(length=64), nullable=False), + sa.Column('status', sa.String(length=20), nullable=False), + sa.Column('requestedby', sa.Integer(), nullable=True), + sa.Column('resultmessage', sa.Text(), nullable=True), + sa.Column('resultjson', sa.Text(length=16777215), nullable=True), + sa.Column('createdat', sa.DateTime(), nullable=False), + sa.Column('claimedat', sa.DateTime(), nullable=True), + sa.Column('completedat', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['assetid'], ['assets.assetid'], + ondelete='CASCADE'), + sa.PrimaryKeyConstraint('endpointcommandid'), + ) + create_index_if_missing('ix_endpointcommands_assetid', + 'endpointcommands', ['assetid']) + create_index_if_missing('ix_endpointcommands_status', + 'endpointcommands', ['status']) + create_index_if_missing('idx_endpointcommands_assetstatus', + 'endpointcommands', ['assetid', 'status']) + + +def downgrade(): + op.drop_index('idx_endpointcommands_assetstatus', + table_name='endpointcommands') + op.drop_index('ix_endpointcommands_status', + table_name='endpointcommands') + op.drop_index('ix_endpointcommands_assetid', + table_name='endpointcommands') + op.drop_table('endpointcommands') + + if _has_column('endpointstates', 'mtconnectcheckedat'): + op.drop_column('endpointstates', 'mtconnectcheckedat') + if _has_column('endpointstates', 'mtconnectrunning'): + op.drop_column('endpointstates', 'mtconnectrunning') diff --git a/plugins/endpoints/migrations/versions/0003_endpoint_command_target.py b/plugins/endpoints/migrations/versions/0003_endpoint_command_target.py new file mode 100644 index 00000000..60b86257 --- /dev/null +++ b/plugins/endpoints/migrations/versions/0003_endpoint_command_target.py @@ -0,0 +1,50 @@ +"""endpoint command target for generic service/process actions. + +Adds nullable target and targetkind columns to endpointcommands so generic +service_* / process_* actions can carry their subject (a Windows service name or +a process image like 'DncMain.exe') over the wire. Fixed-target actions +(mtconnect_*) leave both null. This is the schema half of making the agent +data-driven: a site builds the agent once and every future policy/app is server +data, no agent redistribution. + +Idempotent: each add checks the live schema first so a db.create_all() site +(which already built the columns) re-runs clean on strict MySQL 8. +""" +from alembic import op + +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'endpoints0003cmdtarget' +down_revision = 'endpoints0002commands' +branch_labels = None +depends_on = None + + +def _has_column(table_name, column_name): + from sqlalchemy import inspect + + inspector = inspect(op.get_bind()) + if not inspector.has_table(table_name): + return False + return any(col['name'] == column_name + for col in inspector.get_columns(table_name)) + + +def upgrade(): + if not _has_column('endpointcommands', 'target'): + op.add_column('endpointcommands', + sa.Column('target', sa.String(length=255), + nullable=True)) + if not _has_column('endpointcommands', 'targetkind'): + op.add_column('endpointcommands', + sa.Column('targetkind', sa.String(length=16), + nullable=True)) + + +def downgrade(): + if _has_column('endpointcommands', 'targetkind'): + op.drop_column('endpointcommands', 'targetkind') + if _has_column('endpointcommands', 'target'): + op.drop_column('endpointcommands', 'target') diff --git a/plugins/endpoints/models/__init__.py b/plugins/endpoints/models/__init__.py new file mode 100644 index 00000000..1e1fabaa --- /dev/null +++ b/plugins/endpoints/models/__init__.py @@ -0,0 +1,5 @@ +"""Endpoints plugin models.""" + +from .endpoint import EndpointState, EndpointEvent, EndpointCommand + +__all__ = ['EndpointState', 'EndpointEvent', 'EndpointCommand'] diff --git a/plugins/endpoints/models/endpoint.py b/plugins/endpoints/models/endpoint.py new file mode 100644 index 00000000..0b0e4593 --- /dev/null +++ b/plugins/endpoints/models/endpoint.py @@ -0,0 +1,248 @@ +"""Endpoint RMM telemetry models. + +Two shapes, one per concern: + + EndpointState The cached CURRENT snapshot for an asset - one row per asset, + overwritten in place on every agent report. Holds the fast- + moving metrics (uptime, idle, last boot, logged-in user) and + the latest inventory snapshots (services/processes, open ports, + interfaces, scheduled tasks) as serialized JSON. This is the + cached-current-value half of the ledger pattern: it moves in + the SAME commit as any EndpointEvent rows the report carries. + + EndpointEvent Append-only record of a curated Windows Event Log entry. The + agent only forwards a fixed catalog of IDs (logon/logoff, + privilege use, service install, unexpected shutdown, DCOM, + crash) so this stays a readable security/stability history + rather than a raw event firehose. + +Both link to the core Asset via assetid (FK, ON DELETE CASCADE), so an endpoint +is the same physical thing as its Computer/Asset - no duplicate identity. The +agent reports a hostname; the plugin resolves it to the existing Computer. +""" + +from datetime import datetime + +from shopdb.api import db + + +def _utciso(value): + """Naive-UTC datetime -> ISO string marked UTC, or None. + + Every datetime this plugin stores is naive UTC. Serializing without the Z + leaves the receiver to guess, and JavaScript guesses browser-local. + """ + return value.isoformat() + 'Z' if value else None + + +class EndpointState(db.Model): + """Latest reported state for a single endpoint (one row per asset).""" + + __tablename__ = 'endpointstates' + + endpointstateid = db.Column(db.Integer, primary_key=True) + + # The asset this state belongs to. Unique: exactly one current snapshot per + # endpoint. Resolved from the reported hostname against the computers plugin. + assetid = db.Column( + db.Integer, + db.ForeignKey('assets.assetid', ondelete='CASCADE'), + unique=True, + nullable=False, + index=True, + ) + + # Read-only view of the owning asset. No cascade or backref: the asset side + # must not gain a dependency on this plugin. + asset = db.relationship('Asset', lazy='joined', viewonly=True) + + hostname = db.Column(db.String(100), nullable=True) + loggedinuser = db.Column(db.String(100), nullable=True) + agentversion = db.Column(db.String(40), nullable=True) + + # Fast-moving metrics + lastboottime = db.Column(db.DateTime, nullable=True) + uptimeseconds = db.Column(db.BigInteger, nullable=True) + idleseconds = db.Column(db.BigInteger, nullable=True) + cpupercent = db.Column(db.Float, nullable=True) + memorypercent = db.Column(db.Float, nullable=True) + + # Latest inventory snapshots, serialized JSON (MEDIUMTEXT). Kept as the + # newest snapshot rather than one row per sample: the agent reports the full + # list each cycle and history of these lists is not what operators ask for. + servicesjson = db.Column(db.Text(length=16777215), nullable=True) + portsjson = db.Column(db.Text(length=16777215), nullable=True) + interfacesjson = db.Column(db.Text(length=16777215), nullable=True) + scheduledtasksjson = db.Column(db.Text(length=16777215), nullable=True) + + # Last-known MTConnect agent run state, cached so the WebUI toggle can show + # a real state between command polls. Set by the agent's mtconnect_status + # result. Null = never reported (unknown). + mtconnectrunning = db.Column(db.Boolean, nullable=True) + mtconnectcheckedat = db.Column(db.DateTime, nullable=True) + + # When the agent last successfully reported. There is no heartbeat separate + # from the report, so silence past the quiet-hours window is the only + # evidence an endpoint has stopped checking in. + lastreportedat = db.Column(db.DateTime, nullable=True, index=True) + + createdat = db.Column(db.DateTime, nullable=False, default=datetime.utcnow) + + def __repr__(self): + return f"" + + def to_dict(self): + return { + 'endpointstateid': self.endpointstateid, + 'assetid': self.assetid, + 'hostname': self.hostname, + 'loggedinuser': self.loggedinuser, + 'agentversion': self.agentversion, + 'lastboottime': _utciso(self.lastboottime), + 'uptimeseconds': self.uptimeseconds, + 'idleseconds': self.idleseconds, + 'cpupercent': self.cpupercent, + 'memorypercent': self.memorypercent, + 'mtconnectrunning': self.mtconnectrunning, + 'mtconnectcheckedat': _utciso(self.mtconnectcheckedat), + 'lastreportedat': _utciso(self.lastreportedat), + 'createdat': _utciso(self.createdat), + } + + +class EndpointCommand(db.Model): + """A queued operator command targeting one endpoint agent. + + The WebUI enqueues a named action (server allowlist only, never an arbitrary + command string) for an asset. The agent claims the next pending command on + its long-poll, executes it locally, and reports the outcome back, which + moves the row to a terminal status in the same request. This is a small + at-most-one-in-flight queue, not a general job system. + """ + + __tablename__ = 'endpointcommands' + + # Lifecycle: pending -> running -> succeeded|failed. A command is claimed + # (pending -> running) by exactly one agent poll, then finalized by its + # result post. + STATUS_PENDING = 'pending' + STATUS_RUNNING = 'running' + STATUS_SUCCEEDED = 'succeeded' + STATUS_FAILED = 'failed' + + endpointcommandid = db.Column(db.Integer, primary_key=True) + + assetid = db.Column( + db.Integer, + db.ForeignKey('assets.assetid', ondelete='CASCADE'), + nullable=False, + index=True, + ) + + asset = db.relationship('Asset', lazy='joined', viewonly=True) + + # The allowlisted action name (for example 'mtconnect_start'). Validated + # against the server allowlist at enqueue time, so only known names land. + action = db.Column(db.String(64), nullable=False) + + # Generic actions (service_* / process_*) carry their subject over the wire: + # target is a Windows service name or a process image (for example + # 'DncMain.exe'); targetkind is 'service' or 'process'. Fixed-target actions + # (mtconnect_*) leave both null. This is what lets a site build the agent + # once and drive every future policy/app purely from server data. + target = db.Column(db.String(255), nullable=True) + targetkind = db.Column(db.String(16), nullable=True) + + status = db.Column(db.String(20), nullable=False, + default=STATUS_PENDING, index=True) + + # Who queued it (userid) for audit; the agent never sees this. + requestedby = db.Column(db.Integer, nullable=True) + + # Agent-reported outcome. resultjson holds structured detail (for example + # the observed MTConnect running state); resultmessage is a short summary. + resultmessage = db.Column(db.Text, nullable=True) + resultjson = db.Column(db.Text(length=16777215), nullable=True) + + createdat = db.Column(db.DateTime, nullable=False, default=datetime.utcnow) + claimedat = db.Column(db.DateTime, nullable=True) + completedat = db.Column(db.DateTime, nullable=True) + + __table_args__ = ( + db.Index('idx_endpointcommands_assetstatus', 'assetid', 'status'), + ) + + def __repr__(self): + return f"" + + def to_dict(self): + return { + 'endpointcommandid': self.endpointcommandid, + 'assetid': self.assetid, + 'action': self.action, + 'target': self.target, + 'targetkind': self.targetkind, + 'status': self.status, + 'requestedby': self.requestedby, + 'resultmessage': self.resultmessage, + 'createdat': _utciso(self.createdat), + 'claimedat': _utciso(self.claimedat), + 'completedat': _utciso(self.completedat), + } + + +class EndpointEvent(db.Model): + """A single curated Windows Event Log record forwarded by the agent.""" + + __tablename__ = 'endpointevents' + + endpointeventid = db.Column(db.Integer, primary_key=True) + + assetid = db.Column( + db.Integer, + db.ForeignKey('assets.assetid', ondelete='CASCADE'), + nullable=False, + index=True, + ) + + asset = db.relationship('Asset', lazy='joined', viewonly=True) + + # The Windows Event ID (for example 4625 failed logon, 41 unexpected + # shutdown, 1001 application crash). Indexed because alerting queries filter + # on it. + eventid = db.Column(db.Integer, nullable=False, index=True) + + # Event Log channel the record came from: Security, System, Application. + eventchannel = db.Column(db.String(50), nullable=True) + eventsource = db.Column(db.String(255), nullable=True) + + # When the event happened on the endpoint (from the record), not when it + # reached ShopDB. + eventtime = db.Column(db.DateTime, nullable=True, index=True) + + eventmessage = db.Column(db.Text, nullable=True) + + # Structured fields the agent extracted for this ID (for example the target + # account on a 4625). Advisory index only, not part of dedup. + payloadjson = db.Column(db.Text(length=16777215), nullable=True) + + recordedat = db.Column(db.DateTime, nullable=False, default=datetime.utcnow) + + __table_args__ = ( + db.Index('idx_endpointevents_assetevent', 'assetid', 'eventtime'), + ) + + def __repr__(self): + return f"" + + def to_dict(self): + return { + 'endpointeventid': self.endpointeventid, + 'assetid': self.assetid, + 'eventid': self.eventid, + 'eventchannel': self.eventchannel, + 'eventsource': self.eventsource, + 'eventtime': _utciso(self.eventtime), + 'eventmessage': self.eventmessage, + 'recordedat': _utciso(self.recordedat), + } diff --git a/plugins/endpoints/plugin.py b/plugins/endpoints/plugin.py new file mode 100644 index 00000000..77d02ca3 --- /dev/null +++ b/plugins/endpoints/plugin.py @@ -0,0 +1,596 @@ +"""Endpoints plugin main class. + +Endpoint RMM telemetry reported by the ShopDB agent. Accepts collector input +over HTTPS per ADR-006: the agent posts a per-host payload to +POST /api/collector/endpoints, the plugin resolves the reported hostname to an +existing computer asset, overwrites that asset's cached EndpointState, and +appends any curated Windows events the report carried - all in one commit, so +the cached snapshot and the events it summarizes never disagree. + +Identity is the hostname, resolved against the computers plugin. An unresolved +hostname is reported as a ValueError (surfaced to the caller as a validation +error), never silently dropped: an unknown host is a real gap between the fleet +and ShopDB, the same discipline the backups collector uses for machine numbers. +""" + +import json +import logging +from datetime import datetime +from pathlib import Path +from typing import Dict, List, Optional, Type + +from flask import Flask, Blueprint + +from shopdb.plugins.base import BasePlugin, PluginMeta +from shopdb.api import db + +from .api import endpoints_bp +from .eventcatalog import EVENT_CATALOG +from .models import EndpointState, EndpointEvent, EndpointCommand + +logger = logging.getLogger(__name__) + +# The fixed catalog of Windows Event IDs the agent forwards. Derived from +# EVENT_CATALOG (eventcatalog.py) so the ingest filter and the display titles +# can never drift. IDs outside this set are ignored on ingest rather than +# stored, keeping endpointevents a readable security/stability history instead +# of a raw event firehose. +CURATED_EVENT_IDS = frozenset(EVENT_CATALOG) + + +def _parse_dt(value): + """Best-effort parse of an ISO-8601 string to naive UTC, or None.""" + if not value: + return None + if isinstance(value, datetime): + return value.replace(tzinfo=None) + text = str(value).strip().replace('Z', '+00:00') + try: + parsed = datetime.fromisoformat(text) + except ValueError: + return None + if parsed.tzinfo is not None: + parsed = parsed.astimezone(tz=None).replace(tzinfo=None) + return parsed + + +def _as_json(value): + """Serialize a list/dict to JSON text, or None when nothing was sent.""" + if value is None: + return None + return json.dumps(value, separators=(',', ':'), sort_keys=True) + + +class EndpointsPlugin(BasePlugin): + """Endpoint RMM telemetry ingest plus read/dashboard surface.""" + + def __init__(self): + self._manifest = self._load_manifest() + + def _load_manifest(self) -> Dict: + manifest_path = Path(__file__).parent / 'manifest.json' + if manifest_path.exists(): + with open(manifest_path, 'r') as f: + return json.load(f) + return {} + + @property + def meta(self) -> PluginMeta: + return PluginMeta( + name=self._manifest.get('name', 'endpoints'), + version=self._manifest.get('version', '0.1.0'), + description=self._manifest.get( + 'description', 'Endpoint RMM telemetry'), + author=self._manifest.get('author', 'ShopDB Team'), + dependencies=self._manifest.get('dependencies', ['computers']), + core_version=self._manifest.get('core_version', '>=0.20.0,<1.0.0'), + api_prefix=self._manifest.get('api_prefix', '/api/endpoints'), + ) + + def get_blueprint(self) -> Optional[Blueprint]: + return endpoints_bp + + def get_models(self) -> List[Type]: + return [EndpointState, EndpointEvent, EndpointCommand] + + def get_permissions(self) -> List: + """RBAC for the human-facing read routes. + + View only. Ingest is not covered here: the collector path authorizes on + a collector.ingest-scoped service token, not a user JWT. There is no + write/delete permission because operators do not edit telemetry - the + agent is the sole author, and history is pruned by policy, not by hand. + """ + return [ + ('endpoints.view', 'View endpoint telemetry', 'endpoints'), + # Issue an allowlisted command to an endpoint agent (for example the + # MTConnect toggle). Separate from endpoints.view because acting on a + # 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 + # touching computers/geenforce collectors. Minted from the settings + # UI (see get_settings_cards). Admins hold it via the catalog. + ('collector.ingest.endpoints', + 'Submit endpoint agent telemetry (fleet reporting)', 'collector'), + ] + + def get_settings_defaults(self) -> List[Dict]: + """Settings owned by the endpoints agent-key kill switch. + + `envkeyfallback` gates the bootstrap .env collector key: while true the + old COLLECTOR_API_KEY[_ENDPOINTS] still authorizes (back-compat, and the + only path before a UI key exists). The regenerate flow flips it false so + the .env key stops working and the managed token is the sole credential, + making revocation an instant kill switch. `agentkeyprovisioned` records + that a UI-managed key has been minted so the settings page can render + the rotate/revoke state instead of the first-run generate state. + """ + return [ + { + 'key': 'plugin.endpoints.envkeyfallback', + 'value': 'true', + 'valuetype': 'boolean', + 'category': 'plugin.endpoints', + 'description': 'Accept the bootstrap .env collector key for ' + 'endpoints. Turned off once a UI-managed agent ' + 'key is provisioned so revocation is a real kill ' + 'switch.', + }, + { + 'key': 'plugin.endpoints.agentkeyprovisioned', + 'value': 'false', + 'valuetype': 'boolean', + 'category': 'plugin.endpoints', + 'description': 'True once an operator has minted a UI-managed ' + 'endpoint agent key from the settings page.', + }, + ] + + def get_settings_cards(self) -> List[Dict]: + """The endpoints management card on the global settings page. + + A settings card rather than a docs page because it is the operator's + control surface: manage the agent key (the kill switch), build the site + agent, and configure what it reports. Admin only - key management mints + a collector service credential. + """ + return [ + { + 'group': 'Endpoints', + 'to': '/settings/endpoints/agentkey', + 'icon': 'key', + 'title': 'Endpoints', + 'description': 'Manage the endpoint agent key, build the site ' + 'agent, and configure what it reports.', + 'position': 27, + }, + ] + + def get_navigation_items(self) -> List[Dict]: + """Sidebar entry for the fleet list view. + + Sits just after the PCs item (position 15) because the fleet view is + a telemetry lens over the same computers. + """ + return [ + { + 'name': 'Fleet', + 'icon': 'activity', + 'route': '/endpoints', + 'position': 16, + 'permission': 'endpoints.view', + 'section': 'fleet', + }, + ] + + def init_app(self, app: Flask, db_instance) -> None: + logger.info('Endpoints plugin initialized (v%s)', self.meta.version) + + # ---- ADR-010 UI hooks ----------------------------------------------- + + def get_asset_panels(self) -> List[Dict]: + """Per-PC detail panels, computer assets only. + + 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 [ + { + 'id': 'endpoints-status', + 'title': 'Endpoint status', + 'assettypes': ['computer'], + 'endpoint': '/api/endpoints/asset/{assetid}/status', + 'render': 'keyvalue', + 'empty': 'No agent report yet.', + 'position': 10, + }, + { + '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': { + '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 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', + 'assettypes': ['computer'], + 'endpoint': '/api/endpoints/asset/{assetid}/recentevents', + 'render': 'list', + 'collapsible': True, + 'status': { + 'key': 'severity', + 'levels': { + 'info': 'ok', + 'warning': 'warning', + 'critical': 'critical', + }, + }, + 'map': { + 'title': 'title', + 'badge': { + 'label': 'severity', + 'labelmap': { + 'critical': 'Critical', + 'warning': 'Warning', + 'info': 'Info', + }, + }, + 'meta': [ + {'key': 'eventtime', 'label': 'At', 'format': 'date'}, + ], + }, + 'empty': 'No recent events.', + 'collapsedDefault': True, + 'position': 90, + }, + { + 'id': 'endpoints-interfaces', + 'title': 'Network interfaces', + 'assettypes': ['computer'], + 'endpoint': '/api/endpoints/asset/{assetid}/interfaces', + 'render': 'table', + 'collapsible': True, + 'columns': [ + {'key': 'alias', 'label': 'Adapter'}, + {'key': 'ipv4', 'label': 'IPv4'}, + {'key': 'mac', 'label': 'MAC'}, + {'key': 'gateway', 'label': 'Gateway'}, + {'key': 'subnet', 'label': 'Subnet'}, + {'key': 'dhcp', 'label': 'Assignment'}, + {'key': 'role', 'label': 'Role'}, + ], + 'empty': 'No network interfaces reported.', + 'position': 16, + }, + ] + + def get_dashboard_widgets(self) -> List[Dict]: + """Fleet-wide exception cards. Run once on dashboard load, not per PC. + + Both cards hide when empty and cap their rows, so a healthy fleet adds + nothing to the dashboard and an unhealthy one adds a bounded list. + """ + return [ + { + 'id': 'endpoints-quiet', + 'viewall': '/pcs', + 'title': 'Endpoints gone quiet', + 'endpoint': '/api/endpoints/dashboard/quiet', + 'render': 'exceptions', + 'severity': 'warning', + 'permission': 'endpoints.view', + 'empty': 'hide', + 'position': 22, + 'map': { + 'title': 'hostname', + 'detail': '', + 'meta': [{'key': 'quietdays', 'label': 'silent', + 'suffix': ' days'}], + 'link': '/pcs/{computerid}', + }, + }, + { + 'id': 'endpoints-stability', + 'viewall': '/pcs', + 'title': 'Endpoints with crashes or shutdowns', + 'endpoint': '/api/endpoints/dashboard/stability', + 'render': 'exceptions', + 'severity': 'critical', + 'permission': 'endpoints.view', + 'empty': 'hide', + 'position': 16, + 'map': { + 'title': 'hostname', + 'detail': '', + 'meta': [{'key': 'eventcount', 'suffix': ' events (48h)'}], + 'link': '/pcs/{computerid}', + }, + }, + ] + + # ---- ADR-006 collector contract ------------------------------------- + + def get_collector_schema(self) -> Optional[Dict]: + return { + 'identityfield': 'hostname', + 'fields': { + 'type': 'object', + 'required': ['hostname'], + 'properties': { + 'hostname': { + 'type': 'string', + 'description': 'Endpoint hostname. Resolved against ' + 'the computers plugin (computers.hostname).', + }, + 'adopt': { + 'type': 'boolean', + 'description': 'First-contact only. When true and the ' + 'hostname does not resolve, adoption is ' + 'delegated to the computers collector ' + '(see DESIGN-DECISIONS-agent.md 1a). This ' + 'plugin never creates assets itself.', + }, + 'loggedinuser': {'type': 'string'}, + 'agentversion': {'type': 'string'}, + 'lastboottime': {'type': 'string', 'format': 'date-time'}, + 'uptimeseconds': {'type': 'integer'}, + 'idleseconds': {'type': 'integer'}, + 'cpupercent': {'type': 'number'}, + 'memorypercent': {'type': 'number'}, + 'services': { + 'type': 'array', + 'description': 'Monitored services/processes and their ' + 'run state (UDC, eDnc, Intune, ...).', + }, + 'ports': { + 'type': 'array', + 'description': 'Open listening ports.', + }, + 'interfaces': { + 'type': 'array', + 'description': 'Network interface information.', + }, + 'scheduledtasks': { + 'type': 'array', + 'description': 'Scheduled task inventory + last run.', + }, + 'events': { + 'type': 'array', + 'description': 'Curated Windows Event Log records. Only ' + 'IDs in the server catalog are stored.', + 'items': { + 'type': 'object', + 'required': ['eventid'], + 'properties': { + 'eventid': {'type': 'integer'}, + 'eventchannel': {'type': 'string'}, + 'eventsource': {'type': 'string'}, + 'eventtime': {'type': 'string', + 'format': 'date-time'}, + 'eventmessage': {'type': 'string'}, + 'fields': {'type': 'object'}, + }, + }, + }, + }, + }, + } + + # PC-identifying and hardware fields forwarded to the computers collector, + # which owns the Asset/Computer row and renders the Hardware and Network + # cards. The endpoints plugin only caches telemetry, so these must be + # relayed or vendor/model/OS/IP never land on the PC (see + # DESIGN-DECISIONS-agent.md 1a). + _COMPUTER_HANDOFF_FIELDS = ( + 'loggedinuser', 'serialnumber', 'pctype', 'machinenumber', + 'vendorname', 'modelnumber', 'osname', 'ipaddress', 'lastboottime', + ) + + def _forward_to_computers(self, hostname: str, payload: Dict): + """Return the computers plugin and the PC subset to hand off, or None. + + Both adoption and steady-state reports relay the same hardware subset, + so the Hardware and Network cards fill in on every report, not just at + first contact. + """ + from flask import current_app + + manager = current_app.extensions.get('plugin_manager') + computers = manager.get_plugin('computers') if manager else None + if computers is None: + return None, None + + subset = {'hostname': hostname} + for field in self._COMPUTER_HANDOFF_FIELDS: + if payload.get(field): + subset[field] = payload.get(field) + return computers, subset + + def _adopt_computer(self, hostname: str, payload: Dict, + warnings: List[str]) -> None: + """Delegate first-contact adoption to the computers collector. + + This plugin never creates Asset or Computer rows itself (see + DESIGN-DECISIONS-agent.md 1a). The computers plugin is the sole + authority for that, so on an unknown hostname we forward the + PC-identifying subset of the payload to its collector. It runs inside + the same transaction, so a later failure rolls the adoption back. + """ + computers, adoptpayload = self._forward_to_computers(hostname, payload) + if computers is None: + raise ValueError( + 'cannot adopt {!r}: computers plugin is not available' + .format(hostname)) + + computers.apply_collector_payload(adoptpayload) + db.session.flush() + warnings.append('adopted new computer for hostname: {!r}'.format(hostname)) + + def apply_collector_payload(self, payload: Dict) -> Dict: + """Idempotently upsert one endpoint's state and append its events. + + The cached EndpointState and any EndpointEvent rows are written in the + same transaction (the dispatcher owns the commit), so a reader never + sees a snapshot without the events that produced it. + """ + from plugins.computers.models import Computer + + warnings: List[str] = [] + + hostname = (payload.get('hostname') or '').strip() + if not hostname: + raise ValueError('hostname is required') + + computer = (Computer.query + .filter(db.func.lower(Computer.hostname) == hostname.lower()) + .first()) + if computer is None: + # First-contact adoption is delegated to the computers collector, + # which is the only authority allowed to create Asset + Computer + # rows (DESIGN-DECISIONS-agent.md 1a). This plugin never invents + # assets itself. Adoption runs in the dispatcher's transaction, so a + # later ValueError rolls the whole report back. + if payload.get('adopt'): + self._adopt_computer(hostname, payload, warnings) + computer = (Computer.query + .filter(db.func.lower(Computer.hostname) + == hostname.lower()) + .first()) + if computer is None: + raise ValueError( + 'could not resolve hostname to a computer: {!r}'.format(hostname)) + else: + # Already adopted: relay the hardware/identity subset on every + # report so the computers plugin keeps vendor/model/OS/IP current. + # Without this, Hardware and Network stay blank after adoption + # because this plugin only caches telemetry. + computers, subset = self._forward_to_computers(hostname, payload) + if computers is not None and len(subset) > 1: + computers.apply_collector_payload(subset) + db.session.flush() + + assetid = computer.assetid + + state = EndpointState.query.filter_by(assetid=assetid).first() + action = 'updated' + if state is None: + state = EndpointState(assetid=assetid) + db.session.add(state) + action = 'created' + + state.hostname = hostname + if 'loggedinuser' in payload: + state.loggedinuser = payload.get('loggedinuser') + if 'agentversion' in payload: + state.agentversion = payload.get('agentversion') + if 'lastboottime' in payload: + state.lastboottime = _parse_dt(payload.get('lastboottime')) + if 'uptimeseconds' in payload: + state.uptimeseconds = payload.get('uptimeseconds') + if 'idleseconds' in payload: + state.idleseconds = payload.get('idleseconds') + if 'cpupercent' in payload: + state.cpupercent = payload.get('cpupercent') + if 'memorypercent' in payload: + state.memorypercent = payload.get('memorypercent') + if 'services' in payload: + state.servicesjson = _as_json(payload.get('services')) + if 'ports' in payload: + state.portsjson = _as_json(payload.get('ports')) + if 'interfaces' in payload: + state.interfacesjson = _as_json(payload.get('interfaces')) + if 'scheduledtasks' in payload: + state.scheduledtasksjson = _as_json(payload.get('scheduledtasks')) + + state.lastreportedat = datetime.utcnow() + + stored_events = 0 + for record in (payload.get('events') or []): + try: + eventid = int(record.get('eventid')) + except (TypeError, ValueError): + warnings.append('skipped event with non-integer eventid') + continue + if eventid not in CURATED_EVENT_IDS: + # Outside the catalog: ignored on purpose, not an error. + continue + db.session.add(EndpointEvent( + assetid=assetid, + eventid=eventid, + eventchannel=record.get('eventchannel'), + eventsource=record.get('eventsource'), + eventtime=_parse_dt(record.get('eventtime')), + eventmessage=record.get('eventmessage'), + payloadjson=_as_json(record.get('fields')), + )) + stored_events += 1 + + # flush, not commit: the collector dispatcher owns the transaction. + db.session.flush() + + return { + 'action': action, + 'assetid': assetid, + 'identityvalue': hostname, + 'storedevents': stored_events, + 'warnings': warnings, + } \ No newline at end of file diff --git a/plugins/endpoints/tests/test_plugin.py b/plugins/endpoints/tests/test_plugin.py new file mode 100644 index 00000000..6001ab5f --- /dev/null +++ b/plugins/endpoints/tests/test_plugin.py @@ -0,0 +1,171 @@ +"""Endpoints plugin smoke + collector-contract tests. + +Asserts the plugin loads cleanly, satisfies the framework contract, and that +its ADR-006 collector schema is well formed. Extend with DB-backed ingest tests +as the plugin grows. +""" + +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(): + """meta returns a PluginMeta with the expected name and prefix.""" + plugin = EndpointsPlugin() + assert plugin.meta.name == 'endpoints' + assert plugin.meta.api_prefix == '/api/endpoints' + assert 'computers' in plugin.meta.dependencies + + +def test_endpoints_plugin_get_blueprint_returns_blueprint(): + """get_blueprint returns a Flask Blueprint, not None.""" + plugin = EndpointsPlugin() + assert isinstance(plugin.get_blueprint(), Blueprint) + + +def test_endpoints_plugin_get_models_returns_models(): + """get_models returns the two telemetry tables.""" + plugin = EndpointsPlugin() + models = plugin.get_models() + assert len(models) >= 2 + for model in models: + assert hasattr(model, '__tablename__') + + +def test_endpoints_collector_schema_identity_is_hostname(): + """ADR-006 schema declares hostname as the identity field.""" + plugin = EndpointsPlugin() + schema = plugin.get_collector_schema() + assert schema is not None + assert schema['identityfield'] == 'hostname' + assert 'hostname' in schema['fields']['required'] + + +def test_endpoints_curated_event_ids_include_core_security_ids(): + """The curated catalog covers the agreed logon/stability IDs.""" + for eventid in (4624, 4625, 4634, 4672, 4697, 4698, 41, 4618, 55, 7011, + 10016, 1001): + assert eventid in CURATED_EVENT_IDS + + +def test_curated_ids_match_the_event_catalog(): + """Ingest filter and display catalog cannot drift apart.""" + assert CURATED_EVENT_IDS == frozenset(EVENT_CATALOG) + + +def test_critical_event_ids_are_a_subset_of_the_catalog(): + """Dashboard stability card only queries known critical IDs.""" + assert set(CRITICAL_EVENT_IDS) + assert set(CRITICAL_EVENT_IDS).issubset(set(EVENT_CATALOG)) + + +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) == 5 + for panel in panels: + assert panel['assettypes'] == ['computer'] + assert panel['endpoint'].startswith('/api/endpoints/asset/{assetid}') + 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(): + """Quiet + stability exception cards are declared and hide when empty.""" + plugin = EndpointsPlugin() + widgets = plugin.get_dashboard_widgets() + ids = {w['id'] for w in widgets} + assert {'endpoints-quiet', 'endpoints-stability'} <= ids + for widget in widgets: + assert widget['render'] == 'exceptions' + assert widget['empty'] == 'hide' + assert widget['permission'] == 'endpoints.view' + + +def test_endpoints_navigation_item_points_at_fleet_view(): + """The fleet list view is exposed as a single /endpoints nav item.""" + plugin = EndpointsPlugin() + 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//telemetrymeta') for rule in rules) diff --git a/plugins/machines/frontend/views/MachineForm.vue b/plugins/machines/frontend/views/MachineForm.vue index cf8c28f2..15e603e8 100644 --- a/plugins/machines/frontend/views/MachineForm.vue +++ b/plugins/machines/frontend/views/MachineForm.vue @@ -123,7 +123,7 @@ >