diff --git a/.github/workflows/publish-images.yml b/.github/workflows/publish-images.yml new file mode 100644 index 00000000..a1d4fb64 --- /dev/null +++ b/.github/workflows/publish-images.yml @@ -0,0 +1,93 @@ +# Build and publish per-channel container images to GHCR. +# +# WHY this exists: pilot dev workstations sit behind an inspecting proxy +# (Zscaler) that resets npm's many small fetches mid-install, so building the +# image FROM SOURCE on a pilot box is unreliable. These runners +# (arc-runner-set) install npm cleanly - the CI `frontend` job already proves +# it - so we build the image ONCE here and pilots pull the finished image. +# A single image pull is one resumable HTTPS stream, far more robust through +# the proxy than hundreds of npm requests. +# +# Channels are mapped to how THIS repo actually ships, which is not how the +# branch this came from ships. Upstream, main is a release branch and dev-be is +# the trunk. Here main IS the trunk - it runs hundreds of commits past the last +# release - and releases are tags (ADR-007). Publishing main as :stable would +# put unreleased work behind a word that promises the opposite, to sites that +# have no way to know: +# +# tag v* -> ghcr.io//shopdb-flask:stable (badge off) the release +# main -> ghcr.io//shopdb-flask:edge (DEV badge) the trunk +# +# The image also carries a short-SHA tag for traceability and rollback. +# +# Automatic publishing is OFF until someone confirms the GHCR package exists +# under this owner and decides who may pull it. Re-enable by uncommenting the +# push: block below; workflow_dispatch works meanwhile. + +name: publish-images + +on: + # push: + # tags: + # - 'v*' + # branches: + # - main + # Manual rebuild/publish of the current ref's channel image. + workflow_dispatch: + +# One in-flight publish per branch; a newer push supersedes an older one. +concurrency: + group: publish-images-${{ github.ref }} + cancel-in-progress: true + +jobs: + build-and-push: + runs-on: arc-runner-set + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v5 + + # Lowercase the owner: GHCR repository paths must be lowercase, but + # github.repository_owner can carry uppercase (e.g. ge-aero is fine, but + # this keeps the workflow correct for any owner casing). + - name: Resolve image name and channel tag + id: meta + run: | + owner="$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')" + image="ghcr.io/${owner}/shopdb-flask" + # A tag is a release, anything else is the trunk. Deliberately not + # keyed on the branch name: a release here is a tag, so keying on + # "main" would label the trunk stable. + if [ "${{ github.ref_type }}" = "tag" ]; then + channel="stable" + else + channel="edge" + fi + echo "image=${image}" >> "$GITHUB_OUTPUT" + echo "channel=${channel}" >> "$GITHUB_OUTPUT" + echo "sha=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT" + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build and push channel image + uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: | + ${{ steps.meta.outputs.image }}:${{ steps.meta.outputs.channel }} + ${{ steps.meta.outputs.image }}:${{ steps.meta.outputs.channel }}-${{ steps.meta.outputs.sha }} + # Reuse the channel tag as a cache source/target so subsequent builds + # only redo layers that actually changed. + cache-from: type=registry,ref=${{ steps.meta.outputs.image }}:${{ steps.meta.outputs.channel }} + cache-to: type=inline diff --git a/.gitignore b/.gitignore index f2e77ab6..31591d72 100644 --- a/.gitignore +++ b/.gitignore @@ -132,9 +132,13 @@ docker_build.txt docker_up.txt gates_out.txt pytest_targeted.txt +pt*.txt alandry.py kendry.py +# Visual Studio local editor state (not product source). +.vs/ + # Scratch tooling under scripts/ (catalog diff/merge helpers, docx generator). # Not product source; developer-local utilities. scripts/.temp/ diff --git a/Dockerfile b/Dockerfile index df0156d7..91904fdb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -38,11 +38,40 @@ RUN if [ -f /tmp/corpca/corp-root-ca.pem ]; then \ 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 +# Trust the corp CA for npm's TLS only when it was actually installed above. The +# guard keeps NODE_EXTRA_CA_CERTS unset on a clean checkout (no cert), so Node +# does not warn about a missing cert path; a proxy-inspected build still gets +# the cert because the file is present. +# Install the frontend dependencies. vite and the rest of the build toolchain +# live under devDependencies, so include them explicitly (--include=dev). +# Behind an inspecting proxy (Zscaler) each package fetch is slow (40-50s) and +# npm's default concurrency piles up parallel sockets that the proxy resets +# mid-stream, making npm abort with "Exit handler never called" and leaving an +# incomplete node_modules with no vite. Serializing to a single socket +# (maxsockets 1) lets every fetch complete; the retry loop and raised timeouts +# ride out any remaining transient reset, and the final test hard-verifies the +# toolchain landed so a truncated install fails loudly here rather than as a +# confusing "vite: not found" three steps later. +RUN if [ -f /usr/local/share/ca-certificates/corp-root-ca.crt ]; then \ + export NODE_EXTRA_CA_CERTS=/usr/local/share/ca-certificates/corp-root-ca.crt; \ + fi; \ + cd frontend && \ + npm config set fetch-retries 5 && \ + npm config set fetch-retry-factor 2 && \ + npm config set fetch-retry-mintimeout 20000 && \ + npm config set fetch-retry-maxtimeout 120000 && \ + npm config set fetch-timeout 600000 && \ + npm config set maxsockets 1 && \ + ( npm ci --include=dev --no-audit --no-fund \ + || { echo "npm ci attempt 1 failed, retrying..."; sleep 5; npm ci --include=dev --no-audit --no-fund; } \ + || { echo "npm ci attempt 2 failed, retrying..."; sleep 10; npm ci --include=dev --no-audit --no-fund; } ) && \ + test -x node_modules/.bin/vite || { \ + echo "ERROR: node_modules/.bin/vite missing after npm ci (proxy likely truncated the install)."; \ + exit 1; \ + } # The frontend keeps its repo-relative layout here, because npm prebuild runs # `node ../scripts/stage-frontend.mjs`, which stages each plugin's frontend/ into @@ -54,7 +83,10 @@ COPY scripts/stage-frontend.mjs ./scripts/ COPY plugins/ ./plugins/ COPY frontend/ ./frontend/ -RUN cd frontend && npm run build +RUN if [ -f /usr/local/share/ca-certificates/corp-root-ca.crt ]; then \ + export NODE_EXTRA_CA_CERTS=/usr/local/share/ca-certificates/corp-root-ca.crt; \ + fi; \ + cd frontend && npm run build # Output lands in /build/frontend/dist (Vite default), copied into the final # stage below. diff --git a/agent/ShopDbEndpointAgent.Core/AgentConfig.cs b/agent/ShopDbEndpointAgent.Core/AgentConfig.cs index 86672779..991d857e 100644 --- a/agent/ShopDbEndpointAgent.Core/AgentConfig.cs +++ b/agent/ShopDbEndpointAgent.Core/AgentConfig.cs @@ -34,14 +34,23 @@ public sealed class AgentConfig [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 + // historically never carried a path; now a server-provisioned monitor MAY + // carry one, but the agent only accepts it after LaunchPathPolicy validates + // it against ApprovedLaunchRoots (see below). Start otherwise 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(); + // Approved root directories a server-provisioned launch path must sit under + // before the agent will honor it. Empty = fall back to LaunchPathPolicy's + // shipped org defaults (Program Files roots). A site (or the future Site + // Agent Configuration tool) can widen or narrow this without a rebuild. + [JsonPropertyName("approvedlaunchroots")] + public List ApprovedLaunchRoots { 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 @@ -162,6 +171,42 @@ public sealed class MonitorConfig [JsonPropertyName("services")] public MonitorTarget[] Services { get; set; } = Array.Empty(); + + // Union the local (appsettings.json) watch list with the server-resolved one, + // keyed case-insensitively by wire name so a target watched both locally and + // by policy is not enumerated twice. Server entries win on the label (App) so + // a policy display name overrides a stale local one, but a local-only target + // is preserved - the server list augments, it does not replace. Nulls on + // either side degrade to the other, which is what keeps monitoring alive when + // a fetch fails. + public static MonitorConfig Merge(MonitorConfig? local, ResolvedMonitors? server) + { + return new MonitorConfig + { + Processes = MergeTargets(local?.Processes, server?.Processes), + Services = MergeTargets(local?.Services, server?.Services), + }; + } + + private static MonitorTarget[] MergeTargets( + MonitorTarget[]? local, MonitorTarget[]? server) + { + var byName = new Dictionary( + StringComparer.OrdinalIgnoreCase); + foreach (var target in local ?? Array.Empty()) + { + if (string.IsNullOrWhiteSpace(target?.Name)) + continue; + byName[target.Name.Trim()] = target; + } + foreach (var target in server ?? Array.Empty()) + { + if (string.IsNullOrWhiteSpace(target?.Name)) + continue; + byName[target.Name.Trim()] = target; + } + return byName.Values.ToArray(); + } } public sealed class MonitorTarget @@ -172,4 +217,13 @@ public sealed class MonitorTarget // Human-facing application label, optional. Falls back to Name when empty. [JsonPropertyName("app")] public string App { get; set; } = ""; + + // Optional server-provisioned launch path for a process monitor. Only set on + // process targets that a site leader configured a path for. The agent NEVER + // launches this blindly: LaunchPathPolicy validates it against an approved- + // root allowlist first, and a local appsettings.json launchpaths entry for + // the same image still wins. Null/empty means "no server path" - the agent + // falls back to its local launch map, and process_start fails closed when + // neither yields a validated path. + [JsonPropertyName("launchpath")] public string? LaunchPath { get; set; } } diff --git a/agent/ShopDbEndpointAgent.Core/AgentFaultMonitor.cs b/agent/ShopDbEndpointAgent.Core/AgentFaultMonitor.cs index a46e98df..9db8776a 100644 --- a/agent/ShopDbEndpointAgent.Core/AgentFaultMonitor.cs +++ b/agent/ShopDbEndpointAgent.Core/AgentFaultMonitor.cs @@ -34,7 +34,15 @@ public sealed class AgentFaultMonitor } public void ReportFatal(string context, Exception error) - => Report(EventLogEntryType.Error, 1000, $"{context}: {error}"); + { + // File log first, always: the Event Log sink below is admin-gated (the + // source cannot be created without elevation), so an unelevated run such + // as the tray-launched --once/--report path would otherwise fail with no + // trace anywhere. The rolling file log is the one sink guaranteed to work + // in every context. + logger.LogError(error, "Fatal in {Context}", context); + Report(EventLogEntryType.Error, 1000, $"{context}: {error}"); + } private void OnUnhandledException(object sender, UnhandledExceptionEventArgs args) { diff --git a/agent/ShopDbEndpointAgent.Core/CollectorClient.cs b/agent/ShopDbEndpointAgent.Core/CollectorClient.cs index 4c4d30d9..6f119139 100644 --- a/agent/ShopDbEndpointAgent.Core/CollectorClient.cs +++ b/agent/ShopDbEndpointAgent.Core/CollectorClient.cs @@ -89,6 +89,42 @@ public sealed class CollectorClient : IDisposable return envelope.Data; } + // Fetch the server-resolved watch list (policy-attached processes/services) + // for this hostname, GET /api/endpoints/monitors/resolved. Shares the same + // X-API-Key credential as telemetry. Best-effort: returns null on any + // failure so the caller falls back to the last-known/local list rather than + // blanking monitoring on a transient blip. A single attempt, no retry - the + // next telemetry cycle retries naturally, and a stale-but-usable watch list + // beats an empty one. + public async Task FetchResolvedMonitorsAsync( + string hostname, CancellationToken token) + { + var url = "api/endpoints/monitors/resolved?hostname=" + + Uri.EscapeDataString(hostname); + try + { + using var response = await http.GetAsync(url, token); + var body = await response.Content.ReadAsStringAsync(token); + if (!response.IsSuccessStatusCode) + { + logger.LogWarning( + "Resolved-monitors fetch returned HTTP {Status}: {Body}", + (int)response.StatusCode, body); + return null; + } + var envelope = JsonSerializer.Deserialize( + body, JsonOptions); + return envelope?.Data; + } + catch (Exception error) when ( + error is HttpRequestException or TaskCanceledException or JsonException + && !token.IsCancellationRequested) + { + logger.LogWarning(error, "Resolved-monitors fetch failed; keeping current watch list"); + return null; + } + } + private TimeSpan BackoffDelay(int attempt) { // 2^(attempt-1) seconds, capped, plus up to 1s jitter. @@ -96,6 +132,53 @@ public sealed class CollectorClient : IDisposable return TimeSpan.FromSeconds(seconds + jitter.NextDouble()); } + // Uploads one operator issue report (free-text reason/details plus an + // optional PNG screenshot) to POST /api/endpoints/operatorreports as + // multipart/form-data, authorized by the same X-API-Key collector key as + // telemetry. The server resolves this hostname to its asset. A single + // attempt: the tray shows the operator the outcome and can retry by hand, + // and a stuck retry loop holding a screenshot in memory is worse than a + // clear failure. Returns the parsed upload response (new row id) or throws. + public async Task UploadOperatorReportAsync( + string reason, string details, string? screenshotPath, string hostname, + CancellationToken token) + { + using var form = new MultipartFormDataContent(); + form.Add(new StringContent(reason ?? string.Empty), "reason"); + form.Add(new StringContent(details ?? string.Empty), "details"); + form.Add(new StringContent(hostname ?? string.Empty), "hostname"); + + StreamContent? imageContent = null; + FileStream? imageStream = null; + try + { + if (!string.IsNullOrWhiteSpace(screenshotPath) && File.Exists(screenshotPath)) + { + imageStream = File.OpenRead(screenshotPath); + imageContent = new StreamContent(imageStream); + imageContent.Headers.ContentType = + new System.Net.Http.Headers.MediaTypeHeaderValue("image/png"); + form.Add(imageContent, "screenshot", + Path.GetFileName(screenshotPath)); + } + + using var response = await http.PostAsync( + "api/endpoints/operatorreports", form, token); + 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; + } + finally + { + imageContent?.Dispose(); + imageStream?.Dispose(); + } + } + public void Dispose() => http.Dispose(); } diff --git a/agent/ShopDbEndpointAgent.Core/CommandClient.cs b/agent/ShopDbEndpointAgent.Core/CommandClient.cs index 0dcc395c..f1ad6d6e 100644 --- a/agent/ShopDbEndpointAgent.Core/CommandClient.cs +++ b/agent/ShopDbEndpointAgent.Core/CommandClient.cs @@ -22,12 +22,26 @@ public sealed class CommandClient : IDisposable }; private readonly HttpClient http; + private readonly SocketsHttpHandler handler; private readonly ILogger logger; public CommandClient(AgentConfig config, ILogger logger) { this.logger = logger; - http = new HttpClient + // The command channel long-polls: the server holds each claim open for a + // hold window and then, between windows, the pooled TLS connection sits + // idle. Gunicorn's keep-alive closes an idle socket after a couple of + // seconds, so a connection reused after that yields "unexpected EOF or 0 + // bytes from the transport stream" on the next TLS handshake. Retire + // pooled connections faster than the server's keep-alive so a socket the + // server has already dropped is never handed back out; the lifetime cap + // is a hard backstop so no connection lingers indefinitely. + handler = new SocketsHttpHandler + { + PooledConnectionIdleTimeout = TimeSpan.FromSeconds(1), + PooledConnectionLifetime = TimeSpan.FromMinutes(2), + }; + http = new HttpClient(handler, disposeHandler: true) { BaseAddress = new Uri(config.BaseUrl.TrimEnd('/') + "/"), // Longer than the collector client: the claim request is held open diff --git a/agent/ShopDbEndpointAgent.Core/CommandExecutor.cs b/agent/ShopDbEndpointAgent.Core/CommandExecutor.cs index 9ffae27c..7db7a198 100644 --- a/agent/ShopDbEndpointAgent.Core/CommandExecutor.cs +++ b/agent/ShopDbEndpointAgent.Core/CommandExecutor.cs @@ -56,9 +56,14 @@ public sealed class CommandExecutor // 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. + // map - and without a validated server-provisioned path - cannot be started, + // so process_start fails closed. private readonly IReadOnlyDictionary launchPaths; + // Validates server-provisioned launch paths (from policy-attached monitors) + // before they are honored. Local launchPaths always win over a server path. + private readonly LaunchPathPolicy launchPathPolicy; + // Held for update_app: the agent joins InstallPath to BaseUrl and downloads // the pinned installer with its own collector credential (ApiKey), so the // installer body never rides the command wire. @@ -71,6 +76,7 @@ public sealed class CommandExecutor this.startTimeout = startTimeout ?? TimeSpan.FromSeconds(30); this.config = config ?? new AgentConfig(); this.launchPaths = this.config.ResolveLaunchPaths(); + this.launchPathPolicy = new LaunchPathPolicy(this.config.ApprovedLaunchRoots); } public async Task ExecuteAsync( @@ -366,8 +372,8 @@ public sealed class CommandExecutor if (bare.EndsWith(".exe", StringComparison.OrdinalIgnoreCase)) bare = bare[..^4]; - if (!launchPaths.TryGetValue(bare, out var launchPath) - || string.IsNullOrWhiteSpace(launchPath)) + var launchPath = ResolveLaunchPath(bare, image); + if (string.IsNullOrWhiteSpace(launchPath)) { logger.LogWarning( "Rejected process_start for {Image}: no launchable path configured", @@ -452,6 +458,62 @@ public sealed class CommandExecutor } } + // Resolves the launchable path for a process image. Precedence: + // 1. Local launch map (shipped defaults + appsettings.json). A site pin + // always wins so a server value never silently overrides it. + // 2. A server-provisioned path on the matching process monitor, but only + // after LaunchPathPolicy validates it (approved root, exists, launchable + // extension). A rejected server path is logged and dropped - the caller + // then fails closed exactly as before. + // Returns null when neither source yields a usable path. + private string? ResolveLaunchPath(string bareImage, string image) + { + if (launchPaths.TryGetValue(bareImage, out var localPath) + && !string.IsNullOrWhiteSpace(localPath)) + { + return localPath; + } + + var serverPath = ServerLaunchPathFor(image, bareImage); + if (string.IsNullOrWhiteSpace(serverPath)) + return null; + + if (launchPathPolicy.TryValidate(serverPath, out var validated, out var reason)) + return validated; + + logger.LogWarning( + "Rejected server-provisioned launch path for {Image} ({Path}): {Reason}", + image, serverPath, reason); + return null; + } + + // Finds the server-provisioned launch path carried on the process monitor + // whose wire name matches the image (case-insensitive, .exe optional). + private string? ServerLaunchPathFor(string image, string bareImage) + { + var processes = config.Monitors?.Processes; + if (processes is null) + return null; + + foreach (var target in processes) + { + if (string.IsNullOrWhiteSpace(target?.Name) + || string.IsNullOrWhiteSpace(target.LaunchPath)) + continue; + + var name = target.Name.Trim(); + var bareName = name.EndsWith(".exe", StringComparison.OrdinalIgnoreCase) + ? name[..^4] + : name; + if (string.Equals(name, image, StringComparison.OrdinalIgnoreCase) + || string.Equals(bareName, bareImage, StringComparison.OrdinalIgnoreCase)) + { + return target.LaunchPath; + } + } + return null; + } + private bool IsProcessRunning(string image) { var processes = FindProcessesByImage(image); diff --git a/agent/ShopDbEndpointAgent.Core/Contracts.cs b/agent/ShopDbEndpointAgent.Core/Contracts.cs index d4c92a0a..37a7f000 100644 --- a/agent/ShopDbEndpointAgent.Core/Contracts.cs +++ b/agent/ShopDbEndpointAgent.Core/Contracts.cs @@ -72,6 +72,20 @@ public sealed class CollectorEnvelope [JsonPropertyName("data")] public CollectorResponse? Data { get; set; } } +// Server response to an operator issue report upload. Carries the new row id so +// the agent can log it for correlation. +public sealed class OperatorReportUploadResponse +{ + [JsonPropertyName("operatorreportid")] public int? OperatorReportId { get; set; } + [JsonPropertyName("assetid")] public int? AssetId { get; set; } +} + +public sealed class OperatorReportUploadEnvelope +{ + [JsonPropertyName("status")] public string Status { get; set; } = ""; + [JsonPropertyName("data")] public OperatorReportUploadResponse? 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 - @@ -147,3 +161,24 @@ public sealed class CommandResult [JsonPropertyName("mtconnectrunning")] public bool? MtConnectRunning { get; set; } [JsonPropertyName("detail")] public Dictionary? Detail { get; set; } } + +// The server-resolved watch list for this endpoint (processes and services the +// site's policy attaches to the box's PC type), fetched each telemetry cycle so +// a policy-attached monitor is enumerated without editing appsettings.json on +// the box. Same {name, app} shape as the local MonitorConfig, so the merge is a +// direct list union. Empty lists mean "watch nothing extra beyond local". +public sealed class ResolvedMonitors +{ + [JsonPropertyName("processes")] public MonitorTarget[] Processes { get; set; } + = Array.Empty(); + + [JsonPropertyName("services")] public MonitorTarget[] Services { get; set; } + = Array.Empty(); +} + +// Envelope for the resolved-monitors route: { status, data: { processes, services } }. +public sealed class ResolvedMonitorsEnvelope +{ + [JsonPropertyName("status")] public string Status { get; set; } = ""; + [JsonPropertyName("data")] public ResolvedMonitors? Data { get; set; } +} diff --git a/agent/ShopDbEndpointAgent.Core/LaunchPathPolicy.cs b/agent/ShopDbEndpointAgent.Core/LaunchPathPolicy.cs new file mode 100644 index 00000000..94755761 --- /dev/null +++ b/agent/ShopDbEndpointAgent.Core/LaunchPathPolicy.cs @@ -0,0 +1,238 @@ +using System.Text.Json.Serialization; + +namespace ShopDbEndpointAgent.Core; + +// Guards server-provisioned launch paths. Historically the command wire never +// carried a launch path at all - the agent mapped an image name to a known-good +// local path and anything else failed closed. Server-provisioned monitors now +// MAY carry a path a site leader set in the WebUI, which is a deliberate +// relaxation of that invariant. This policy is the replacement safeguard: a +// server path is only honored when it sits under an approved root, actually +// exists, and is an approved executable type. Everything else is rejected and +// the agent falls back to its local launch map (still fail closed). +// +// The approved-root list is org-opinionated with shipped defaults (the Windows +// Program Files roots), overridable per site via AgentConfig.ApprovedLaunchRoots +// so the future Site Agent Configuration tool can widen or narrow it without a +// rebuild. A local appsettings.json launchpaths entry still wins over any +// server path, so a site pin is never silently overridden. +public sealed class LaunchPathPolicy +{ + // Only these extensions are launchable. .bat is included because some shop- + // floor apps ship a launcher script (see the eDNC/UDC run.bat pattern). + private static readonly string[] ApprovedExtensions = + { ".exe", ".bat", ".cmd" }; + + // Shipped org defaults: the standard 64- and 32-bit install roots. Resolved + // from the environment so a non-C: system drive still matches. + public static IReadOnlyList DefaultApprovedRoots { get; } = + BuildDefaultRoots(); + + private readonly IReadOnlyList approvedRoots; + + public LaunchPathPolicy(IEnumerable? approvedRootsOverride = null) + { + var roots = (approvedRootsOverride ?? Enumerable.Empty()) + .Where(root => !string.IsNullOrWhiteSpace(root)) + .Select(NormalizeRoot) + .ToList(); + approvedRoots = roots.Count > 0 ? roots : DefaultApprovedRoots; + } + + // Result of validating a candidate path: the normalized full path when it is + // acceptable, or a human-readable reason when it is rejected. + public bool TryValidate(string? candidate, out string fullPath, out string reason) + { + fullPath = ""; + reason = ""; + + if (string.IsNullOrWhiteSpace(candidate)) + { + reason = "empty path"; + return false; + } + + string resolved; + try + { + resolved = Path.GetFullPath(candidate.Trim()); + } + catch (Exception ex) + { + reason = $"not a valid path: {ex.Message}"; + return false; + } + + // Reject an alternate data stream before anything else looks at the + // path. On .NET Framework an interior colon made GetFullPath throw; on + // net8.0 it does not, so "readme.txt:payload.exe" survives, reports an + // .exe extension, passes the root check, and File.Exists returns true + // for the stream. CreateProcess will run it. Write access to any + // existing file under an approved root would otherwise be enough. + var lastSegment = Path.GetFileName(resolved); + if (lastSegment.Contains(':')) + { + reason = "path names an alternate data stream"; + return false; + } + + var extension = Path.GetExtension(resolved); + if (!ApprovedExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase)) + { + reason = $"extension '{extension}' is not launchable"; + return false; + } + + if (!IsUnderApprovedRoot(resolved)) + { + reason = "path is outside every approved launch root"; + return false; + } + + if (!File.Exists(resolved)) + { + reason = "file does not exist"; + return false; + } + + // Everything above is LEXICAL: GetFullPath is pure string normalization + // and never touches the disk, so the root check so far constrains the + // name, not the file. File.Exists follows reparse points, which means a + // junction anywhere under an approved root pointed the check at one + // place and the execution at another. Creating a junction needs no + // privilege, only write access to the parent - and vendor install trees + // under Program Files are not reliably locked down. + // + // So resolve what the path actually refers to and re-check THAT. A + // resolve failure rejects: this is the last gate before the agent runs + // the file. + string finalPath; + try + { + finalPath = ResolveFinalPath(resolved); + } + catch (Exception ex) + { + reason = $"cannot resolve the real path: {ex.Message}"; + return false; + } + + if (!IsUnderApprovedRoot(finalPath)) + { + reason = "path resolves outside every approved launch root"; + return false; + } + + fullPath = finalPath; + return true; + } + + // The path with every symlink and junction on it followed, so the caller + // checks the file that will actually execute. The leaf is resolved first, + // then the directory chain above it - a junction is usually placed on a + // DIRECTORY, so resolving only the leaf would miss the case this exists to + // catch. + private static string ResolveFinalPath(string resolved) + { + string? name; + try + { + name = System.IO.File.ResolveLinkTarget(resolved, returnFinalTarget: true)?.FullName; + } + catch + { + name = null; + } + if (!string.IsNullOrWhiteSpace(name)) + { + // ResolveLinkTarget resolves the file itself; run it back through + // GetFullPath so the comparison sees the same shape as the roots. + return Path.GetFullPath(name); + } + + // Not a link. Its directory chain still might be, so resolve that. + var directory = Path.GetDirectoryName(resolved); + if (string.IsNullOrEmpty(directory)) + return resolved; + var realDirectory = ResolveDirectoryChain(directory); + return Path.GetFullPath(Path.Combine(realDirectory, Path.GetFileName(resolved))); + } + + // Walk up the directory chain resolving any junction or symlink on it. A + // junction is usually placed on a DIRECTORY, not on the executable, so + // resolving only the leaf would miss the case this exists to catch. + private static string ResolveDirectoryChain(string directory) + { + // Stop at the volume root. Walking into it and asking whether "C:\" is + // a link throws DirectoryNotFoundException, which the caller turned + // into a rejection - so the first version of this rejected every + // legitimate path as well as the malicious ones. A fail-closed gate + // that also fails the normal case is just an outage. Caught in the VM, + // not here. + var parent = Path.GetDirectoryName(directory); + if (string.IsNullOrEmpty(parent) || parent == directory) + return directory; + + var resolvedDirectory = TryResolveLink(directory); + if (resolvedDirectory is not null) + return resolvedDirectory; + + var realParent = ResolveDirectoryChain(parent); + return realParent == parent + ? directory + : Path.Combine(realParent, Path.GetFileName(directory)); + } + + // A component's link target, or null when it is not a link OR cannot be + // asked. Never throws: "cannot tell" must not be reported as "is a link", + // and the caller re-checks the assembled path against the roots regardless, + // so an unresolvable component cannot widen what is accepted. + private static string? TryResolveLink(string path) + { + try + { + var target = System.IO.Directory + .ResolveLinkTarget(path, returnFinalTarget: true)?.FullName; + return string.IsNullOrWhiteSpace(target) ? null : target; + } + catch + { + return null; + } + } + + private bool IsUnderApprovedRoot(string resolved) + { + foreach (var root in approvedRoots) + { + // Compare against root + separator so "C:\Program Files" does not + // also match "C:\Program FilesEvil\...". Case-insensitive on Windows. + var prefix = root.EndsWith(Path.DirectorySeparatorChar) + ? root + : root + Path.DirectorySeparatorChar; + if (resolved.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + return true; + } + return false; + } + + private static string NormalizeRoot(string root) + { + var full = Path.GetFullPath(root.Trim()); + return full.TrimEnd(Path.DirectorySeparatorChar); + } + + private static IReadOnlyList BuildDefaultRoots() + { + var candidates = new[] + { + Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), + Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86), + }; + return candidates + .Where(path => !string.IsNullOrWhiteSpace(path)) + .Select(NormalizeRoot) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + } +} diff --git a/agent/ShopDbEndpointAgent.Core/OperatorReportPayload.cs b/agent/ShopDbEndpointAgent.Core/OperatorReportPayload.cs new file mode 100644 index 00000000..0402a1b3 --- /dev/null +++ b/agent/ShopDbEndpointAgent.Core/OperatorReportPayload.cs @@ -0,0 +1,52 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace ShopDbEndpointAgent.Core; + +// The on-disk hand-off between the interactive tray (which captures the +// operator's screen and notes) and the headless service binary (which holds the +// collector key and performs the upload). The tray writes this JSON sidecar to a +// per-run temp folder and invokes `shopdbagent.exe --report `; the agent +// reads it, uploads, and deletes the folder. Keeping the secret in the service +// preserves the "tray never holds a credential" rule already used by --once. +public sealed class OperatorReportPayload +{ + [JsonPropertyName("reason")] + public string Reason { get; set; } = "Other (free text)"; + + [JsonPropertyName("details")] + public string Details { get; set; } = string.Empty; + + // Absolute path to the captured PNG, or null when the operator declined a + // screenshot. Written by the tray into the interactive operator's + // %USERPROFILE%\ShopDBScreenshots folder and passed through as an absolute + // path; the agent reads it verbatim and never re-derives the location (as + // SYSTEM its own profile would resolve elsewhere). + [JsonPropertyName("screenshotpath")] + public string? ScreenshotPath { get; set; } + + private static readonly JsonSerializerOptions Options = new() + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + WriteIndented = true, + }; + + public static void Write(string filePath, string reason, string details, + string? screenshotPath) + { + var payload = new OperatorReportPayload + { + Reason = reason, + Details = details, + ScreenshotPath = screenshotPath, + }; + File.WriteAllText(filePath, JsonSerializer.Serialize(payload, Options)); + } + + public static OperatorReportPayload Read(string filePath) + { + var json = File.ReadAllText(filePath); + return JsonSerializer.Deserialize(json, Options) + ?? throw new InvalidDataException("empty or invalid report payload"); + } +} diff --git a/agent/ShopDbEndpointAgent.Core/TelemetryWorker.cs b/agent/ShopDbEndpointAgent.Core/TelemetryWorker.cs index 1fcd3412..2efa847d 100644 --- a/agent/ShopDbEndpointAgent.Core/TelemetryWorker.cs +++ b/agent/ShopDbEndpointAgent.Core/TelemetryWorker.cs @@ -96,6 +96,21 @@ public sealed class TelemetryWorker : BackgroundService // exact production collection path. public async Task RunOnceAsync(bool adopt, CancellationToken token) { + // Refresh the watch list from server policy BEFORE collecting so a + // policy-attached monitor (added in the WebUI, never in appsettings.json) + // is enumerated and reported this cycle. Server entries merge over the + // local list; a failed fetch returns null and Merge keeps the local list, + // so monitoring never blanks on a transient blip. + var resolved = await client.FetchResolvedMonitorsAsync( + Environment.MachineName, token); + if (resolved is not null) + { + config.Monitors = MonitorConfig.Merge(config.Monitors, resolved); + logger.LogDebug( + "Watch list refreshed from policy: {Processes} process(es), {Services} service(s)", + config.Monitors.Processes.Length, config.Monitors.Services.Length); + } + var report = collector.Collect(agentVersion, adopt); if (utilities is not null) diff --git a/agent/ShopDbEndpointAgent.PolicyProbe/Program.cs b/agent/ShopDbEndpointAgent.PolicyProbe/Program.cs new file mode 100644 index 00000000..bee73158 --- /dev/null +++ b/agent/ShopDbEndpointAgent.PolicyProbe/Program.cs @@ -0,0 +1,97 @@ +using ShopDbEndpointAgent.Core; + +// Planted-violation suite for LaunchPathPolicy, run on real Windows because the +// cases that matter (junctions, alternate data streams, 8.3 names) are +// filesystem behaviour, not string behaviour. Linux would prove nothing. +// +// Builds its own fixtures under an approved root, so it exercises the same +// shape as a real shop-floor install rather than a synthetic path. + +int failures = 0; + +void Check(string label, bool expectAccept, string candidate, LaunchPathPolicy policy) +{ + var ok = policy.TryValidate(candidate, out var full, out var reason); + var pass = ok == expectAccept; + if (!pass) failures++; + var verdict = pass ? "PASS" : "FAIL"; + var got = ok ? $"accepted -> {full}" : $"rejected ({reason})"; + Console.WriteLine($"{verdict} {label}"); + Console.WriteLine($" want={(expectAccept ? "accept" : "reject")} got={got}"); +} + +var root = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "LppFixture"); +var outside = Path.Combine(Path.GetTempPath(), "LppOutside"); +Directory.CreateDirectory(root); +Directory.CreateDirectory(outside); + +var policy = new LaunchPathPolicy(new[] { root }); + +// A real executable inside the approved root. +var good = Path.Combine(root, "good.exe"); +File.WriteAllBytes(good, new byte[] { 0x4D, 0x5A }); + +// The payload the attacker wants run, planted OUTSIDE the approved root. +var payload = Path.Combine(outside, "payload.exe"); +File.WriteAllBytes(payload, new byte[] { 0x4D, 0x5A }); + +// A text file inside the root, to hang an alternate data stream off. +var carrier = Path.Combine(root, "readme.txt"); +File.WriteAllText(carrier, "notes"); + +Console.WriteLine($"approved root : {root}"); +Console.WriteLine($"outside dir : {outside}"); +Console.WriteLine(); + +Check("a real exe under the approved root", true, good, policy); +Check("wrong extension", false, carrier, policy); +Check("file that does not exist", false, Path.Combine(root, "nope.exe"), policy); +Check("traversal back out of the root", false, + Path.Combine(root, "..", "..", "Windows", "System32", "cmd.exe"), policy); +Check("UNC path", false, @"\\attacker\share\payload.exe", policy); +Check("sibling directory sharing the root's prefix", false, root + "Evil\\payload.exe", policy); +Check("empty path", false, "", policy); + +// --- alternate data stream -------------------------------------------------- +try +{ + File.WriteAllBytes(carrier + ":payload.exe", new byte[] { 0x4D, 0x5A }); + Check("alternate data stream on a file in the root", false, + carrier + ":payload.exe", policy); +} +catch (Exception ex) +{ + Console.WriteLine($"SKIP alternate data stream fixture: {ex.Message}"); +} + +// --- directory junction out of the root ------------------------------------ +var junction = Path.Combine(root, "logs"); +var mk = System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo +{ + FileName = "cmd.exe", + Arguments = $"/c mklink /J \"{junction}\" \"{outside}\"", + UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, +}); +mk!.WaitForExit(); +if (mk.ExitCode == 0) +{ + Check("exe reached through a junction pointing out of the root", false, + Path.Combine(junction, "payload.exe"), policy); +} +else +{ + Console.WriteLine($"SKIP junction fixture: mklink exit {mk.ExitCode} {mk.StandardError.ReadToEnd().Trim()}"); +} + +// --- 8.3 short name --------------------------------------------------------- +// 8.3 short names used to be rejected, because the lexical prefix check could +// not match "C:\PROGRA~1" against "C:\Program Files" - fail closed, but only by +// accident of not understanding the path. Resolution now expands it to the real +// directory, which IS under the approved root, so accepting is correct: the +// check that matters runs on the resolved path either way. +var shortRoot = Path.Combine(@"C:\PROGRA~1", "LppFixture", "good.exe"); +Check("8.3 short name resolving into the approved root", true, shortRoot, policy); + +Console.WriteLine(); +Console.WriteLine(failures == 0 ? "ALL EXPECTATIONS MET" : $"{failures} EXPECTATION(S) NOT MET"); +return failures == 0 ? 0 : 1; diff --git a/agent/ShopDbEndpointAgent.PolicyProbe/README.md b/agent/ShopDbEndpointAgent.PolicyProbe/README.md new file mode 100644 index 00000000..390a67bb --- /dev/null +++ b/agent/ShopDbEndpointAgent.PolicyProbe/README.md @@ -0,0 +1,33 @@ +# LaunchPathPolicy probe + +A planted-violation probe for `LaunchPathPolicy`. It builds real fixtures under +a real approved root - a junction pointing out of it, an alternate data stream, +an 8.3 short name - and asserts what the policy does with each. + +## Why it is not a unit test in CI + +The cases worth testing are filesystem behaviour, not string behaviour: +junctions, alternate data streams and 8.3 short names do not exist on Linux, and +CI runs on Linux runners. A suite that skipped every meaningful case there would +report green and mean nothing, which is the failure shape this repo has already +been bitten by more than once. So it is a probe you run on Windows, by hand, +when the policy changes. + +## Running it + +Publish from the dev box and run it in the win11 VM: + + dotnet publish agent/ShopDbEndpointAgent.PolicyProbe -c Release -r win-x64 \ + --self-contained true -p:PublishSingleFile=true -o /tmp/lpp + +Copy `lpp/lpptest.exe` into the VM and run it as an account that may create a +junction under Program Files. Exit code 0 means every expectation held; it +prints one line per case either way. + +## What it caught + +The first version of the link-resolving hardening recursed to the volume root +and asked whether `C:\` was a link. That throws, and the caller treated a +resolve failure as a rejection - so the policy rejected every legitimate launch +path as well as the malicious ones. It compiled cleanly and read correctly. Only +running it on Windows showed the agent would have failed closed on everything. diff --git a/agent/ShopDbEndpointAgent.PolicyProbe/ShopDbEndpointAgent.PolicyProbe.csproj b/agent/ShopDbEndpointAgent.PolicyProbe/ShopDbEndpointAgent.PolicyProbe.csproj new file mode 100644 index 00000000..9112673f --- /dev/null +++ b/agent/ShopDbEndpointAgent.PolicyProbe/ShopDbEndpointAgent.PolicyProbe.csproj @@ -0,0 +1,20 @@ + + + + Exe + net8.0 + enable + enable + lpptest + LppTest + true + + + + + diff --git a/agent/ShopDbEndpointAgent.Tray/Program.cs b/agent/ShopDbEndpointAgent.Tray/Program.cs index 80e0f0cd..4e8cdc0a 100644 --- a/agent/ShopDbEndpointAgent.Tray/Program.cs +++ b/agent/ShopDbEndpointAgent.Tray/Program.cs @@ -1,6 +1,8 @@ +using System.Threading; + namespace ShopDbEndpointAgent.Tray; -// Interactive user-session tray app (SCAFFOLD). Separate process from the +// Interactive user-session tray app (SCAFFOLD). // 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 @@ -8,9 +10,35 @@ namespace ShopDbEndpointAgent.Tray; // is deferred until that decision is made server-side. internal static class Program { + // Machine-wide single-instance guard. The installer starts the tray from an + // HKLM Run value for every user at logon, and an operator can also launch it + // from the Start Menu shortcut, so a second copy is easy to spawn. A named + // mutex in the Global namespace keeps exactly one tray per session: a later + // launch that cannot own the mutex exits immediately instead of stacking a + // duplicate icon. Global\ scopes across sessions on the same machine. + private const string SingleInstanceMutexName = @"Global\ShopDbEndpointAgentTray"; + [STAThread] - private static void Main() + private static void Main(string[] args) { + // Headless icon export for the installer: `--export-icon ` writes + // the branded .ico and exits without starting the tray, so install.ps1 + // can point the Start Menu shortcut at a real brand icon. Kept in this + // exe so the icon has a single source (TrayIcon) and no binary is + // checked into the repo. + if (args.Length >= 2 && + string.Equals(args[0], "--export-icon", StringComparison.OrdinalIgnoreCase)) + { + TrayIcon.Export(args[1]); + return; + } + + using var singleInstance = new Mutex(true, SingleInstanceMutexName, out bool owned); + if (!owned) + { + return; + } + ApplicationConfiguration.Initialize(); Application.Run(new TrayContext()); } diff --git a/agent/ShopDbEndpointAgent.Tray/ScreenCapture.cs b/agent/ShopDbEndpointAgent.Tray/ScreenCapture.cs new file mode 100644 index 00000000..675b628e --- /dev/null +++ b/agent/ShopDbEndpointAgent.Tray/ScreenCapture.cs @@ -0,0 +1,34 @@ +using System.Drawing.Imaging; + +namespace ShopDbEndpointAgent.Tray; + +// Captures the full virtual desktop (all monitors) to a PNG on disk, porting +// the operator PowerShell proof of concept: +// CopyFromScreen over SystemInformation.VirtualScreen, then Bitmap.Save as +// PNG. Kept in the tray project because only the interactive tray session can +// see the operator's desktop; the headless service runs in session 0 and +// would capture a black frame. +internal static class ScreenCapture +{ + // Grab every monitor in one shot. VirtualScreen spans the whole desktop so + // a multi-head shop-floor PC is captured intact rather than just the + // primary display. Returns the saved file path. + public static string CaptureToPng(string filePath) + { + var bounds = SystemInformation.VirtualScreen; + using var bitmap = new Bitmap(bounds.Width, bounds.Height, + PixelFormat.Format32bppArgb); + using (var graphics = Graphics.FromImage(bitmap)) + { + graphics.CopyFromScreen(bounds.Left, bounds.Top, 0, 0, + bounds.Size, CopyPixelOperation.SourceCopy); + } + + var directory = Path.GetDirectoryName(filePath); + if (!string.IsNullOrEmpty(directory)) + Directory.CreateDirectory(directory); + + bitmap.Save(filePath, ImageFormat.Png); + return filePath; + } +} diff --git a/agent/ShopDbEndpointAgent.Tray/TicketForm.cs b/agent/ShopDbEndpointAgent.Tray/TicketForm.cs index 392418e2..f953dcc8 100644 --- a/agent/ShopDbEndpointAgent.Tray/TicketForm.cs +++ b/agent/ShopDbEndpointAgent.Tray/TicketForm.cs @@ -1,22 +1,20 @@ 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. +// The operator issue reporter. Collects a free-text description and an optional +// screenshot request, then hands the result back to TrayContext which performs +// the capture (after this dialog closes, so it never appears in the shot) and +// the upload via the service binary. +// +// Only "Other (free text)" is offered today: the fixed-reason routing from the +// original design is not wired to a ticketing backend, so a single free-text +// path is the one flow that works end to end. The dropdown is kept (disabled, +// preselected) so the richer reason list can return without reworking the form. internal sealed class TicketForm : Form { - // Fixed dropdown reasons from the source conversation (section 6). + // Only the free-text reason is functional. Kept as a one-item list so the + // control and its layout stay in place for the future reason routing. 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)", }; @@ -24,6 +22,11 @@ internal sealed class TicketForm : Form private readonly TextBox detailBox; private readonly CheckBox screenshotBox; + // Collected result, read by TrayContext after DialogResult.OK. + public string Reason => reasonBox.SelectedItem?.ToString() ?? "Other (free text)"; + public string Details => detailBox.Text?.Trim() ?? string.Empty; + public bool IncludeScreenshot => screenshotBox.Checked; + public TicketForm() { Text = "Report an issue"; @@ -46,13 +49,16 @@ internal sealed class TicketForm : Form Top = 40, Width = 388, DropDownStyle = ComboBoxStyle.DropDownList, + // Disabled until the fixed-reason routing exists; free text is the + // only functional path, so the selection is fixed on it. + Enabled = false, }; reasonBox.Items.AddRange(Reasons); reasonBox.SelectedIndex = 0; var detailLabel = new Label { - Text = "Details (optional)", + Text = "Details", Left = 16, Top = 76, Width = 388, @@ -72,6 +78,7 @@ internal sealed class TicketForm : Form Left = 16, Top = 188, Width = 388, + Checked = true, }; var submit = new Button @@ -82,7 +89,6 @@ internal sealed class TicketForm : Form Width = 76, DialogResult = DialogResult.OK, }; - submit.Click += OnSubmit; var cancel = new Button { @@ -101,18 +107,4 @@ internal sealed class TicketForm : Form 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 index 93827706..f6b80d72 100644 --- a/agent/ShopDbEndpointAgent.Tray/TrayContext.cs +++ b/agent/ShopDbEndpointAgent.Tray/TrayContext.cs @@ -1,4 +1,7 @@ using System.Diagnostics; +using System.Reflection; + +using ShopDbEndpointAgent.Core; namespace ShopDbEndpointAgent.Tray; @@ -44,10 +47,102 @@ internal sealed class TrayContext : ApplicationContext trayIcon.DoubleClick += OnSendStatus; } - private void OnReportIssue(object? sender, EventArgs e) + private async void OnReportIssue(object? sender, EventArgs e) { - using var dialog = new TicketForm(); - dialog.ShowDialog(); + string reason; + string details; + bool includeScreenshot; + using (var dialog = new TicketForm()) + { + if (dialog.ShowDialog() != DialogResult.OK) + return; + reason = dialog.Reason; + details = dialog.Details; + includeScreenshot = dialog.IncludeScreenshot; + } + + var agentExe = ResolveAgentExe(); + if (agentExe is null) + { + ShowBalloon("Report an issue", "Could not find " + AgentExeName + + ". Is the ShopDB Endpoint Agent installed?", ToolTipIcon.Error); + return; + } + + // Capture AFTER the dialog closes so the report form is never in the + // shot. Stage into the interactive operator's own profile + // (%USERPROFILE%\ShopDBScreenshots), NOT %TEMP%: on this locked-down + // image the tray process's TEMP resolves to a different account's + // AppData that ShopFloor cannot write, and AppData/Temp are aggressively + // ACL'd/redirected. The profile root is owned by the operator and, by + // inherited ACL, readable by SYSTEM (the agent service) so the handoff + // works. Files are kept for the operator to show a DT/specialist later; + // pruning is the agent's future server-driven Capture Cleanup job. + var storeDir = OperatorReportStoreDir(); + var stamp = DateTime.Now.ToString("yyyyMMdd-HHmmss"); + string? payloadPath = null; + try + { + Directory.CreateDirectory(storeDir); + string? screenshotPath = null; + if (includeScreenshot) + { + screenshotPath = Path.Combine(storeDir, "screenshot-" + stamp + ".png"); + ScreenCapture.CaptureToPng(screenshotPath); + } + + payloadPath = Path.Combine(storeDir, "report-" + stamp + ".json"); + OperatorReportPayload.Write(payloadPath, reason, details, screenshotPath); + + ShowBalloon("Report an issue", "Sending your report to ShopDB...", + ToolTipIcon.Info); + + int exitCode = await RunAgentAsync(agentExe, "--report \"" + payloadPath + "\""); + if (exitCode == 0) + ShowBalloon("Report an issue", "Your report was sent to ShopDB.", + ToolTipIcon.Info); + else + ShowBalloon("Report an issue", + "Report send failed (exit " + exitCode + + "). See the agent log under ProgramData\\ShopDB.", + ToolTipIcon.Warning); + } + catch (Exception error) + { + ShowBalloon("Report an issue", "Could not send the report: " + + error.Message, ToolTipIcon.Error); + } + finally + { + // Only the transient JSON sidecar is removed; the screenshot stays + // for the operator. The agent also deletes the sidecar on success, + // so this just covers the failure path. + if (payloadPath is not null) + TryDeleteFile(payloadPath); + } + } + + // The operator-visible folder screenshots and the report sidecar are staged + // into, under the interactive user's profile. Resolved in the tray (running + // as that user) and passed to the agent as an absolute path; the agent must + // never re-derive it, since as SYSTEM its profile would resolve elsewhere. + private static string OperatorReportStoreDir() + => Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + "ShopDBScreenshots"); + + private static void TryDeleteFile(string filePath) + { + try + { + if (File.Exists(filePath)) + File.Delete(filePath); + } + catch + { + // Best-effort; a leftover sidecar is harmless and the agent also + // removes it on a successful upload. + } } // Runs shopdbagent.exe --once out of process so the operator can push a @@ -74,7 +169,7 @@ internal sealed class TrayContext : ApplicationContext try { - int exitCode = await RunAgentOnceAsync(agentExe); + int exitCode = await RunAgentAsync(agentExe, "--once"); if (exitCode == 0) ShowBalloon("Send Status", "Status sent to ShopDB.", ToolTipIcon.Info); @@ -96,12 +191,12 @@ internal sealed class TrayContext : ApplicationContext } } - private static Task RunAgentOnceAsync(string agentExe) + private static Task RunAgentAsync(string agentExe, string arguments) { var startInfo = new ProcessStartInfo { FileName = agentExe, - Arguments = "--once", + Arguments = arguments, WorkingDirectory = Path.GetDirectoryName(agentExe) ?? Environment.CurrentDirectory, UseShellExecute = false, CreateNoWindow = true, @@ -181,9 +276,36 @@ internal sealed class TrayContext : ApplicationContext => 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.", + "Send Status now reports this PC to ShopDB on demand.\n\n" + + "Tray version: " + TrayVersion() + "\n" + + "Agent version: " + AgentVersion(), "About", MessageBoxButtons.OK, MessageBoxIcon.Information); + // The tray's own assembly version, the same value stamped from the csproj + // at build time. + private static string TrayVersion() + => Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "unknown"; + + // The installed agent service exe's file version, read from wherever the + // tray resolves it (beside the tray, then the default install dir). The + // service and tray can legitimately be different versions, so this is shown + // as its own line rather than assumed equal to the tray. Degrades to a + // clear note when the agent exe is not found or its version is unreadable. + private static string AgentVersion() + { + var agentExe = ResolveAgentExe(); + if (agentExe is null) + return "not found"; + try + { + return FileVersionInfo.GetVersionInfo(agentExe).FileVersion ?? "unknown"; + } + catch (FileNotFoundException) + { + return "not found"; + } + } + private void OnExit(object? sender, EventArgs e) { trayIcon.Visible = false; diff --git a/agent/ShopDbEndpointAgent.Tray/TrayIcon.cs b/agent/ShopDbEndpointAgent.Tray/TrayIcon.cs index dbc0957b..b00e8bb9 100644 --- a/agent/ShopDbEndpointAgent.Tray/TrayIcon.cs +++ b/agent/ShopDbEndpointAgent.Tray/TrayIcon.cs @@ -62,6 +62,22 @@ internal static class TrayIcon } } + // Writes the branded icon to an .ico file so a Start Menu / desktop shortcut + // can show the brand instead of the generic exe icon. Reuses the same GDI+ + // drawing as the tray so there is still one source of brand truth and no + // checked-in binary. 64px suits shortcut/large-icon views; the OS downscales. + public static void Export(string path, int size = 64) + { + using var icon = Create(size); + var directory = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(directory)) + { + Directory.CreateDirectory(directory); + } + using var stream = new FileStream(path, FileMode.Create, FileAccess.Write); + icon.Save(stream); + } + private static GraphicsPath RoundedRect(RectangleF rect, float radius) { float diameter = radius * 2; diff --git a/agent/ShopDbEndpointAgent.slnx b/agent/ShopDbEndpointAgent.slnx index e58da209..1855a388 100644 --- a/agent/ShopDbEndpointAgent.slnx +++ b/agent/ShopDbEndpointAgent.slnx @@ -3,4 +3,6 @@ + + diff --git a/agent/ShopDbEndpointAgent/Program.cs b/agent/ShopDbEndpointAgent/Program.cs index aa75f556..b003d9f6 100644 --- a/agent/ShopDbEndpointAgent/Program.cs +++ b/agent/ShopDbEndpointAgent/Program.cs @@ -10,6 +10,9 @@ using ShopDbEndpointAgent.Core; // (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) +// --report upload one operator issue report (JSON sidecar written by +// the tray), then exit. Keeps the collector key in the +// service so the tray never holds a secret. // // Secrets are never read here: AgentConfigProvider layers the registry // (HKLM\SOFTWARE\GE\ShopDB: CollectorKey, BaseUrl) over the shipped, non-secret @@ -19,6 +22,7 @@ using ShopDbEndpointAgent.Core; var runOnce = args.Any(a => string.Equals(a, "--once", StringComparison.OrdinalIgnoreCase)); var forceConsole = args.Any(a => string.Equals(a, "--console", StringComparison.OrdinalIgnoreCase)); +var reportPayloadPath = ArgValue(args, "--report"); var agentVersion = Assembly.GetExecutingAssembly() .GetName().Version?.ToString() ?? "0.0.0"; @@ -58,6 +62,9 @@ try if (runOnce) return await RunOnceAsync(config, agentVersion, logDirectory, faultMonitor); + if (!string.IsNullOrWhiteSpace(reportPayloadPath)) + return await RunReportAsync(config, reportPayloadPath, logDirectory, faultMonitor); + await RunHostAsync(config, agentVersion, logDirectory, forceConsole); return 0; } @@ -103,6 +110,61 @@ static async Task RunOnceAsync(AgentConfig config, string agentVersion, } } +// Uploads one operator issue report described by a tray-written JSON sidecar, +// then deletes the payload folder. Runs out of process from the tray so the +// collector key stays in the service binary. The exit code is the tray's +// contract: 0 = uploaded, non-zero = failed (detail in the agent log). +static async Task RunReportAsync(AgentConfig config, string payloadPath, + string logDirectory, AgentFaultMonitor faultMonitor) +{ + using var services = BuildOnceServices(config, "0.0.0", logDirectory); + var client = services.GetRequiredService(); + var logger = services.GetRequiredService>(); + try + { + var payload = OperatorReportPayload.Read(payloadPath); + var response = await client.UploadOperatorReportAsync( + payload.Reason, payload.Details, payload.ScreenshotPath, + Environment.MachineName, CancellationToken.None); + logger.LogInformation("Operator report uploaded: id={ReportId}", + response?.OperatorReportId); + return 0; + } + catch (Exception error) + { + faultMonitor.ReportFatal("--report", error); + return 1; + } + finally + { + // Remove only the transient JSON sidecar. The screenshot lives in the + // operator's %USERPROFILE%\ShopDBScreenshots folder and is deliberately + // KEPT so the operator can show it to a DT/specialist later; pruning it + // is the future server-driven Capture Cleanup job, not this path. (The + // sidecar previously sat in a throwaway temp folder we deleted whole; + // now it shares the operator's folder, so a recursive delete here would + // wipe saved screenshots.) + try + { + if (File.Exists(payloadPath)) + File.Delete(payloadPath); + } + catch { /* best-effort; the tray also removes the sidecar */ } + } +} + +// Read the value following a flag (e.g. --report C:\path\report.json). Returns +// null when the flag is absent or has no following token. +static string? ArgValue(string[] args, string flag) +{ + for (int i = 0; i < args.Length - 1; i++) + { + if (string.Equals(args[i], flag, StringComparison.OrdinalIgnoreCase)) + return args[i + 1]; + } + return null; +} + static ServiceProvider BuildOnceServices(AgentConfig config, string agentVersion, string logDirectory) { diff --git a/agent/ShopDbEndpointAgent/ShopDbEndpointAgent.csproj b/agent/ShopDbEndpointAgent/ShopDbEndpointAgent.csproj index 9c7d55bf..63e33106 100644 --- a/agent/ShopDbEndpointAgent/ShopDbEndpointAgent.csproj +++ b/agent/ShopDbEndpointAgent/ShopDbEndpointAgent.csproj @@ -7,7 +7,38 @@ enable ShopDbEndpointAgent shopdbagent - - 1.4.1 + 1.6.3 true win-x64 true diff --git a/agent/ShopDbEndpointAgent/appsettings.json b/agent/ShopDbEndpointAgent/appsettings.json index 162913e3..6db6ca67 100644 --- a/agent/ShopDbEndpointAgent/appsettings.json +++ b/agent/ShopDbEndpointAgent/appsettings.json @@ -7,20 +7,10 @@ "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" } - ] + "processes": [], + "services": [] }, - "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" - } + "launchpaths": {} } diff --git a/docker-compose.airgap.yml b/docker-compose.airgap.yml index 7354ccb9..edaba728 100644 --- a/docker-compose.airgap.yml +++ b/docker-compose.airgap.yml @@ -32,6 +32,10 @@ x-app-env: &app-env LOG_LEVEL: ${LOG_LEVEL:-INFO} ZABBIX_URL: ${ZABBIX_URL:-} ZABBIX_TOKEN: ${ZABBIX_TOKEN:-} + # Channel label for a distributable installer. Empty on the stable (main) + # bundle, 'DEV' on the edge (dev-be) bundle, which lights up the sidebar + # badge. Sourced from .env, which build-channel-installer.ps1 pre-fills. + SHOPDB_ENV_LABEL: ${SHOPDB_ENV_LABEL:-} services: db: diff --git a/docker-compose.channel.yml b/docker-compose.channel.yml new file mode 100644 index 00000000..2f16b22e --- /dev/null +++ b/docker-compose.channel.yml @@ -0,0 +1,27 @@ +# Channel overlay for side-by-side stable / bleeding-edge stacks. +# +# The base docker-compose.yml is channel-agnostic. This overlay adds only the +# pieces that differ between a stable (main) and an edge (dev-be) stack so the +# two can run at once on one dev workstation without colliding: +# +# - SHOPDB_ENV_LABEL is passed into the api container. When non-empty the +# public settings endpoint surfaces it as env_label and the sidebar renders +# a red DEV badge. Stable leaves it unset, so nothing renders. +# - Ports fall back to the SAME defaults as the base file, so running this +# overlay alone changes nothing; the bootstrap script overrides API_PORT / +# MYSQL_PORT for the edge stack so both channels bind distinct host ports. +# +# The stacks are isolated by compose project name (-p shopdb-stable / +# -p shopdb-edge), which namespaces containers, networks, and the named +# volumes, so each channel keeps its own database and instance state. +# +# Usage (driven by scripts/dev-bootstrap.ps1, shown here for reference): +# docker compose -p shopdb-edge \ +# -f docker-compose.yml -f docker-compose.channel.yml up -d + +services: + api: + environment: + # Empty on stable. The edge stack sets SHOPDB_ENV_LABEL=DEV in its .env + # (written by the bootstrap script) to light up the badge. + SHOPDB_ENV_LABEL: ${SHOPDB_ENV_LABEL:-} diff --git a/docker-compose.pull.yml b/docker-compose.pull.yml new file mode 100644 index 00000000..947cd047 --- /dev/null +++ b/docker-compose.pull.yml @@ -0,0 +1,95 @@ +# shopdb-flask PULL stack for dev workstations (stable / edge channels). +# +# WHY this exists: pilots behind an inspecting proxy cannot reliably BUILD the +# image from source (npm's many small fetches get reset mid-install). Instead +# CI (.github/workflows/publish-images.yml) builds the image on clean runners +# and publishes it to GHCR; this file just PULLS and RUNS it. One resumable +# image pull survives the proxy where hundreds of npm requests do not. +# +# Nothing is built here (no `build:`), mirroring docker-compose.airgap.yml: +# - api/migrate use `image:` (pulled from GHCR), never `build: .`. +# - NO ./plugins bind mount; the image carries every plugin baked in. +# - a one-shot `migrate` runs db upgrade + plugin upgrade-all + seed before +# api starts, so `up -d` alone brings up a working site. +# +# Driven by scripts/dev-bootstrap.ps1, which sets SHOPDB_IMAGE (the channel +# image ref), SHOPDB_ENV_LABEL, API_PORT and MYSQL_PORT per channel and picks a +# compose project name (-p shopdb-stable / -p shopdb-edge) so the two channels +# run side by side without colliding. + +x-app-env: &app-env + FLASK_APP: wsgi.py + FLASK_ENV: production + DATABASE_URL: mysql+pymysql://shopdb:${MYSQL_PASSWORD}@db:3306/shopdb_flask?charset=utf8mb4 + SECRET_KEY: ${SECRET_KEY:?SECRET_KEY must be set} + JWT_SECRET_KEY: ${JWT_SECRET_KEY:?JWT_SECRET_KEY must be set} + CORS_ORIGINS: ${CORS_ORIGINS:?CORS_ORIGINS must be set} + LOG_LEVEL: ${LOG_LEVEL:-INFO} + # Empty on stable; 'DEV' on edge, which lights the sidebar badge. + SHOPDB_ENV_LABEL: ${SHOPDB_ENV_LABEL:-} + +services: + db: + image: mysql:8.4 + command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci + restart: unless-stopped + environment: + MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:?MYSQL_ROOT_PASSWORD must be set} + MYSQL_DATABASE: shopdb_flask + MYSQL_USER: shopdb + MYSQL_PASSWORD: ${MYSQL_PASSWORD:?MYSQL_PASSWORD must be set} + volumes: + - db_data:/var/lib/mysql + ports: + - "127.0.0.1:${MYSQL_PORT:-3306}:3306" + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-u", "root", "-p${MYSQL_ROOT_PASSWORD}"] + interval: 10s + timeout: 5s + retries: 5 + + # One-shot schema + seed. Runs to completion and exits; api waits for it. + # Every step is idempotent, so it is safe to run on every `up`. + migrate: + image: ${SHOPDB_IMAGE:?SHOPDB_IMAGE must be set to the channel image ref} + pull_policy: always + restart: "no" + depends_on: + db: + condition: service_healthy + environment: + <<: *app-env + command: + - sh + - -c + - > + flask db upgrade && + flask plugin upgrade-all && + flask seed permissions && + flask seed settings && + flask seed reference-data + volumes: + - instance_data:/app/instance + + api: + image: ${SHOPDB_IMAGE:?SHOPDB_IMAGE must be set to the channel image ref} + pull_policy: always + restart: unless-stopped + depends_on: + db: + condition: service_healthy + migrate: + condition: service_completed_successfully + environment: + <<: *app-env + ports: + # Loopback only, same as the DB below. This stack generates its own + # dev secrets and may hold a real imported dataset; it has no business + # listening on every interface of a laptop on a shop-floor network. + - "127.0.0.1:${API_PORT:-5001}:5001" + volumes: + - instance_data:/app/instance + +volumes: + db_data: + instance_data: diff --git a/docker-compose.yml b/docker-compose.yml index dc2e63e3..3d28a3ff 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -99,6 +99,11 @@ services: retries: 3 volumes: - ./plugins:/app/plugins:ro + # Standalone operator scripts shipped inside generated bundles (the + # endpoint-agent uninstaller). Read-only, same pattern as plugins: the + # site-agent packaging route reads uninstall-shopdbagent.ps1 from here at + # download time, so it must be visible in the container. + - ./scripts:/app/scripts:ro # Self-signed TLS material for the dev/test https listener. Read-only; # generated on the host under ./certs and never baked into the image. - ./certs:/app/certs:ro diff --git a/docs/openapi.json b/docs/openapi.json index 3d20663c..180739c7 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -21826,8 +21826,8 @@ "tags": [ "plugin-endpoints" ], - "summary": "List endpoints reporting through the ShopDB agent, with last-seen and online state", - "description": "List endpoints reporting through the ShopDB agent, with last-seen and online state\n\n**Auth:** endpoints.view\n\n**Params:** query: search (hostname substring)\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/endpoints\n```", + "summary": "List endpoint states. Registered both with and without the trailing slash, so both forms are described here", + "description": "List endpoint states. Registered both with and without the trailing slash, so both forms are described here\n\n**Auth:** endpoints.view\n\n**Params:** query: search (hostname substring)\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/endpoints\n```", "security": [ { "bearerAuth": [] @@ -22718,6 +22718,432 @@ } } }, + "/api/endpoints/monitors": { + "get": { + "tags": [ + "plugin-endpoints" + ], + "summary": "List every monitor definition, shipped and local, ordered shipped-first then by display name", + "description": "List every monitor definition, shipped and local, ordered shipped-first then by display name\n\n**Auth:** jwt + require_permission endpoints.view\n\n**Params:** none\n\n**Example:**\n```\ncurl http://localhost:5001/api/endpoints/monitors -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + } + }, + "post": { + "tags": [ + "plugin-endpoints" + ], + "summary": "Create a local monitor definition; source is forced to 'local' so a caller cannot mint a shipped row", + "description": "Create a local monitor definition; source is forced to 'local' so a caller cannot mint a shipped row\n\n**Auth:** jwt + require_role admin\n\n**Params:** body: kind (process|service, required), displayname (required), processname (required for process), servicename (required for service), launchpath, datacollectorpath, logcollectorpath, isactive (default true)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/endpoints/monitors -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{\"kind\":\"service\",\"displayname\":\"Spooler\",\"servicename\":\"Spooler\"}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "body: kind (process|service, required), displayname (required), processname (required for process), servicename (required for service), launchpath, datacollectorpath, logcollectorpath, isactive (default true)" + } + } + } + } + } + }, + "/api/endpoints/monitors/{monitor_id}": { + "put": { + "tags": [ + "plugin-endpoints" + ], + "summary": "Update a local monitor definition; 403 on a shipped row, which is product-owned and read-only", + "description": "Update a local monitor definition; 403 on a shipped row, which is product-owned and read-only\n\n**Auth:** jwt + require_role admin\n\n**Params:** path: monitor_id; body: same fields as create, all optional\n\n**Example:**\n```\ncurl -X PUT http://localhost:5001/api/endpoints/monitors/4 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{\"isactive\":false}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "monitor_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "path: monitor_id; body: same fields as create, all optional" + } + } + } + } + }, + "delete": { + "tags": [ + "plugin-endpoints" + ], + "summary": "Delete a local monitor definition; 403 on a shipped row", + "description": "Delete a local monitor definition; 403 on a shipped row\n\n**Auth:** jwt + require_role admin\n\n**Params:** path: monitor_id\n\n**Example:**\n```\ncurl -X DELETE http://localhost:5001/api/endpoints/monitors/4 -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "monitor_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ] + } + }, + "/api/endpoints/monitors/resolve": { + "get": { + "tags": [ + "plugin-endpoints" + ], + "summary": "Resolve the monitor set a PC type should watch: shipped defaults for 'Shopfloor PC' plus whatever active site policy...", + "description": "Resolve the monitor set a PC type should watch: shipped defaults for 'Shopfloor PC' plus whatever active site policy attaches to that type. The human-facing view of what the agent will be told\n\n**Auth:** jwt + require_permission endpoints.view\n\n**Params:** query: pctype (required)\n\n**Example:**\n```\ncurl 'http://localhost:5001/api/endpoints/monitors/resolve?pctype=Shopfloor+PC' -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + } + } + }, + "/api/endpoints/monitors/resolved": { + "get": { + "tags": [ + "plugin-endpoints" + ], + "summary": "Agent runtime watch-list. The agent calls this each telemetry cycle and receives {processes[], services[]} shaped like...", + "description": "Agent runtime watch-list. The agent calls this each telemetry cycle and receives {processes[], services[]} shaped like its own MonitorConfig, so a policy-attached monitor is watched without editing appsettings.json on the bay. Unknown hostname is a 404; a PC type with no monitors returns empty lists\n\n**Auth:** endpoints collector API key (X-API-Key/Bearer), not JWT\n\n**Params:** query: hostname (required)\n\n**Example:**\n```\ncurl 'http://localhost:5001/api/endpoints/monitors/resolved?hostname=F7MHPF24' -H \"X-API-Key: $ENDPOINTS_KEY\"\n```", + "security": [ + { + "apiKeyAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + } + } + }, + "/api/endpoints/operatorreports": { + "post": { + "tags": [ + "plugin-endpoints" + ], + "summary": "Agent uploads one operator issue report captured from the tray. The hostname must resolve to a computer asset. The...", + "description": "Agent uploads one operator issue report captured from the tray. The hostname must resolve to a computer asset. The screenshot is stored under a server-generated uuid filename; a retention sweep runs opportunistically so the feed stays bounded without a scheduler\n\n**Auth:** endpoints collector API key (X-API-Key/Bearer), not JWT\n\n**Params:** multipart/form-data: hostname (required), reason (default 'Other (free text)', truncated to 128), details, screenshot (png, 10 MB cap)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/endpoints/operatorreports -H \"X-API-Key: $ENDPOINTS_KEY\" -F hostname=F7MHPF24 -F 'details=Machine will not home' -F screenshot=@shot.png\n```", + "security": [ + { + "apiKeyAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "multipart/form-data: hostname (required), reason (default 'Other (free text)', truncated to 128), details, screenshot (png, 10 MB cap)" + } + } + } + } + } + }, + "/api/endpoints/panel/{asset_id}/operatorreports": { + "get": { + "tags": [ + "plugin-endpoints" + ], + "summary": "Operator Reports asset panel: this endpoint's reports newest first, each carrying a hasimage flag so the viewer knows...", + "description": "Operator Reports asset panel: this endpoint's reports newest first, each carrying a hasimage flag so the viewer knows whether to request the screenshot\n\n**Auth:** jwt + require_permission endpoints.view\n\n**Params:** path: asset_id\n\n**Example:**\n```\ncurl http://localhost:5001/api/endpoints/panel/46/operatorreports -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "asset_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ] + } + }, + "/api/endpoints/operatorreports/{operatorreportid}/image": { + "get": { + "tags": [ + "plugin-endpoints" + ], + "summary": "Serve one operator report's screenshot, hardened with the shared upload headers (CSP sandbox + nosniff); 404 when the...", + "description": "Serve one operator report's screenshot, hardened with the shared upload headers (CSP sandbox + nosniff); 404 when the report carries no image\n\n**Auth:** jwt + require_permission endpoints.view\n\n**Params:** path: operatorreportid\n\n**Example:**\n```\ncurl http://localhost:5001/api/endpoints/operatorreports/12/image -H \"Authorization: Bearer $TOK\" -o shot.png\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "operatorreportid", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ] + } + }, "/api/scripts": { "get": { "tags": [ diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index 6bc0f1b3..57fcc970 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -178,6 +178,25 @@ export const endpointsApi = { }, telemetryMeta(assetId) { return api.get(`/endpoints/asset/${assetId}/telemetrymeta`) + }, + // Monitor catalog: reusable process/service definitions. Shipped rows are + // product-owned and read-only; local rows are user-built on the Settings page. + monitors: { + list() { + return api.get('/endpoints/monitors') + }, + create(data) { + return api.post('/endpoints/monitors', data) + }, + update(monitorid, data) { + return api.put(`/endpoints/monitors/${monitorid}`, data) + }, + remove(monitorid) { + return api.delete(`/endpoints/monitors/${monitorid}`) + }, + resolve(pctype) { + return api.get('/endpoints/monitors/resolve', { params: { pctype } }) + } } } diff --git a/frontend/src/assets/style.css b/frontend/src/assets/style.css index 7cf95e64..18bd2c44 100644 --- a/frontend/src/assets/style.css +++ b/frontend/src/assets/style.css @@ -1068,17 +1068,28 @@ td.actions .btn + .btn { } /* Content Grid */ -/* Balanced two-column card flow. Uses CSS multicol (not a hand-assigned - grid) so cards distribute by height and the columns stay even no matter - how many cards land on either side. display:contents flattens the two - .content-column wrappers so their cards flow directly into the columns, - which keeps the existing markup unchanged. */ +/* Two-column card layout on an explicit CSS grid rather than multicol. + Multicol was abandoned because a child using column-span: all (the full + width Network Interfaces panel) overlaps the shorter column above it when + the two columns have unequal heights - a known multicol spanning bug. Grid + has no such flaw: short fact cards sit two-up and a full-width panel spans + both tracks cleanly via .pap-span. display:contents flattens the two + .content-column wrappers so their cards become direct grid items, which + keeps the existing markup unchanged. */ .content-grid { - column-count: 2; - column-gap: 25px; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 25px; + align-items: start; margin-bottom: 25px; } +/* Grid supplies the gap; the per-card margin was a multicol-only crutch and + would otherwise double the vertical spacing. */ +.content-grid > .section-card { + margin-bottom: 0; +} + .content-column { display: contents; } @@ -1522,7 +1533,7 @@ td.actions .btn + .btn { } .content-grid { - column-count: 1; + grid-template-columns: 1fr; } .audit-footer { diff --git a/frontend/src/components/PluginAssetPanels.vue b/frontend/src/components/PluginAssetPanels.vue index 9782f2af..b199931e 100644 --- a/frontend/src/components/PluginAssetPanels.vue +++ b/frontend/src/components/PluginAssetPanels.vue @@ -143,21 +143,205 @@ {{ b.label }} + + +
+ +
+ {{ panel.empty || 'No operator reports.' }} +
+
+ + + diff --git a/plugins/computers/frontend/views/BusinessPoliciesList.vue b/plugins/computers/frontend/views/BusinessPoliciesList.vue index 4a6b20ae..e2f3b971 100644 --- a/plugins/computers/frontend/views/BusinessPoliciesList.vue +++ b/plugins/computers/frontend/views/BusinessPoliciesList.vue @@ -1,8 +1,8 @@